Merge origin/master into codex/dsh-badge-plugin
This commit is contained in:
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md
|
||||
2026-07-28-dsh-native-typescript-source-launch.md: b5e8ed2a18bb0cbb3ab54cf5ed4a427efaa9dfeb
|
||||
2026-07-28-dsh-native-typescript-source-launch.zh.md: 05a4f92d2d3be553bb9ca363bf08e3ebf8e3f5b8
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Native TypeScript source launch for dsh
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
English | [中文](2026-07-28-dsh-native-typescript-source-launch.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: dsh 原生 TypeScript 源码启动
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
[English](2026-07-28-dsh-native-typescript-source-launch.md) | 中文
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md
|
||||
2026-07-27-question-composer-rows-do-not-shrink.md: 47e581a23caaeb9368b075fa84a01d2bc945ab46
|
||||
2026-07-27-question-composer-rows-do-not-shrink.zh.md: a1b72cab41c51c268540dcec60e85a0edf2fe9bc
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Question-composer option rows are scroll content, not the slack absorber
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
English | [中文](2026-07-27-question-composer-rows-do-not-shrink.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 提问 composer 的选项行是滚动内容,而非空间不足时的吸收方
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
[English](2026-07-27-question-composer-rows-do-not-shrink.md) | 中文
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.md
|
||||
2026-07-28-web-conversation-polish-sweep.md: 338b687c041c585c1d490fdad9b8bbcf88fc3912
|
||||
2026-07-28-web-conversation-polish-sweep.zh.md: 166662070b67837b9f4755e6457578f74af771ed
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Web conversation UI polish sweep
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
English | [中文](2026-07-28-web-conversation-polish-sweep.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Web 对话 UI 视觉优化
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
[English](2026-07-28-web-conversation-polish-sweep.md) | 中文
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.md
|
||||
2026-07-30-web-details-default-closed.md: e4271917b998c9a916d8c67b30627022587547b8
|
||||
2026-07-30-web-details-default-closed.zh.md: 3b9432067c944ea604e039ee1b305a927a1c206e
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Web details default closed
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
English | [中文](2026-07-30-web-details-default-closed.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Web 详情栏默认关闭
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
[English](2026-07-30-web-details-default-closed.md) | 中文
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md
|
||||
2026-07-31-hero-visible-while-blank-session-opens.md: 1b1f35d82731675978585d718e4ef837f0c78aa5
|
||||
2026-07-31-hero-visible-while-blank-session-opens.zh.md: 91c1a372ebc6341632820450d9e54d58c9d36916
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Hero stays visible while a blank session opens
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
English | [中文](2026-07-31-hero-visible-while-blank-session-opens.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 空白会话打开期间保持 hero 可见
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
[English](2026-07-31-hero-visible-while-blank-session-opens.md) | 中文
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.md
|
||||
2026-07-22-docked-web-goal-bar.md: decf40996b51a0f2358a943bbb928f00d4db2026
|
||||
2026-07-22-docked-web-goal-bar.zh.md: 8ebb106b260a6107084c07b51b1763adb1df2da8
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Docked web goal bar
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
English | [中文](2026-07-22-docked-web-goal-bar.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 停靠式 Web 目标条
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
[English](2026-07-22-docked-web-goal-bar.md) | 中文
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.md
|
||||
2026-07-29-web-message-icon-actions-and-clock.md: a95c7d33a917026c882f17d30264cf9ec743dee5
|
||||
2026-07-29-web-message-icon-actions-and-clock.zh.md: b64c14aaa7056e19ccc4d512db3d24714e11a309
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Web message IconActions and clocks
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
English | [中文](2026-07-29-web-message-icon-actions-and-clock.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Web 消息 IconActions 与时钟
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
[English](2026-07-29-web-message-icon-actions-and-clock.md) | 中文
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-30-dsh-dump-config.md
|
||||
2026-07-30-dsh-dump-config.md: cc16f11d79b536a661d67811c6fd50705f6009e3
|
||||
2026-07-30-dsh-dump-config.zh.md: 185a2b8f37cef102b48d4dea1b3ed0a96958d220
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: dsh --dump-config prints the composed config tree
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
English | [中文](2026-07-30-dsh-dump-config.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: dsh --dump-config 打印合成后的配置树
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
[English](2026-07-30-dsh-dump-config.md) | 中文
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.md
|
||||
2026-07-30-web-composer-stats-and-input-polish.md: 3c80b74c564a1779c69ef525f8d1f194e8915f6b
|
||||
2026-07-30-web-composer-stats-and-input-polish.zh.md: eabb174e78f3171997b9103650c7fe326793ce1b
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Web composer stats detail and input-zone polish
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
English | [中文](2026-07-30-web-composer-stats-and-input-polish.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Web composer stats detail and input-zone polish
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
[English](2026-07-30-web-composer-stats-and-input-polish.md) | 中文
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md
|
||||
2026-07-30-web-context-injection-disclosure.md: e9551cacdcd5b3e45ba35eeb76db6c70e5bbe368
|
||||
2026-07-30-web-context-injection-disclosure.zh.md: a4937ba880df30b911f7c8a1eceaab7e2bb74026
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Web context injection disclosure
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
English | [中文](2026-07-30-web-context-injection-disclosure.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Web 上下文注入展开项
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
[English](2026-07-30-web-context-injection-disclosure.md) | 中文
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md
|
||||
2026-07-31-hover-card-click-copy.md: 906f64129ee9ba767859260a3288faada21ea4de
|
||||
2026-07-31-hover-card-click-copy.zh.md: 0359b2edacde9e048db900d315b6a7befd361075
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Hover cards copy their primary value on activation
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
English | [中文](2026-07-31-hover-card-click-copy.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 悬浮卡片激活时复制主要值
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
[English](2026-07-31-hover-card-click-copy.md) | 中文
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-07-31-web-cards-toolrow.md
|
||||
2026-07-31-web-cards-toolrow.md: 9bdf5d4e8917178ec27ea5f5d24af753c0d10eab
|
||||
2026-07-31-web-cards-toolrow.zh.md: ce473a8a34ba8b1bb022d681244508c56a1084f3
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Card tool rows collapse through one ToolRow
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
English | [中文](2026-07-31-web-cards-toolrow.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 卡片工具行通过同一个 ToolRow 折叠
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
[English](2026-07-31-web-cards-toolrow.md) | 中文
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
"architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml": "sha256:1eb43c420a21b7a3adf0aa5274d9aa597187630a29d7e535c5e266f82e803665",
|
||||
"architecture/2026-07-28-consolidated-tui-presentation.md": "sha256:e6fa4ea0c9d1d94942ab98de47c554f4e8aa3b639a1cce52113107b1dbb0f4b0",
|
||||
"architecture/2026-07-28-consolidated-tui-presentation.zh.md": "sha256:01814434482a84ebd7f672eb5c26fc468b568e773452563bf39ba52ad25d054a",
|
||||
"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",
|
||||
"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",
|
||||
@@ -61,6 +64,9 @@
|
||||
"bug-fix/2026-07-26-intent-draft-same-tick-echo.i18n.yaml": "sha256:c623947c4fa00e6d4b51792c7972ba09582bbcb7605beb373725c0dd666f2c81",
|
||||
"bug-fix/2026-07-26-intent-draft-same-tick-echo.md": "sha256:fa8b1417b2cdd3deecbf8e55bdddd73dd3a8c6e3486fd399b0b8bdf317e56373",
|
||||
"bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md": "sha256:00ce72552dbaa11562fbc541343a5d33f9449edabbe6dd354eb879a7d4d530f8",
|
||||
"bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml": "sha256:9b8fd6c3fc5f6527890d74a70372de90ade6db5fa957246d4bbdc06ee06e072c",
|
||||
"bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md": "sha256:0411033becc2835ce53cd268c9fa149830274c9f61016514087bea89562cfbb8",
|
||||
"bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md": "sha256:41f024f8b7a4587a92026b4d36d77879086bc86716b0b28bf2131b85db35ee75",
|
||||
"bug-fix/2026-07-27-tool-card-single-row-fields-inline.i18n.yaml": "sha256:4b94aded16c60628d22414dce524e8a98a8af4fff298805ee7efc63cae02c90d",
|
||||
"bug-fix/2026-07-27-tool-card-single-row-fields-inline.md": "sha256:40adcd522a9a2eeacc6f2b0196d1f24888a4f57830b7490a3be3d78c86c4e968",
|
||||
"bug-fix/2026-07-27-tool-card-single-row-fields-inline.zh.md": "sha256:a79d56c9b781442ee596b47707d1a8c80abcd6466094b01802189c8e55f16da7",
|
||||
@@ -70,9 +76,18 @@
|
||||
"bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.i18n.yaml": "sha256:280b93ece72662501f65edd58a00cdafb5b5941e4ef1314d7198fab18950cb03",
|
||||
"bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md": "sha256:112bdbde16b6023eeb5b8a79cd2a711385e7198d51bbfc0520e9612acaa95c8a",
|
||||
"bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.zh.md": "sha256:fc4e7f778ea63c4583cf81132c264cf6c4b9cc3e1818778061b0497ff16b8ef6",
|
||||
"bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml": "sha256:27e60a7d822b201dee65eebc8335da8edd416ee19b9b89c056302bae69c830fd",
|
||||
"bug-fix/2026-07-28-web-conversation-polish-sweep.md": "sha256:92647f06202f8711107918da9d9947767385a8b6307e772b1e868b0b1cb07412",
|
||||
"bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md": "sha256:95ccc18f4b35396bdcabee8b8745e75008f6c07f829fd8719bbb35d77ac0fd95",
|
||||
"bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml": "sha256:50b7a32e11591719c249258ecc2ec0f45e58f1a04050d2e53f6e2650f58ba137",
|
||||
"bug-fix/2026-07-30-tui-adapter-registration-race.md": "sha256:7e17eb1dd8f92e1efb7a18477df277b13580840b473ffe8a5309fc70ec3cfa3e",
|
||||
"bug-fix/2026-07-30-tui-adapter-registration-race.zh.md": "sha256:efcbd3d82af6a58677efe1a0580edd715945b6a93418fde47badac9c01a29866",
|
||||
"bug-fix/2026-07-30-web-details-default-closed.i18n.yaml": "sha256:2af5559d727f3e4afdd4946eaf89ac212c81db611db78dbd9bfabb1c4661db17",
|
||||
"bug-fix/2026-07-30-web-details-default-closed.md": "sha256:27a280a817c8048718bb22927e7d9572cf99ffd0c044631e99e0fd6ea236876f",
|
||||
"bug-fix/2026-07-30-web-details-default-closed.zh.md": "sha256:e047c7d02cf4b95b0c7f78f4b79af254091294b05cc75e98a8bb860ae2074189",
|
||||
"bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml": "sha256:42218a762ce0141d3cb43deb6c688d3705cdc4405e03851d486c78f3d25b70ef",
|
||||
"bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md": "sha256:a40992e89736131f5c487e5357848f14accd06e135dbec9ce242c968a5b11d43",
|
||||
"bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md": "sha256:e0cc576bc1c196affc9220ddabf15d735c347029c530c56454f0e585979101e1",
|
||||
"bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml": "sha256:cd39ae2646fdc6827bf29a63953b5463faa37d5b404ae8cc3c0913c47bc92d0c",
|
||||
"bug-fix/2026-07-31-tui-diff-context-line-accounting.md": "sha256:57066bccd22c2dc2c3546b363de73d13b55ff8683ee12b17a81ed2bcf536645b",
|
||||
"bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md": "sha256:a658d886c5eb203f5f30a6fac70ad18e4a24cf756746254723d8f1d144653c04",
|
||||
@@ -157,6 +172,9 @@
|
||||
"feature/2026-07-21-tui-verbose-status-line.i18n.yaml": "sha256:4371b9a46d713d4180aa5d0b1ecde1ff3cae948380a8f56474c895e6113d7824",
|
||||
"feature/2026-07-21-tui-verbose-status-line.md": "sha256:9dcba19ee725b1593e9413a1da5398c205a258aff2e384acd406bb618e86c7f0",
|
||||
"feature/2026-07-21-tui-verbose-status-line.zh.md": "sha256:203c2abac99cedf7afa2540c925367ba66f00b61b926d1cc86472a603ad2bb07",
|
||||
"feature/2026-07-22-docked-web-goal-bar.i18n.yaml": "sha256:4fae22f5b921ae37feda632addace14bab8d1578c861228dd1df89a7b579f057",
|
||||
"feature/2026-07-22-docked-web-goal-bar.md": "sha256:94a4b00afc231eddd0fdcf6a494157d12a12b3ae40e8d732c3c05c6811b1c1f8",
|
||||
"feature/2026-07-22-docked-web-goal-bar.zh.md": "sha256:90dfa4f855a810bce6d57157049eacd6b0d9aa45e99fc296d9024adae4ebac7a",
|
||||
"feature/2026-07-23-trajectory-step-cell.i18n.yaml": "sha256:fe2e935a0affdef877902a40d9861ef5f55b30f40650469f6a52a4d45a92793f",
|
||||
"feature/2026-07-23-trajectory-step-cell.md": "sha256:185e3b87174cb6d2f2d2271fd2a74b1517d03e8570be602570d027bf6002d106",
|
||||
"feature/2026-07-23-trajectory-step-cell.zh.md": "sha256:51f46be43d2f5c4f78a05ed9aeec92d1f33ac988f45cf24d35528e9c43828ef3",
|
||||
@@ -205,24 +223,45 @@
|
||||
"feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml": "sha256:0865835802348b730542adbe6b7db613750f3786993c6a14dbb2f47686c13c70",
|
||||
"feature/2026-07-29-tui-hidden-mode-assistant-fold.md": "sha256:a5fefebd802e2d9c3c79c7852c1c34c7bbef3f2ac2150d224608b9ec44e966ad",
|
||||
"feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md": "sha256:21bccd1e07ec8dc73b618f428461848bb90b6235afe0b842afb0afab2d5cc575",
|
||||
"feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml": "sha256:59b09ed3c94f9b706b5ba8b567e264d9a6f4ff396245a8f4435ddfe5f4af3620",
|
||||
"feature/2026-07-29-web-message-icon-actions-and-clock.md": "sha256:c4f56f6681f7fa5fdef5354cdd1d0d00ae7e77f4ebcfae6015579b8a4e9ad712",
|
||||
"feature/2026-07-29-web-message-icon-actions-and-clock.zh.md": "sha256:6f8522382f644467ba39c7c0f15583de30a79ac17305cdba94c4195169cb0dc3",
|
||||
"feature/2026-07-30-compaction-progress-visibility.i18n.yaml": "sha256:4c2267054ad5d73aecc8d39d138a0cb532981b33175252e67252d07c3314b0f5",
|
||||
"feature/2026-07-30-compaction-progress-visibility.md": "sha256:2dfe07244cd784f27a9e5850801d40e96eae21a20ba10aaf56f9a793cdf5b505",
|
||||
"feature/2026-07-30-compaction-progress-visibility.zh.md": "sha256:6180b8aff0536147ab6ed6a78ecdbe1448fd12d89407746ecb1c7c05c73d4d60",
|
||||
"feature/2026-07-30-dsh-dump-config.i18n.yaml": "sha256:b400c8cce902328989e5493301f66451de7a36e966d69c43b20935fc635aca0f",
|
||||
"feature/2026-07-30-dsh-dump-config.md": "sha256:85b81dd517aaa6bb7510780acd961739c6da223bf2ddf6747d62f7a74e652d1f",
|
||||
"feature/2026-07-30-dsh-dump-config.zh.md": "sha256:d0d55947bcb0ef53d534844c15928ae26eebe3fb6d43f98ab176ec36a8eac640",
|
||||
"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-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",
|
||||
"feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml": "sha256:eb0f02b1e15cb127618c82871fcea1b3ba4a9a5db670615072f15c71864729ea",
|
||||
"feature/2026-07-30-web-composer-stats-and-input-polish.md": "sha256:68d0c0486219d8e886db07c4cde2285e515adf9756c1e4bc94e4bbd5cbcc2c93",
|
||||
"feature/2026-07-30-web-composer-stats-and-input-polish.zh.md": "sha256:679565f7a2e183ac71132bd1cbd9d2418d77fe0e5472224b15bd310dd3931f4b",
|
||||
"feature/2026-07-30-web-context-injection-disclosure.i18n.yaml": "sha256:83a8b78b0140afc8f18034674b594b998099c162ed9b9d939cb99e49cea2272b",
|
||||
"feature/2026-07-30-web-context-injection-disclosure.md": "sha256:00f869b29861ff8f30e64ea3ca65aa9f8a06b33be205453ea3b679e5dfe05c4d",
|
||||
"feature/2026-07-30-web-context-injection-disclosure.zh.md": "sha256:9bca10469e4d77b20c3785ce7ab6cbc628a03b8919de8a47c14466c1dbc94d36",
|
||||
"feature/2026-07-31-experimental-subcommand-gate.i18n.yaml": "sha256:d223669bebbf6ea65b4ec636e8e7ed618eff389117335946be713897151c6968",
|
||||
"feature/2026-07-31-experimental-subcommand-gate.md": "sha256:8fdee37340f7e72397cf2f440a2ca70639a987d07f0c2102e02e79c0fec4bfeb",
|
||||
"feature/2026-07-31-experimental-subcommand-gate.zh.md": "sha256:bcdec0f82319670a1d1de54a27b103b5e2d86306b884a5f9f415b89cd5a373f4",
|
||||
"feature/2026-07-31-hover-card-click-copy.i18n.yaml": "sha256:2b95987c23e13a4499f5f3851770e8f97aa6f6df457a36b6aa818a8db08785c9",
|
||||
"feature/2026-07-31-hover-card-click-copy.md": "sha256:f9a85c1603dcbdd36d26f730bf2a1f7bfaaa2267c7c30bee08cb9a94a7ce774e",
|
||||
"feature/2026-07-31-hover-card-click-copy.zh.md": "sha256:b01e6edda5c6b031b5265ca0d868583fecce04cbc817fa7e8cc4433d10056e64",
|
||||
"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",
|
||||
"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",
|
||||
"process/2026-06-11-tsdown-over-dumble.i18n.yaml": "sha256:22791adb84a4b6c545173d4f1708eea51151d57e426d875e0e5423be9b6e0212",
|
||||
"process/2026-06-11-tsdown-over-dumble.md": "sha256:8d3c35dddd8869cc3361059dfe4b7b8ab6716d29dda232c97c2f37e92c841dc0",
|
||||
"process/2026-06-11-tsdown-over-dumble.zh.md": "sha256:cf11c651c13f5ffef5474e7795006ba3653c5eb08eae75a879be6499353455dc",
|
||||
"process/2026-06-20-generated-cordis-catalog.i18n.yaml": "sha256:5250aaec698b25bdf5e3a02f67e531793fc968f9c38348f0c8bd11418571967f",
|
||||
"process/2026-06-20-generated-cordis-catalog.md": "sha256:1f3190b759bf1445b35f25f2f18ac1c8b16d7f2e9263e2627bf3c94fac54d275",
|
||||
"process/2026-06-20-generated-cordis-catalog.zh.md": "sha256:5d46da71bd73bba62ba15a9f11b21da422dbecc8ed68b6f530ceb302e1935ddb",
|
||||
"process/2026-07-03-documentation-graph-atlas.i18n.yaml": "sha256:b1e1ed4b7865d87f939dbf8c94c0ea1069fdf7af6fa68f695e6c9d6eccbeb123",
|
||||
"process/2026-07-03-documentation-graph-atlas.md": "sha256:b62e92bb12123bfa4c4dac806f584aabb6b60af4c5a6a4ab88f84bb9153e766d",
|
||||
"process/2026-07-03-documentation-graph-atlas.zh.md": "sha256:3485ede4a5e695643bcf9e744a62f8914cff788ae35717dac5eb6bf77e0d65cf",
|
||||
@@ -322,9 +361,21 @@
|
||||
"simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml": "sha256:7acf002ea8c1533f052c7bfc0c4e3da013ecf43c5872866a3ee4a8c2691c5e33",
|
||||
"simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md": "sha256:f18a913096b7defd2192c4bac888a33f68075c3662703a0e28a6146897d17777",
|
||||
"simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md": "sha256:ff48a37673c97059536fe5b61aff746133eac682145550badb049eb5c83b097c",
|
||||
"simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml": "sha256:534abd90ddde9ccd35ab7e595de4242d8fa30a75908a638e5b3290f749553e5a",
|
||||
"simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md": "sha256:ce449c72ed09238ba5dbe6068689db13bd33225694b2ad2c7696541f32dc0eec",
|
||||
"simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md": "sha256:2f66b407b626f1f8c661d849b3b359c959e690715e0ac3f61f7e3d57d59e89c3",
|
||||
"simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml": "sha256:6df56f5f5639847f0fac445abb5fea8a07f3cf9a0703d9022e50728c8f5055ca",
|
||||
"simplification/2026-07-26-turndown-for-tool-web-html-markdown.md": "sha256:344c5cc2a1e79287eeda6996ae417dd2e02a7545987df0f2c9016b20ae094d93",
|
||||
"simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md": "sha256:8c4f2ac12ccd23f7ada90694502f5da689344e6bba72080a89672aa9b4f1903a",
|
||||
"simplification/2026-07-27-copyable-transcript-no-gutter-bar.i18n.yaml": "sha256:821f96f3e203e03b80553c07b10a511926bb5014be95c7df6bffb30c8e226d31",
|
||||
"simplification/2026-07-27-copyable-transcript-no-gutter-bar.md": "sha256:4b6aa150bbc8a4da0acac4d20f5fb8c2b77fef7e9c4c4dba8fd8e84dec36d619",
|
||||
"simplification/2026-07-27-copyable-transcript-no-gutter-bar.zh.md": "sha256:5225e627ff301be171434a5b9f18905fe1578f50eb2d4bf9998e126aba6cc3e3",
|
||||
"simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml": "sha256:ad42430fef4a5db610f8c56acfcda79b40396418694f664a5b0fb093bff1f114",
|
||||
"simplification/2026-07-30-sidebar-resize-without-visible-pill.md": "sha256:6f2cfc5121371ec19c7178b31777c223e16bebdc9b63b7654a61cadc4a765b63",
|
||||
"simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md": "sha256:ab859eeb12c6a74da3c37d411af52fce37bed3195941570b2f23ccd9a00d55fe",
|
||||
"simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml": "sha256:531c446f0e95054f8ced17be9a180f8b0a823f7e9d5ce466c94c2f9cff90a111",
|
||||
"simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md": "sha256:a35a6372aabdf7cbc211f1bd5820d85d3467c9ed50f84e05caa3339382379ce7",
|
||||
"simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md": "sha256:a6ed9530289a783c3d7a1ddb038fba6b7daf7feb773298a57e811791e354d438",
|
||||
"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",
|
||||
@@ -345,6 +396,9 @@
|
||||
"testing/2026-07-08-shared-acp-snapshot-package.zh.md": "sha256:02da3f910c2060f70038a0d86a7ddae4a8890905600440e1373412f54fbdcea8",
|
||||
"testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml": "sha256:c1a22174274b9f34ef4368039b3547f221507b040bd51b87af73a2722ee6b4d2",
|
||||
"testing/2026-07-18-tui-terminal-state-snapshots.md": "sha256:9a7fdcbeafc34376cb049b9668e0f4e9e541f523116fb11c3af9d35c2963e908",
|
||||
"testing/2026-07-18-tui-terminal-state-snapshots.zh.md": "sha256:26750f240f6c8a7b28746f62fe161b357e9c5dd52867cc7037399f1ed6ff37fa"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/process/2026-06-20-generated-cordis-catalog.md
|
||||
2026-06-20-generated-cordis-catalog.md: 8d013a5b0c7e1b8df9f607215384f6c26a83b5b8
|
||||
2026-06-20-generated-cordis-catalog.zh.md: 2550bc805db7bea95106d444ecf9b0ad75ef91cc
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Generated cordis events + services catalog
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
English | [中文](2026-06-20-generated-cordis-catalog.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 生成的 Cordis 事件与服务目录
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
[English](2026-06-20-generated-cordis-catalog.md) | 中文
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md
|
||||
2026-07-26-eventsource-parser-for-deepseek-sse.md: 9e716cf9556c9c2d8cdf6cb85c6908d45a127a0f
|
||||
2026-07-26-eventsource-parser-for-deepseek-sse.zh.md: 16c63ddc9646f6309654910bd80801bd20005545
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Replace the hand-rolled SSE parser in llm-deepseek with eventsource-parser
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
English | [中文](2026-07-26-eventsource-parser-for-deepseek-sse.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 用 eventsource-parser 替换 llm-deepseek 中手写的 SSE 解析器
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
[English](2026-07-26-eventsource-parser-for-deepseek-sse.md) | 中文
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md
|
||||
2026-07-26-turndown-for-tool-web-html-markdown.md: 46c4eba12c782146aa32df245c5f69567723935a
|
||||
2026-07-26-turndown-for-tool-web-html-markdown.zh.md: 077ea89c54c63e41bec2aabd15e744b7b8a764da
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Replace tool-web's regex HTML-to-markdown converter with turndown
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
English | [中文](2026-07-26-turndown-for-tool-web-html-markdown.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 用 turndown 替换 tool-web 的正则 HTML 转 markdown 转换器
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
[English](2026-07-26-turndown-for-tool-web-html-markdown.md) | 中文
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.md
|
||||
2026-07-30-sidebar-resize-without-visible-pill.md: 50bf43564675540df2db8e88530a82177f551407
|
||||
2026-07-30-sidebar-resize-without-visible-pill.zh.md: 41a33e039aaa29813d4232e5a6d36141b55ce947
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Sidebar resize without a visible pill
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
English | [中文](2026-07-30-sidebar-resize-without-visible-pill.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 侧边栏缩放不显示胶囊
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
[English](2026-07-30-sidebar-resize-without-visible-pill.md) | 中文
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md
|
||||
2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: 8ef2b7deb103f2e2a9147b4b50c7d39936ed2381
|
||||
2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: 875e44a06e1cebf4f2b1731909c479b2f06fb06a
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Web UI drops steer entry and interjection chrome
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
English | [中文](2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Web UI 去掉 steer 入口与插话 chrome
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
[English](2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md) | 中文
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.md
|
||||
2026-07-26-execa-for-test-subprocess-plumbing.md: ca5edc50bf17a34809037462f8e9603b8ed28e74
|
||||
2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 186e5cd560b6500364c855438d6c3ff18206b52b
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Adopt execa for hand-rolled test subprocess plumbing
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
English | [中文](2026-07-26-execa-for-test-subprocess-plumbing.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 采用 execa 替换手写的测试子进程管道代码
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-07
|
||||
|
||||
[English](2026-07-26-execa-for-test-subprocess-plumbing.md) | 中文
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md
|
||||
2026-06-13-twin-llm-adapters.md: b922891d4438553fd96a7f4f4226f378e66e8ad2
|
||||
2026-06-13-twin-llm-adapters.zh.md: 391f9259172bc91bb4e5fc036e6064a207a7e308
|
||||
2026-06-13-twin-llm-adapters.md: a4c87325a0b0d1ebe6cf8f95672e5de74ef37d57
|
||||
2026-06-13-twin-llm-adapters.zh.md: 753d7900f23c0d3388be0488c291145fdccf5a95
|
||||
@@ -12,7 +12,7 @@ English | [中文](2026-06-13-twin-llm-adapters.zh.md)
|
||||
|
||||
Ship **two** adapters against the one contract from the start, deliberately built on different internals:
|
||||
|
||||
- `dsh-llm-deepseek` — direct `fetch` + in-repo translation against the DeepSeek API; SSE framing is delegated to `eventsource-parser` ([the SSE-parser swap](../simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md)). The twin identity is owning the fetch/translate internals rather than delegating to a full provider SDK, not hand-rolling transport plumbing.
|
||||
- `dsh-llm-deepseek` — direct `fetch` + in-repo translation against the DeepSeek API; SSE framing is delegated to `eventsource-parser` ([the archived SSE-parser swap](../../archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md)). The twin identity is owning the fetch/translate internals rather than delegating to a full provider SDK, not hand-rolling transport plumbing.
|
||||
- `dsh-llm-pi-ai` — the same endpoint through the `@earendil-works/pi-ai` library (its own event vocabulary).
|
||||
|
||||
The rule they enforce: **anything the StreamChunk vocabulary cannot express for BOTH implementations is a core-vocabulary bug**, caught immediately rather than at the next provider. The pair pinned down conventions now documented on `StreamChunk` in `dsh-llm/src/types.ts`: usage emitted before finish, nothing after finish, tool-call `arguments` as raw JSON strings end-to-end, and the two sanctioned error paths (throw from `stream()` *or* end with `finish {kind:'error'|'aborted'}`) that a consumer must handle on both sides — a divergence the library-backed adapter surfaced that a single direct-fetch adapter would have hidden.
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
从一开始就针对同一份契约交付**两个**适配器,刻意基于不同的内部实现构建:
|
||||
|
||||
- `dsh-llm-deepseek`:直接 `fetch` + 仓库内翻译逻辑对接 DeepSeek API;SSE(Server-Sent Events)分帧委托给 `eventsource-parser`([SSE 解析器替换](../simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md))。孪生身份在于自行持有 fetch/translate 内部实现而非委托给完整的提供方 SDK,不在于手写传输层管道。
|
||||
- `dsh-llm-deepseek`:直接 `fetch` + 仓库内翻译逻辑对接 DeepSeek API;SSE(Server-Sent Events)分帧委托给 `eventsource-parser`([已归档的 SSE 解析器替换](../../archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md))。孪生身份在于自行持有 fetch/translate 内部实现而非委托给完整的提供方 SDK,不在于手写传输层管道。
|
||||
- `dsh-llm-pi-ai`:通过 `@earendil-works/pi-ai` 库访问同一端点(该库有自己的事件词汇)。
|
||||
|
||||
二者共同执行的规则是:**凡 StreamChunk 词汇无法为两个实现同时表达的内容,都是核心词汇的缺陷**——立即暴露,而非等到下一个提供方接入时才发现。这对孪生适配器确立了现已记录在 `dsh-llm/src/types.ts` 中 `StreamChunk` 上的约定:usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程以原始 JSON 字符串传递,以及消费方必须在两侧都处理的两条合法错误路径(`stream()` 抛异常,*或者*以 `finish {kind:'error'|'aborted'}` 结束)。这一分歧正是由基于库的适配器暴露出来的,单一直接 fetch 适配器会将其隐藏。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md
|
||||
2026-06-21-mandatory-app-attribution-headers.md: a8ffe91c431cdc7907626bbc3eaf8096035777de
|
||||
2026-06-21-mandatory-app-attribution-headers.zh.md: ac4affce583d5d81253f320ff022f670d4d66cc8
|
||||
2026-06-21-mandatory-app-attribution-headers.md: ad9d65805c8f0c96bd811b5036310d019760627e
|
||||
2026-06-21-mandatory-app-attribution-headers.zh.md: 3021c7fcca00f2e929d997625c303f9a27dbf673
|
||||
+2
-2
@@ -32,7 +32,7 @@ The provider-neutral identity is owned by `dsh-llm` (`packages/llm/llm/src/attri
|
||||
|
||||
- product token for `User-Agent`: `deepseek-harness` (continuity with the pre-Agent Note wire value and the repo/org identity)
|
||||
- version: read from the owning package's manifest via `createRequire`, never a hand-copied constant
|
||||
- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home; a `FIXME` in `attribution.ts` blocks release until that repository actually exists
|
||||
- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home, which must exist before release
|
||||
|
||||
The default is mandatory and non-empty. White-label deployments pass their own `AppIdentity` to `attributionHeaders(identity)` - the override seam is the function parameter, with no deployment config plumbing until a consumer needs it - and omission falls back to the harness default rather than suppressing attribution. There is no per-request API for the model, user prompt, session id, cwd, user email, API key owner, or local machine identity to influence these fields.
|
||||
|
||||
@@ -77,7 +77,7 @@ The landed contract:
|
||||
|
||||
**Providers see that traffic comes from the harness.** That is the point, but it means deployments that previously blended into generic SDK traffic become identifiable. Mitigation: send only static public product data and let forks/white-label deployments pass their own `AppIdentity`.
|
||||
|
||||
**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise. The `FIXME` marker on the constant blocks a release from shipping with it unresolved (see `docs/development.md` marker semantics).
|
||||
**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise that blocks release.
|
||||
|
||||
**Header support differs by client library.** The hand-rolled adapter sets headers directly; the pi-ai-backed adapter depends on pi-ai continuing to honor `StreamOptions.headers` (merged last over provider defaults). The wire-level mock-server tests are the guard: if a pi-ai upgrade stops delivering the header, the suite goes red. This is useful pressure on the abstraction: a provider adapter that cannot set mandatory headers cannot fully implement the harness LLM contract.
|
||||
|
||||
|
||||
+2
-2
@@ -32,7 +32,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这
|
||||
|
||||
- `User-Agent` 的产品 token:`deepseek-harness`(与 Agent Note 之前的线路值及仓库/组织身份保持连续性)
|
||||
- 版本:通过 `createRequire` 从所属包的 manifest(元数据清单)读取,绝不手动复制常量
|
||||
- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页;`attribution.ts` 中的 `FIXME` 标记在该仓库实际存在之前阻塞发布
|
||||
- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页,且必须在发布前实际存在
|
||||
|
||||
默认值是强制的且非空。白标部署通过向 `attributionHeaders(identity)` 传入自己的 `AppIdentity` 来覆盖——覆盖 seam 就是函数参数,在有消费方需要之前不做部署配置管道——省略时回退到 harness 默认值而非抑制归属。没有逐请求 API 允许模型、用户提示词、会话 id、cwd、用户邮箱、API key 所有者或本地机器身份影响这些字段。
|
||||
|
||||
@@ -77,7 +77,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这
|
||||
|
||||
**提供方看到流量来自 harness。** 这正是目的,但意味着此前混在通用 SDK 流量中的部署变得可识别。缓解措施:仅发送静态公开产品数据,并允许 fork/白标部署传入自己的 `AppIdentity`。
|
||||
|
||||
**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个悬空承诺。常量上的 `FIXME` 标记阻塞发布,不允许带着未解决的问题出门(见 `docs/development.md` 标记语义)。
|
||||
**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个阻塞发布的悬空承诺。
|
||||
|
||||
**不同客户端库的头部支持有差异。** 手写适配器直接设置头部;基于 pi-ai 的适配器依赖 pi-ai 继续尊重 `StreamOptions.headers`(最后合并覆盖提供方默认值)。线路级 mock 服务器测试是守卫:如果 pi-ai 升级后不再投递该头部,套件会变红。这对抽象施加了有益的压力:一个无法设置强制头部的提供方适配器不能完整实现 harness 的 LLM 契约。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md
|
||||
2026-07-02-tool-render-intent-union.md: d82141f519bff66df000f1316093aacd38b8e42b
|
||||
2026-07-02-tool-render-intent-union.zh.md: e145908e019e71765a475b0ca9d22414e9b42d23
|
||||
2026-07-02-tool-render-intent-union.md: 67607b2848305439513503d7e03ad5e2a2e4020a
|
||||
2026-07-02-tool-render-intent-union.zh.md: 9cbab75ce87f6d313ca4b6ac8dda043733361f0d
|
||||
@@ -14,7 +14,7 @@ A tool declares how its calls render in a UI (an editor's tool-call card) throug
|
||||
- Which combinations are *valid* is unwritten: a `terminal` call that also sets `content` means "description above the card"; a generic call that sets `terminal` is meaningless but representable. The type permits nonsense.
|
||||
- There is no way to express the one file-tool affordance an editor most wants — a **diff card** (`{path, oldText, newText}`, which Zed renders as an inline diff / new-file preview). `ToolCallPresentation.content` is the *LLM* `ContentBlock[]` vocabulary (text/image), so a tool literally cannot ask for a diff.
|
||||
|
||||
The existing `FIXME(tool-presentation)` in `packages/core/tools/src/index.ts` named the fix: "redesign the type so a tool declares its render INTENT once (e.g. a tagged union over card kinds) rather than a bag of optional fields the bridge stitches together." An earlier rejected collapse-tool-owned-presentation proposal deferred it explicitly: rich rendering "should return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is met by multiple producer families plus the TUI and host/client-runtime (Web) consumers.
|
||||
An earlier rejected collapse-tool-owned-presentation proposal deferred rich rendering until it could "return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is met by multiple producer families plus the TUI and host/client-runtime (Web) consumers.
|
||||
|
||||
## Decision
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
- 哪些组合是*合法的*没有文档说明:一个设置了 `content` 的 `terminal` 调用意味着「卡片上方的描述」;一个设置了 `terminal` 的 generic 调用毫无意义但类型上可表达。类型允许无意义的状态存在。
|
||||
- 无法表达编辑器最需要的文件工具能力:**diff 卡片**(`{path, oldText, newText}`,Zed 将其渲染为内联 diff / 新文件预览)。`ToolCallPresentation.content` 使用的是 *LLM(大语言模型)* 的 `ContentBlock[]` 词汇(text/image),工具根本无法请求 diff 展示。
|
||||
|
||||
`packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」一个早先被否决的折叠工具自有呈现提案明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件已由多个生产者族,加上 TUI 与宿主/客户端运行时(Web)这些消费方满足。
|
||||
一个早先被否决的折叠工具自有呈现提案把富渲染推迟到它能够「在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归」之时。该条件已由多个生产者族,加上 TUI 与宿主/客户端运行时(Web)这些消费方满足。
|
||||
|
||||
## 决策
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md
|
||||
2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 94f5fa409e7b539b48750d12576c7a342a30c9ba
|
||||
2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 341b3a89f423c9cc7d2fc56f1ea25a1985680d0d
|
||||
2026-07-05-prompt-variables-and-tool-guidance-ownership.md: a3b5021daf323971308760bde4f97651db8edbda
|
||||
2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 490d41302ea4f4e14e11e301acbf47170f95cace
|
||||
+1
-1
@@ -10,7 +10,7 @@ The assembled system prompt had four defects, all of one family: facts the harne
|
||||
|
||||
**The model could not know its own name.** `AgentOptions.model` drives every request, but no prompt text carried it — and nothing COULD carry it: sections in `dsh-system-prompt` were context-global while the model name is per-agent, and `assemble()` took no per-agent input at all.
|
||||
|
||||
**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the coding-agent and ACP persona strings — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand; both YAMLs carried a `FIXME(config-comments)` apologizing for a symptom of the split, and the old terminal welcome banner hand-enumerated the tool set too.
|
||||
**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the coding-agent and ACP persona strings — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand, and the old terminal welcome banner hand-enumerated the tool set too.
|
||||
|
||||
**The persona rendered after tool guidance.** The loop string-joined `agent.options.systemPrompt` AFTER the assembled sections, so the model read "Use the read tool…" before "You are a coding agent" — backwards relative to the identity-first convention (Claude Code, Codex) and a second composition path besides the section pipeline.
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
**模型无法知道自己的名字。** `AgentOptions.model` 驱动每个请求,但没有任何提示词文本携带它——也不可能携带:`dsh-system-prompt` 中的 section 是上下文全局的,而模型名称是 per-agent 的,`assemble()` 根本不接受任何 per-agent 输入。
|
||||
|
||||
**工具指导是 leaf YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 coding-agent 和 ACP persona 字符串里——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs` 和 `dsh-tool-web` 则通过 `ctx.systemPrompt.section()` 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona;两份 YAML 都带着一条 `FIXME(config-comments)` 为这种分裂的症状道歉,旧终端欢迎横幅也手动枚举了工具集。
|
||||
**工具指导是 leaf YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 coding-agent 和 ACP persona 字符串里——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs` 和 `dsh-tool-web` 则通过 `ctx.systemPrompt.section()` 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona,旧终端欢迎横幅也手动枚举了工具集。
|
||||
|
||||
**Persona 渲染在工具指导之后。** agent loop(智能体循环)将 `agent.options.systemPrompt` 字符串拼接在已组装的 section 之后,于是模型先读到「Use the read tool…」再读到「You are a coding agent」——与 identity-first 约定(Claude Code、Codex)相反,且是 section 流水线之外的第二条组合路径。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md
|
||||
2026-07-05-reconstructable-requests.md: 2f559a3052b9fb84f788975a64799e4f020b0d3e
|
||||
2026-07-05-reconstructable-requests.zh.md: 26abdc024a166856e51ebf09f086c7868fc8236d
|
||||
2026-07-05-reconstructable-requests.md: ebca9b99cad791159302da9c2bbce9f4df147aab
|
||||
2026-07-05-reconstructable-requests.zh.md: 91ef3fd04502f2c2f092da60a8bd909cf7fa25df
|
||||
@@ -53,4 +53,3 @@ Like MiniCode, the conversation advances append-only and resets only when model-
|
||||
- Tool-result trimming (planned) 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.
|
||||
- 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.
|
||||
- FIXME(call-config-shape): revisit `LlmCallConfig`'s exact field set — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit there out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them.
|
||||
@@ -53,4 +53,3 @@ Status: implemented
|
||||
- 工具结果裁剪(计划中)无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。
|
||||
- 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对分片密集型日志仍然很小,并只保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。
|
||||
- 快照预期输出变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。
|
||||
- FIXME(call-config-shape):重新审视 `LlmCallConfig` 的确切字段集——哪些字段对缓存而言真正属于 epoch 级别(`model` 毫无疑问;采样标量出于谨慎放在那里),以及当适配器需要时,提供方特定的额外项(推理(reasoning)选项、额外 body 参数)应归属何处。
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-27-compiler-independent-typert-model.md: 338476924dfb5d9832d0b64bf01b8d3c297cd6d6
|
||||
2026-07-27-compiler-independent-typert-model.zh.md: a88f4dbba50696071552ea12a63b69ecac202418
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md
|
||||
2026-07-27-compiler-independent-typert-model.md: 15641e5f785c5d2daecbfc2d5cffedd64d8384a7
|
||||
2026-07-27-compiler-independent-typert-model.zh.md: 7bddd3ffde343346c163f55ef493c32bad1e8314
|
||||
+2
-2
@@ -22,7 +22,7 @@ PackageModel recognizes Cordis services, events, `@typert object` reference obje
|
||||
|
||||
[`dsh-typert-registry`](../../../../packages/typert/registry/README.md) provides `ctx.typert` and handles runtime registration only: one contribution atomically carries package-face reflection and an optional Zod schema, and Cordis effect disposal revokes it. The registry neither analyzes TypeScript nor merges the two faces. JSON Schema is an on-demand projection of registered Zod schemas.
|
||||
|
||||
Package artifact publication is explicit opt-in. When invoked, `WorkspaceTypertGenerator` validates that each requested host face exposes the user-facing subpath `package/typert` from the root artifact `package/lib/typert.host.{js,d.ts}`, or that each requested client face exposes `package/client/typert` from `package/lib/typert.client.{js,d.ts}`. It neither edits exports nor runs as part of the ordinary root build or typecheck, so those commands do not generate whole-workspace Typert artifacts. Generated declarations keep `TYPERT` typed as `unknown`, so business packages do not depend on the registry.
|
||||
Package artifact publication remains explicit opt-in through package exports. When invoked, `WorkspaceTypertGenerator` validates that each requested host face exposes the user-facing subpath `package/typert` from the root artifact `package/lib/typert.host.{js,d.ts}`, or that each requested client face exposes `package/client/typert` from `package/lib/typert.client.{js,d.ts}`; it never edits those exports. The later [TypeRT Remote design](2026-08-02-typert-remote-method-calls.md) adds a whole-workspace Host contract pass to root build, typecheck, lint, and documentation typecheck. For opted-in Host packages, that pass emits both local reflection and strict Host-for-Client `/remote` contracts before consumers resolve them. Generated local declarations keep `TYPERT` typed as `unknown`, so business packages do not depend on the registry.
|
||||
|
||||
At build time, `CordisCatalogProjector` consumes the analyzed `FaceModel` and `TypeGraph` once to generate `docs/cordis-catalog/events.md`, `docs/cordis-catalog/services.md`, and the static `SERVICE_API`, `EVENT_API`, and `TYPE_API` catalog committed for `tool-cordis`. `tool-cordis` reads that static catalog and has no runtime dependency on `ctx.typert`. [`dsh-typert-loader`](../../../../packages/typert/loader/README.md) and the registry remain an independent runtime path: the loader follows Cordis Loader entry lifecycle events, imports an explicitly published `./typert` host artifact, and registers it through `ctx.typert`; neither component supplies the current `cordis_inspect` catalog.
|
||||
|
||||
@@ -50,4 +50,4 @@ For each supported node kind and literal category, Zod emitter tests run both su
|
||||
|
||||
New generation targets and static checks can reuse the same TypeGraph, and business categories can extend PackageModel without parsing the AST again. Preserving pre-evaluation types and independent faces makes the model more complex than a flattened schema; emitters must explicitly declare their supported scope and fail on missing capabilities.
|
||||
|
||||
Explicit opt-in keeps artifact publication and package exports under package ownership, while ordinary root builds and typechecks incur no whole-workspace Typert generation phase. The static Cordis catalogs remain reproducible from the canonical model without coupling `tool-cordis` to runtime registry state. `ctx.typert` reflects only artifacts mounted in the current runtime, and unloading does not control Zod instances that consumers retain after importing them directly.
|
||||
Explicit package opt-in keeps artifact publication and exports under package ownership. Repository orchestration may still run the whole-workspace Host contract pass for every opted-in package; that pass remains owned by the later Remote Gateway Agent Note. The static Cordis catalogs remain reproducible from the canonical model without coupling `tool-cordis` to runtime registry state. `ctx.typert` reflects only artifacts mounted in the current runtime, and unloading does not control Zod instances that consumers retain after importing them directly.
|
||||
+2
-2
@@ -22,7 +22,7 @@ PackageModel 识别 Cordis service、event、`@typert object` 引用对象和 `@
|
||||
|
||||
[`dsh-typert-registry`](../../../../packages/typert/registry/README.md) 提供 `ctx.typert`,且只负责运行时注册:一个 contribution 原子携带 package-face reflection 与可选 Zod schema,并随 Cordis effect 撤销。注册表不分析 TypeScript,也不合并两个 face。JSON Schema 是对已注册 Zod schema 的按需投影。
|
||||
|
||||
包产物发布采用显式 opt-in。`WorkspaceTypertGenerator` 仅在被调用时校验所请求 face 的根目录产物协议:host face 必须通过面向用户的 subpath `package/typert` 暴露 `package/lib/typert.host.{js,d.ts}`,client face 必须通过 `package/client/typert` 暴露 `package/lib/typert.client.{js,d.ts}`。它既不修改 exports,也不作为根目录普通 build 或 typecheck 的一部分运行,因此这些命令不会生成全仓 Typert 产物。生成的声明将 `TYPERT` 类型保持为 `unknown`,因此业务包不依赖注册表。
|
||||
包产物发布仍通过 package exports 采用显式 opt-in。`WorkspaceTypertGenerator` 仅在被调用时校验所请求 face 的根目录产物协议:host face 必须通过面向用户的 subpath `package/typert` 暴露 `package/lib/typert.host.{js,d.ts}`,client face 必须通过 `package/client/typert` 暴露 `package/lib/typert.client.{js,d.ts}`;它不会修改这些 exports。后续的 [TypeRT Remote 设计](2026-08-02-typert-remote-method-calls.md) 为根目录 build、typecheck、lint 与文档类型检查增加了全仓 Host 契约 pass。对于已 opt-in 的 Host 包,该 pass 会在消费方解析两者之前生成本地反射产物与严格的 Host-for-Client `/remote` 契约。生成的本地声明将 `TYPERT` 类型保持为 `unknown`,因此业务包不依赖注册表。
|
||||
|
||||
构建期的 `CordisCatalogProjector` 一次消费分析后的 `FaceModel` 与 `TypeGraph`,生成 `docs/cordis-catalog/events.md`、`docs/cordis-catalog/services.md`,以及为 `tool-cordis` 提交的静态 `SERVICE_API`、`EVENT_API` 和 `TYPE_API` catalog。`tool-cordis` 读取该静态 catalog,运行时不依赖 `ctx.typert`。[`dsh-typert-loader`](../../../../packages/typert/loader/README.md) 与注册表仍是独立的运行时路径:loader 监听 Cordis Loader 配置项生命周期事件,导入显式发布的 `./typert` host 产物,并通过 `ctx.typert` 注册;两者都不是当前 `cordis_inspect` catalog 的数据源。
|
||||
|
||||
@@ -50,4 +50,4 @@ Zod emitter 对支持的节点和各类 literal 逐类执行成功与失败 pars
|
||||
|
||||
新增生成目标或静态检查可复用同一 TypeGraph,业务类目也可在 PackageModel 上扩展,而无需再次解析 AST。保留计算前类型和独立 face 的代价是模型比打平后的 schema 更复杂,emitter 必须显式声明支持范围并对缺失能力失败。
|
||||
|
||||
显式 opt-in 使产物发布与 package exports 由各包自行管理,根目录普通 build 和 typecheck 不会引入全仓 Typert 生成阶段。静态 Cordis catalog 可从标准模型复现,同时不把 `tool-cordis` 与运行时注册表状态耦合。`ctx.typert` 只反映当前运行时中已挂载的产物;对于消费方直接导入后仍持有的 Zod 实例,卸载流程无法控制。
|
||||
包级显式 opt-in 使产物发布与 exports 由各包自行管理。仓库编排仍可为每个已 opt-in 的包运行全仓 Host 契约 pass;该 pass 仍由后续 Remote Gateway Agent Note 负责说明。静态 Cordis catalog 可从标准模型复现,同时不把 `tool-cordis` 与运行时注册表状态耦合。`ctx.typert` 只反映当前运行时中已挂载的产物;对于消费方直接导入后仍持有的 Zod 实例,卸载流程无法控制。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md
|
||||
2026-07-29-dsh-source-launch-tsx-esm.md: 93fbb248b45efde37d5fbdb1ec4b812ab3332088
|
||||
2026-07-29-dsh-source-launch-tsx-esm.zh.md: 4d7b2c47db68f21e904a607f16c80d33c706c488
|
||||
2026-07-29-dsh-source-launch-tsx-esm.md: 21e912c7c7bbdd70142c202105d9a3035442884a
|
||||
2026-07-29-dsh-source-launch-tsx-esm.zh.md: dc6150b7017777eea99778cc9813e17402354462
|
||||
@@ -4,11 +4,11 @@ Status: implemented
|
||||
|
||||
English | [中文](2026-07-29-dsh-source-launch-tsx-esm.zh.md)
|
||||
|
||||
> Supersedes [native TypeScript source launch](2026-07-28-dsh-native-typescript-source-launch.md): Node removed the capability that decision was built on.
|
||||
> Supersedes [native TypeScript source launch](../../archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md): Node removed the capability that decision was built on.
|
||||
|
||||
## Problem
|
||||
|
||||
The [native source-launch decision](2026-07-28-dsh-native-typescript-source-launch.md) ran `apps/cli/src/bin.ts` under `node --experimental-transform-types` with a resolve-only paths loader, so Node owned TypeScript transformation. Node 26.0.0 removed `--experimental-transform-types` (the process rejects the flag with `bad option`), keeping only strip mode, and strip mode rejects syntax this source graph requires: vendored Cordis parameter properties (`constructor(private ctx: Context)`), the `@Inject` decorators in `vendor/hmr`, and runtime enums/namespaces throughout `vendor/` and `packages/workflow`. The repository's engines range (`^22.19.0 || >=24.0.0`) includes Node 26, so the native launch chain could not start at all there — and no CI job executed the real launch vector, so the incompatibility shipped silently.
|
||||
The [archived native source-launch decision](../../archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md) ran `apps/cli/src/bin.ts` under `node --experimental-transform-types` with a resolve-only paths loader, so Node owned TypeScript transformation. Node 26.0.0 removed `--experimental-transform-types` (the process rejects the flag with `bad option`), keeping only strip mode, and strip mode rejects syntax this source graph requires: vendored Cordis parameter properties (`constructor(private ctx: Context)`), the `@Inject` decorators in `vendor/hmr`, and runtime enums/namespaces throughout `vendor/` and `packages/workflow`. The repository's engines range (`^22.19.0 || >=24.0.0`) includes Node 26, so the native launch chain could not start at all there — and no CI job executed the real launch vector, so the incompatibility shipped silently.
|
||||
|
||||
Startup latency also mattered: the off-thread `module.register()` hooks worker serialized every resolution across threads (~440ms of `makeSyncRequest` wait during TUI boot), and the full tsx default (`--import tsx`) pays ~0.4s in its CJS hook's resolution amplification.
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@ Status: implemented
|
||||
|
||||
[English](2026-07-29-dsh-source-launch-tsx-esm.md) | 中文
|
||||
|
||||
> 取代[原生 TypeScript 源码启动](2026-07-28-dsh-native-typescript-source-launch.md):Node 移除了该决策所依赖的能力。
|
||||
> 取代[已归档的原生 TypeScript 源码启动](../../archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md):Node 移除了该决策所依赖的能力。
|
||||
|
||||
## 问题
|
||||
|
||||
[原生源码启动决策](2026-07-28-dsh-native-typescript-source-launch.md)让 `apps/cli/src/bin.ts` 在 `node --experimental-transform-types` 下运行,配合一个只做解析的 paths loader,由 Node 负责 TypeScript 转换。Node 26.0.0 移除了 `--experimental-transform-types`(进程以 `bad option` 拒绝该 flag),只保留 strip 模式,而 strip 模式无法接受这个源码图必需的语法:vendor Cordis 中的参数属性(`constructor(private ctx: Context)`)、`vendor/hmr` 中的 `@Inject` 装饰器,以及遍布 `vendor/` 与 `packages/workflow` 的运行时 enum/namespace。仓库的 engines 范围(`^22.19.0 || >=24.0.0`)包含 Node 26,因此原生启动链在其上完全无法启动——且没有任何 CI 任务执行过真实启动向量,这一不兼容悄然发布。
|
||||
[已归档的原生源码启动决策](../../archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md)让 `apps/cli/src/bin.ts` 在 `node --experimental-transform-types` 下运行,配合一个只做解析的 paths loader,由 Node 负责 TypeScript 转换。Node 26.0.0 移除了 `--experimental-transform-types`(进程以 `bad option` 拒绝该 flag),只保留 strip 模式,而 strip 模式无法接受这个源码图必需的语法:vendor Cordis 中的参数属性(`constructor(private ctx: Context)`)、`vendor/hmr` 中的 `@Inject` 装饰器,以及遍布 `vendor/` 与 `packages/workflow` 的运行时 enum/namespace。仓库的 engines 范围(`^22.19.0 || >=24.0.0`)包含 Node 26,因此原生启动链在其上完全无法启动——且没有任何 CI 任务执行过真实启动向量,这一不兼容悄然发布。
|
||||
|
||||
启动延迟同样是问题:off-thread 的 `module.register()` 钩子工作线程把每次解析都跨线程序列化(TUI 启动期间约 440ms 的 `makeSyncRequest` 等待),而完整的 tsx 默认形态(`--import tsx`)会因其 CJS 钩子放大解析开销而多花约 0.4s。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md
|
||||
2026-07-29-request-level-llm-config-credentials.md: f12a2496a767decc3ce2b065f6be03009aec8992
|
||||
2026-07-29-request-level-llm-config-credentials.zh.md: 2d132c0fd0ab2205ca013b49226a9764293cc11d
|
||||
2026-07-29-request-level-llm-config-credentials.md: 238400ea41f25a716729d1721c113645c2c8ba72
|
||||
2026-07-29-request-level-llm-config-credentials.zh.md: b0d04d4303bf0ccf5ebc74af8c4a3e493f861d63
|
||||
+1
-1
@@ -14,7 +14,7 @@ The [settings seam](2026-07-28-user-settings-seam.md) shipped without a producti
|
||||
|
||||
**Per-request resolution, not fiber rebuilds.** The adapters take an options thunk (and a per-stream credential resolver) instead of frozen construction facts, resolving once per operation — the Pi pattern, with its tested semantics: two requests straddling a change see two configurations, one request resolves exactly once, and an in-flight stream keeps the facts it started with. This deletes the entire swap machinery a rebuild design needs (`DUPLICATE_ADAPTER` ordering, `NO_ADAPTER` windows, a deferred-activation state machine) and makes a missing key a *request-time* actionable failure (`MISSING_CREDENTIAL` naming every entry point) while the route stays registered and the catalog stays browsable. The one registration-captured fact — the retry policy the `ctx.llm` registry snapshots at `registerAdapter` (plus pi-ai's route *set*) — re-registers the same adapter instance in one synchronous section when it changes.
|
||||
|
||||
**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over `$DSH_HOME/.env` (writable, byte-preserving line edits, a quoting ladder dotenv reads back verbatim, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable.
|
||||
**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over the provider-managed document (writable, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). That document was `$DSH_HOME/.env` in dotenv form; the [credentials document split](2026-08-04-credentials-yaml-and-user-environment-layer.md) later moved it to `$DSH_HOME/.credentials.yaml` and freed the old path to become the user's environment layer. Adapters resolve the reference through the seam, or — only without a mounted seam — through the environment layers.
|
||||
|
||||
**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and its `cordis.yml` entry as the composition `base` — a settings section is the same YAML shape as the entry config, and `resolveAdapterOptions`/`resolveProfiles` stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's `providers` became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions, and an empty dict is the valid dormant posture — a composition ships the adapter bare and every route stays a user-plane decision.
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
**按请求解析,而非重建 fiber。**适配器改为接收一个 options thunk(外加按流调用的凭据解析器),不再持有冻结的构造期事实,每个操作解析一次——即 Pi 的模式,连同其经测试固定的语义:跨越一次变更的两个请求看到两份配置,一个请求恰好解析一次,进行中的流保持其起始事实。这删掉了重建式设计所需的整套切换机制(`DUPLICATE_ADAPTER` 顺序问题、`NO_ADAPTER` 窗口、延迟激活状态机),并把密钥缺失变成*请求时*可行动的失败(`MISSING_CREDENTIAL` 点名每个配置入口),同时路由保持注册、catalog 保持可浏览。唯一在注册期捕获的事实——`ctx.llm` 注册表在 `registerAdapter` 时快照的重试策略(外加 pi-ai 的路由*集合*)——在其变化时于一个同步区段内原地重新注册同一适配器实例。
|
||||
|
||||
**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 `$DSH_HOME/.env` 之上(可写、保字节行级编辑、dotenv 能逐字读回的引号阶梯、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。
|
||||
**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 provider 管理的文档之上(可写、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。该文档当时是 dotenv 形式的 `$DSH_HOME/.env`;[凭据文档拆分](2026-08-04-credentials-yaml-and-user-environment-layer.md)后来把它移到 `$DSH_HOME/.credentials.yaml`,并让旧路径转为用户的环境层。适配器通过 seam 解析该引用;仅在未挂载 seam 时,才通过各环境层解析。
|
||||
|
||||
**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),schema 用其插件 `Config` schema,组合 `base` 用其 `cordis.yml` 条目——settings 分节与 entry 配置是同一种 YAML 形状,`resolveAdapterOptions`/`resolveProfiles` 对两者仍是唯一的显式 resolve 步骤。存活快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 `providers` 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引,而空字典是合法的休眠姿态——组合可以裸挂该适配器,把每一条路由都留给用户面决定。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.md: 6fe5f554acbfd804db9625fcaa794d513c8799c4
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.zh.md: b58a9e68bb1fa36282ad270542b6a64a0b014726
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.md: 94b32c3cfaa3e1c5059573881a2f393d29aed3ac
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 2506ce391c9125323faf107e3c04c52785e0cc98
|
||||
+3
-3
@@ -10,15 +10,15 @@ English | [中文](2026-07-30-credential-boundaries-and-atomic-registration.zh.m
|
||||
|
||||
Review found the credential path leaking across boundaries it had drawn. The shipped surfaces hoisted `$DSH_HOME/.env` into `process.env` before cordis booted, so on the next run `credentials-local` classified every key it had stored itself as a read-only ambient launch override: `describe()` reported `source: 'env'` with `writable: false`, `set`/`unset` rejected as shadowed, and a key stored from the web page or TUI became unrotatable and undeletable while the adapter kept using the value captured at launch. The store's own write path repeated the settings-local defects that same review round fixed (two independent chains, whole-file render from a stale cache), plus editor bugs of its own: a physical line inside another key's quoted multi-line value read as an assignment, CRLF endings degraded to LF, a multi-line entry reported `writable: true` while `set` always threw, and `credentials/updated` was emitted bare after the commit, so one broken observer made a durable write look failed. On the read side, the file's `0600` mode stops other OS users but not the model, whose bash and filesystem tools run as the same user.
|
||||
|
||||
Two request-path defects sat beside them. DeepSeek's per-request resolution kept connection facts in a last-good snapshot but re-read the literal `apiKey` from the raw configuration, so a settings generation the resolver rejected could still put its key on the previous generation's endpoint. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. And its route swap disposed the old registration before creating the new one: a route another adapter owned dropped every existing route, after which the facts cache could equal the registry's, so restoring the working configuration never re-applied.
|
||||
Two request-path defects sat beside them. DeepSeek resolved connection and credential facts independently, so a settings generation the resolver rejected could still pair its credential choice with the previous generation's endpoint. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. And its route swap disposed the old registration before creating the new one: a route another adapter owned dropped every existing route, after which the facts cache could equal the registry's, so restoring the working configuration never re-applied.
|
||||
|
||||
## Decision
|
||||
|
||||
**`$DSH_HOME/.env` belongs to the credential provider alone.** No surface loads it into `process.env`. 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 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 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.
|
||||
|
||||
**One request, one generation.** DeepSeek's resolved snapshot carries the credential facts (literal key and reference) beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. A rejected generation now contributes nothing at all. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and the reference. The boot-time credential probe is deleted: it could run before the credentials service mounted and reported every failure as a missing key, while the first request already gives the accurate error.
|
||||
**One request, one generation.** DeepSeek's resolved snapshot carries the credential reference beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. A rejected generation now contributes nothing at all. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and the reference. The boot-time credential probe is deleted: it could run before the credentials service mounted and reported every failure as a missing key, while the first request already gives the accurate error.
|
||||
|
||||
**Route replacement is a registry operation, not a caller sequence.** `registerAdapter` returns a handle carrying `replace(providers)`: the candidate set is validated in full first (conflicts, names, provider metadata), then swapped in one synchronous section. A refused replacement leaves the previous routes registered and serving, and the caller's facts cache only advances after the registry actually holds the new set, so reverting to a working configuration re-applies. pi-ai's registration facts are sorted by provider, so a settings document that merely reorders its keys is no longer a route change.
|
||||
|
||||
|
||||
+3
-3
@@ -14,15 +14,15 @@ Status: implemented
|
||||
|
||||
在读取一侧,文件的 `0600` 权限挡得住其他 OS 用户,却挡不住模型:它的 bash 与文件系统工具就以同一个用户身份运行。
|
||||
|
||||
与之并排的还有两个请求路径缺陷。DeepSeek 的按请求解析把连接事实保存在最后可用快照里,却仍从原始配置重新读取字面 `apiKey`,于是被 resolver 拒绝的那一代设置,照样能把自己的密钥送到上一代的端点上。配置了 `apiKeyEnv` 却解析不到值时,pi-ai 会把 `undefined` 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。而且它的路由替换是先释放旧注册、再创建新注册:只要有一条路由已被别的适配器占有,现有路由就会被全部丢掉,此后事实缓存可能与注册表中的事实相等,于是把配置改回可用状态也不会重新生效。
|
||||
与之并排的还有两个请求路径缺陷。DeepSeek 分别解析连接事实与凭据事实,因此被 resolver 拒绝的那一代设置仍可能把自己的凭据选择与上一代的端点配在一起。配置了 `apiKeyEnv` 却解析不到值时,pi-ai 会把 `undefined` 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。而且它的路由替换是先释放旧注册、再创建新注册:只要有一条路由已被别的适配器占有,现有路由就会被全部丢掉,此后事实缓存可能与注册表中的事实相等,于是把配置改回可用状态也不会重新生效。
|
||||
|
||||
## 决策
|
||||
|
||||
**`$DSH_HOME/.env` 只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。真正的启动环境,以及调用目录中由 bin 加载的 `.env`,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 Loader 组合中的一次真实重启来证明,而不是靠对 `describe()` 的单元断言。
|
||||
**凭据文档只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。当时该文档是 `$DSH_HOME/.env`;[凭据文档拆分](2026-08-04-credentials-yaml-and-user-environment-layer.md)后来把它移到 `$DSH_HOME/.credentials.yaml`,因此如今被加载的正是那条旧路径——作为用户的普通环境层,其中不含任何 provider 管理的密钥。真正的启动环境,以及调用目录中由 bin 加载的 `.env`,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 Loader 组合中的一次真实重启来证明,而不是靠对 `describe()` 的单元断言。
|
||||
|
||||
**存下的凭据对模型没有边界,而 README 就是这么写的。**`0700` 目录下的 `0600` 挡得住其他 OS 用户;模型的 bash 与文件系统工具正是以同一用户身份运行,而已交付的默认值不约束任何东西。harness 真正守住的更窄,也就照这个宽度写下来:没有任何一个面会把该文档提升进 `process.env`,模型也从不会拿到它的解析后路径,因此要拿到这个值,需要刻意去读一条并未交给它的路径。OS 钥匙串(keychain)提供方——一个模型的进程根本读不到的存储——被记录为真正的答案,而不是靠一个残缺的方案去暗示它。
|
||||
|
||||
**一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据事实(字面密钥与引用),`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。
|
||||
**一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据引用,`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。
|
||||
|
||||
**路由替换是注册表的操作,不是调用方的一串步骤。**`registerAdapter` 返回一个携带 `replace(providers)` 的句柄:候选集合先被完整校验(冲突、名称、提供方元数据),再在一个同步区段内完成替换。被拒绝的替换会让先前的路由保持注册并继续服务,而调用方的事实缓存只有在注册表确实持有新集合之后才会推进,因此改回可用配置时会重新生效。pi-ai 的注册事实按提供方排序,因此仅仅调换键顺序的设置文档不再算作路由变更。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md
|
||||
2026-07-30-session-end-seed-log-boundary.md: 9d0685876b4d1bac339961c67ab08f620e499464
|
||||
2026-07-30-session-end-seed-log-boundary.zh.md: 8fa9625ea6c58b0b07d964ef2580b670893a3d75
|
||||
2026-07-30-session-end-seed-log-boundary.md: 26cd67ccbfdf5dfc62e44a53c877acf6d2fee34a
|
||||
2026-07-30-session-end-seed-log-boundary.zh.md: 5ce12681da3ee46cc7c69aa6d432d25712db17fd
|
||||
@@ -36,7 +36,7 @@ The predicate holds for a bracket *this* session inherited, not as a liveness si
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A boundary written by the persistence coordinator's cold-load path.** Built first, as the [`session/resumed` boundary](../../rejected/architecture/2026-07-29-session-resumed-log-boundary.md), and abandoned before merge. It covers no fork, which is the one case where the inherited bracket's owner may still be running. Because the marker was minted at load it also had to be a durable write on a read path, which spread cost across the seam: a revision bump on every cold load, a `commitRepair` batch on a balanced log with nothing to repair, a stored-time floor to keep the clamp monotonic, and a load that failed against a read-only store.
|
||||
**A boundary written by the persistence coordinator's cold-load path.** Built first as a `session/resumed` boundary and abandoned before merge. It covers no fork, which is the one case where the inherited bracket's owner may still be running. Because the marker was minted at load it also had to be a durable write on a read path, which spread cost across the seam: a revision bump on every cold load, a `commitRepair` batch on a balanced log with nothing to repair, a stored-time floor to keep the clamp monotonic, and a load that failed against a read-only store.
|
||||
|
||||
**A boundary appended at loop start.** The loop calls `resumeWith`, so it covers the resume paths, but it misses `fork()` and `adopt()` entirely, and the event would have to fire on `'startup'` — the source a fork child publishes — so `SessionStartSource` would stop discriminating. It also publishes the session before the marker is appended, so a `session/created` listener could observe a seeded log with no boundary.
|
||||
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ Status: implemented
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**由持久化协调器的冷加载路径写入边界。** 最先实现的方案,即 [`session/resumed` 边界](../../rejected/architecture/2026-07-29-session-resumed-log-boundary.md),在合并前被放弃。它完全覆盖不到 fork,而 fork 恰恰是继承括号的所有方可能仍然存活的那一种情形。由于标记是在加载时铸造的,它还必须在读取路径上做持久写入,这把成本铺开到整个 seam:每次冷加载都递增 revision、对一份无需修复的平衡日志也要走 `commitRepair`、需要一个已存储时间下限来维持钳制的单调性,以及加载在只读存储上会失败。
|
||||
**由持久化协调器的冷加载路径写入边界。** 最初将其实现为 `session/resumed` 边界,并在合并前放弃。它完全覆盖不到 fork,而 fork 恰恰是继承括号的所有方可能仍然存活的那一种情形。由于标记是在加载时铸造的,它还必须在读取路径上做持久写入,这把成本铺开到整个 seam:每次冷加载都递增 revision、对一份无需修复的平衡日志也要走 `commitRepair`、需要一个已存储时间下限来维持钳制的单调性,以及加载在只读存储上会失败。
|
||||
|
||||
**在 loop 启动时追加边界。** loop 调用 `resumeWith`,因此覆盖恢复路径,但完全漏掉 `fork()` 与 `adopt()`,而且事件不得不在 `'startup'` 上触发——那是 fork 子会话发布的来源——于是 `SessionStartSource` 将不再具有区分力。它还会在追加标记之前就发布会话,因此 `session/created` 监听方可能观察到一份没有边界的带种子日志。
|
||||
|
||||
|
||||
@@ -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: 0b18cee414df23a2ed8a8b43b76dc06403804691
|
||||
2026-07-30-web-config-plane.zh.md: e70c2a47970f943e49393b099c4fcea58dc0fbdc
|
||||
2026-07-30-web-config-plane.md: 483058c8a24ad8c414278eba84f4226e973f2923
|
||||
2026-07-30-web-config-plane.zh.md: 9247aac52401cd570d37f4aad47d9da6bb194d19
|
||||
@@ -16,7 +16,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer
|
||||
|
||||
**`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value.
|
||||
|
||||
**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-local` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The loopback-only `settings.describe` response carries only the boolean `hasDocument` capability beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on desktop Linux, `Invoke-Item` on Windows, and `wslpath -w` followed by that Windows handoff on WSL). Generic workspace paths retain the existing default-application handoff. The browser neither derives `$DSH_HOME` nor receives a filesystem target; remote pages make no privileged settings read for this action.
|
||||
**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-local` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The loopback-only `settings.describe` response carries only the boolean `hasDocument` capability beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on desktop Linux, `Invoke-Item` on Windows, and `wslpath -w` followed by that Windows handoff on WSL). Generic workspace paths retain the default intent, including its browser preference for browser-renderable documents. The browser neither derives `$DSH_HOME` nor receives a filesystem target; remote pages make no privileged settings read for this action.
|
||||
|
||||
**The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯
|
||||
|
||||
**`describe()` 增加分层与结构化 secret 脱敏。**`SettingsDescriptor` 在生效值之外携带 `base`/`user`,表单据此按「字段是否出现在用户层」来标记「已覆盖」,而非按值是否不等(与 base *相等*的覆盖仍然是覆盖)。`describe({ redactSecrets: true })`——在每个 wire 面都强制启用——经由对 schema 的纯结构遍历(object/dict/array 容器;secret 角色子树整体是一个不透明叶节点)从全部三层剥除 `role('secret')` 子树,并把剥除的槽位枚举为 `{path, set}`,页面因此不必收到任何值就能渲染只写输入框。
|
||||
|
||||
**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-local` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。仅限回环访问的 `settings.describe` 响应会在脱敏 namespace 视图旁只携带布尔型 `hasDocument` 能力。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认可准备好一份由提供方持有的本地文档后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;桌面 Linux 上使用 `xdg-open`;Windows 上使用 `Invoke-Item`;WSL 上先执行 `wslpath -w`,再使用同一 Windows 交接)。通用 Workspace 路径仍保留现有的默认应用交接。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标;远程页面不会为这项操作发起特权 settings 读取。
|
||||
**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-local` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。仅限回环访问的 `settings.describe` 响应会在脱敏 namespace 视图旁只携带布尔型 `hasDocument` 能力。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认可准备好一份由提供方持有的本地文档后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;桌面 Linux 上使用 `xdg-open`;Windows 上使用 `Invoke-Item`;WSL 上先执行 `wslpath -w`,再使用同一 Windows 交接)。通用 Workspace 路径仍保留默认意图,包括针对浏览器可渲染文档的浏览器偏好。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标;远程页面不会为这项操作发起特权 settings 读取。
|
||||
|
||||
**llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。
|
||||
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md
|
||||
2026-07-31-hover-card-click-copy.md: c87734fe328fa2adb396d6685495faa82bc1fff2
|
||||
2026-07-31-hover-card-click-copy.zh.md: 2d3bc893dd617a3e2e21431175c54bcd4b7ed598
|
||||
# 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: f3db8b9eec5eb8fb610fc58edad9313e4f715326
|
||||
2026-08-02-typert-remote-method-calls.zh.md: da4459432e8c9e08821bcded2921cbd33fc5e8c8
|
||||
@@ -0,0 +1,529 @@
|
||||
# Agent Note: TypeRT Gateway Targeted Method Calls
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-02-typert-remote-method-calls.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Host API Proxy handles direct method calls, stateful interactions, and Session event streams. These concerns have different lifecycles, routing semantics, and client programming interfaces. Continuing to export all business operations through one package would couple business Services, transport protocols, state machines, and client types.
|
||||
|
||||
This decision covers only targeted method calls in which one request produces one result. Stateful interactions such as Permission and Approval, as well as Session event streams, remain separate designs.
|
||||
|
||||
The contract for a direct method call belongs to the business Service that implements it. Business developers declare only which methods are remotely callable, without also maintaining a central API interface, routing table, parameter conversion table, client stub, and Zod schema.
|
||||
|
||||
The Host and Browser Client use separate TypeScript Programs because each side augments the Cordis `Context` type differently. A Remote projection must not import the complete Host declarations into a consumer or depend on Browser-specific types. If the TUI later reuses this programming interface, it must likewise see only methods marked Remote. TUI integration is outside the current scope, but the implementation boundary must preserve this isomorphic reuse.
|
||||
|
||||
## Decision
|
||||
|
||||
A business Service extends `GatewayService` and declares callable methods with `@Remote` or `@RemoteScope()`. A Service that already has another base class may instead expose the same binding through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently.
|
||||
|
||||
The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client Remote Service. The projection and Remote abstraction remain platform-independent so that a future TUI can reuse them.
|
||||
|
||||
`@deepseek-ai/dsh-api-gateway`, located at `packages/api/gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.remote`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket.
|
||||
|
||||
`@deepseek-ai/dsh-api-remotes`, located at `packages/api/remotes`, is the BFF layer above the Gateway. Its Host entry owns Agent/Session identity resolution and TypeRT lookup configuration; its `/client` entry selects the generated Remote contributions exposed by the application. The Client entry consumes the shared `TypeRTClientRemote` contract through Cordis rather than importing the concrete Gateway implementation.
|
||||
|
||||
## Components and Cordis services
|
||||
|
||||
| Component | Cordis service | Responsibility |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-type-meta` | Declares only the minimal `ctx.typert` protocol | `GatewayService`, decorators, binding fallback, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser |
|
||||
| TypeRT registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers |
|
||||
| TypeRT generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` |
|
||||
| API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results |
|
||||
| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, TypeRT interception, and legacy API Proxy fallback |
|
||||
| API Gateway's Client face | `ctx.remote`, `ctx.remote.<namespace>` | Mounts Remote contributions, materializes each namespace as a traced `remote.<namespace>` child Service, and delegates canonical calls to `ctx.connection.rpc` |
|
||||
| API Remotes | No new service | Owns Host Agent/Session lookup policy and serves as the only Client business facade, selecting and mounting `/remote` contributions while exposing the selected API declarations |
|
||||
| Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers |
|
||||
| Business packages such as Goal | Existing business Services | Declare only bindings, Remote methods, and canonical DTOs, and export the generated `/remote` subpath |
|
||||
|
||||
The Host Gateway does not depend on concrete implementations of `ctx.agents`, `ctx.sessions`, `ctx.goals`, or `ctx.httpServer`. The Client Remote does not understand the physical carrier, and Connection does not understand Goal, Agent, lookup, `InvocationDescriptor`, or Remote namespaces.
|
||||
|
||||
## Business declarations
|
||||
|
||||
Ordinary direct calls use `@Remote`. When an existing method's parameters and result are already the intended Remote contract, decorate that method directly without renaming it. Add a `remoteExport*` adapter only when the wire contract needs a distinct request or result shape, and use the decorator argument to declare its short API name. A method explicitly declares every required business object in a top-level parameter position:
|
||||
|
||||
```text
|
||||
export class GoalService extends GatewayService {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'goals')
|
||||
}
|
||||
|
||||
create(agent: Agent, request: CreateGoalRequest): GoalView {
|
||||
// Existing business method remains unchanged.
|
||||
}
|
||||
|
||||
@Remote('create')
|
||||
remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult {
|
||||
const view = this.create(agent, request)
|
||||
return { ref: { id: view.id, revision: view.revision } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`goals` is the explicit Cordis service key passed to `super()` and is the default wire namespace. Pass a `namespace` option as the third argument only when the protocol namespace genuinely needs to differ from the service key.
|
||||
|
||||
Use `@RemoteScope()` when the Service receiver must be resolved within an isolated kind of Context. Scope identity does not enter the business method's parameters:
|
||||
|
||||
```text
|
||||
export class ScopedGoalService extends GatewayService {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'goals')
|
||||
}
|
||||
|
||||
@RemoteScope('agent', 'create')
|
||||
remoteExportCreate(request: CreateGoalRequest): Promise<CreateGoalResult> {
|
||||
// Runs against the goals service resolved from the Agent Context.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
An endpoint selects exactly one invocation mode. A flow that needs an explicit `Agent` parameter uses `@Remote`. A flow that first switches to an Agent Context and then resolves a scoped receiver uses `@RemoteScope('agent')`. TypeRT does not infer either mode from the method body or from a missing parameter.
|
||||
|
||||
Business packages depend only on the lightweight `@deepseek-ai/dsh-type-meta`. It provides `GatewayService` and declaration protocols for decorators, the binding fallback, lookup, Remote Scope, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime.
|
||||
|
||||
A method that cooperatively supports cancellation declares `signal: AbortSignal` as its final Host parameter. This reserved parameter is not a business value, lookup, or JSON field. The generated consumer method exposes it as a final optional parameter so ordinary calls remain unchanged while callers that own cancellation can pass a signal.
|
||||
|
||||
## Decorators and the explicit Gateway facet
|
||||
|
||||
A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteScope('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `GatewayService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance.
|
||||
|
||||
In SRC mode, the decorator may record the prototype, method name, and invocation mode in a `WeakMap` internal to `dsh-type-meta`. It writes no custom properties to a Service instance, prototype, constructor, or method function.
|
||||
|
||||
In LIB mode, the TypeRT compiler performs strict method discovery, type resolution, and descriptor generation. It accepts a literal service key in `GatewayService`'s direct `super()` call or the explicit binding fallback; generation neither rewrites business source nor injects hidden registration metadata.
|
||||
|
||||
## Lookup and Remote Scope registration
|
||||
|
||||
The Gateway has no built-in branches for Agent, Session, or other business objects. Each object-owning package provides both a static declaration and a runtime provider:
|
||||
|
||||
```text
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTLookupMap {
|
||||
agent: TypeRTLookup<Agent, SessionId>
|
||||
}
|
||||
}
|
||||
|
||||
ctx.typert.lookups.register('agent', {
|
||||
parameter: 'agent',
|
||||
wire: 'agentId',
|
||||
resolve: sessionId => resolveAgent(sessionId),
|
||||
})
|
||||
```
|
||||
|
||||
The static declaration tells TypeRT that `Agent` corresponds to `SessionId` on the wire. The runtime provider resolves an `agentId` in a request to the currently live `Agent` object. If either side is missing, the LIB build or the earliest resolvable runtime registration fails immediately.
|
||||
|
||||
Lookup objects such as Agent and Session may each occupy only one top-level parameter position. An ordinary JSON request may be passed as another complete parameter, but this design does not support `request.agent`, object destructuring, arrays of objects, nested lookups, or searching arbitrary complex structures for IDs.
|
||||
|
||||
Remote Scope uses a separate merge-extensible map and Context provider. The Agent package registers an `agent` provider that locates the Agent Context from its wire identity and resolves the Service key named by the descriptor from that Context. The Gateway does not know the internal structure of an Agent Context.
|
||||
|
||||
The Client also registers an `agent` Context binder. The binder only retrieves a `SessionId` from the Context in which a call occurs; it neither enumerates Scopes nor copies methods into each one. A Cordis Service tracker automatically rebinds a scoped namespace to the current Agent Context.
|
||||
|
||||
## InvocationDescriptor
|
||||
|
||||
TypeRT, the permissive SRC parser, Host Gateway, and Client Remote exchange one canonical description:
|
||||
|
||||
```text
|
||||
InvocationDescriptor {
|
||||
id: '@deepseek-ai/dsh-goal#goals/create'
|
||||
service: 'goals'
|
||||
namespace: 'goals'
|
||||
method: 'create'
|
||||
implementation: 'remoteExportCreate'
|
||||
invocation: direct | { context: 'agent', wire: 'agentId' }
|
||||
scope?: { context: 'agent', wire: 'agentId' }
|
||||
parameters: [
|
||||
{ name, wire, source: json | lookup, lookup?, codec }
|
||||
]
|
||||
cancellation?: { parameter: 'signal' }
|
||||
result: codec
|
||||
sourceLocation
|
||||
}
|
||||
```
|
||||
|
||||
`method` is the external short name used by the endpoint and Client Remote; `implementation` is the actual member name on the Host receiver. `implementation` may be omitted when the two names match. A `direct` descriptor retains the original Service instance as the receiver. A Context descriptor first uses the corresponding Context provider to find the scoped Context, then resolves the receiver by the descriptor's service key.
|
||||
|
||||
The strict generator writes `scope` only when a direct method has exactly one lookup parameter, a `TypeRTContextMap` declaration with the same name exists, and both use the same wire type symbol. `scope.wire` must identify that lookup parameter. It declares that a consumer may fill this parameter from the Context in which the call occurs, without changing the Host receiver or endpoint. No scoped projection is generated when there are multiple lookups, no Context declaration, or mismatched wire types; a type mismatch is a build error.
|
||||
|
||||
Parameter order comes from the method signature. HTTP fields come from parameter names or lookup declarations. A cancellation descriptor reserves only the final `signal` position and keeps it outside named `args`; Connection or a direct Gateway caller supplies the actual signal. The Gateway does not infer optional fields, Context types, lookup types, or missing arguments from request contents, and it does not synthesize business defaults.
|
||||
|
||||
A LIB codec contains a Zod schema and a canonical `typeSymbol` consisting of "package + public subpath + export name." An SRC codec is marked only as `src-json`. When the Host and consumer run in different JavaScript realms, each holds its own Zod instances, but both sets are generated from the same TypeRT model and symbol keys.
|
||||
|
||||
Descriptors exist only in the local registry on each side. The wire carries only the `/api` channel, endpoint, and `{ args }` payload. The Host uses its descriptor to decode and invoke the method, while the Client uses its corresponding descriptor to encode arguments and validate the result.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that TypeRT Service.
|
||||
|
||||
Business-object and scoped-Context packages own stable declarations and default resolvers through `lookups.register()` and `contexts.registerHost()`; Host composition supplies effect-scoped asynchronous policies through `lookups.configure()` and `contexts.configureHost()`. Configuration may precede provider registration, but does not by itself make an identity available without a live provider; unloading the configuration restores the provider's default resolver. API Remotes creates the shared `agentFor()` resolver for `agent` and `session` lookups and the `agent` Host Context: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The standard Web API Proxy supplies its Agent defaults and scope setup and consumes that resolver for legacy methods. The `session` lookup returns the resolved Agent's Session, while the `agent` Host Context returns its Context, so all three projections share one resume lifecycle.
|
||||
|
||||
The registry's Host root entry has the complete `TypeRTService` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program.
|
||||
|
||||
## Canonical types, symbols, and Zod
|
||||
|
||||
Remote Client DTS does not copy business DTOs or redeclare structurally identical shadow types. It imports original symbols only from public, type-only subpaths that do not carry Host Cordis merges:
|
||||
|
||||
```text
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/types'
|
||||
```
|
||||
|
||||
Consequently, `SessionId`, the Agent wire ID, the request, and the result all refer to the same TypeScript declaration in the Host and Browser Client. A future TUI can reuse them without a second set of types. Go to Definition, renames, and Find References for a DTO return to the one source location for the business type instead of stopping at a copy in a generated file.
|
||||
|
||||
Remote methods themselves use declaration-map navigation. TypeRT anchors `InvocationModel.location` to the decorated Host method-name token and emits a source-map segment on the corresponding property of the namespace interface. For an adapter-backed endpoint, after the TypeScript editor resolves `ctx.remote.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition.
|
||||
|
||||
TypeRT generates a wire Zod codec for the same symbol key. The Host Gateway uses it to validate input and encode results, while the Client Remote uses it to encode arguments and validate responses. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON.
|
||||
|
||||
Named business types referenced by Remote methods must be exported from public, type-only subpaths. If the only reachable entry also imports Host Services, Cordis `Context` merges, or Host-only implementations, the build fails and requires the business package to provide a safe type entry. Primitives, literals, and simple compositions explicitly supported by TypeRT need no additional names.
|
||||
|
||||
A lookup parameter does not expose the `Agent` class to consumers. The Remote projection refers to the canonical ID type in the lookup declaration, such as `SessionId`, while the Host continues to resolve objects through the canonical `Agent` class symbol.
|
||||
|
||||
## Three artifact kinds and two TypeScript Programs
|
||||
|
||||
The Host and Client still use only two independent TypeScript Programs, but TypeRT generates three semantically distinct kinds of artifacts:
|
||||
|
||||
```text
|
||||
Host Program
|
||||
├─ typert.host.js / typert.host.d.ts
|
||||
│ Host 自身的 Service、Event、Object、schema 和 inbound Gateway 信息
|
||||
└─ typert.remote-client.js / typert.remote-client.d.ts / typert.remote-client.d.ts.map
|
||||
Host Remote 对任意消费环境的 wire 投影
|
||||
|
||||
Client Program
|
||||
└─ typert.client.js / typert.client.d.ts
|
||||
Client 自身的 Service、Event、Object 和 schema 信息
|
||||
```
|
||||
|
||||
`remote-client` is the Host Program's second emitter, not a third Program or the Client's local face. It contains no Host Cordis merge, Service class, Context class, or implementation code, and it does not enter the Host-local reflection registry.
|
||||
|
||||
The Host lib build performs strict Host analysis and emits both the Host-local and Remote consumer artifacts. The Client lib then consumes the Remote DTS. The complete order is:
|
||||
|
||||
```text
|
||||
Host lib build
|
||||
→ 生成 typert.host.{js,d.ts}
|
||||
→ 生成各业务包 lib/typert.remote-client.{js,d.ts,d.ts.map}
|
||||
→ 完成 Client lib 和 typert.client 产物
|
||||
→ Vite 构建 Web
|
||||
```
|
||||
|
||||
The existing top-level `build` still runs `build:lib` before `build:web`, but `build:lib` must complete the Host and Remote artifacts before starting Client TypeScript compilation. A clean build must not depend on stale `.d.ts` files from an earlier build.
|
||||
|
||||
Compiler-backed repository gates that resolve the consumer surface have the same prerequisite even when their primary inputs are source files. The public `typecheck`, `lint`, and `doc-typecheck` commands run the Host contract pass first. The gate scheduler may use their `*:contracts-ready` variants only after an explicit TypeRT-contract or complete-build dependency, so parallel lanes neither read missing declarations nor run concurrent generators against the same outputs.
|
||||
|
||||
## The `/remote` package entry
|
||||
|
||||
Every business package that provides Remote methods exports a generated `/remote` subpath:
|
||||
|
||||
```text
|
||||
"./remote": {
|
||||
"types": "./lib/typert.remote-client.d.ts",
|
||||
"default": "./lib/typert.remote-client.js"
|
||||
}
|
||||
```
|
||||
|
||||
Consumer code selects a capability through the business package itself:
|
||||
|
||||
```text
|
||||
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
|
||||
```
|
||||
|
||||
This import brings the `.d.ts` map augmentation into the current TypeScript project while supplying the JS descriptor for the same contract as a value to the runtime. A business package that is not imported does not extend the current project's Remote API types.
|
||||
|
||||
The business package's published files must include both `lib/typert.remote-client.d.ts.map` and the `src` file referenced by that map. The generated DTS refers to its adjacent map with `//# sourceMappingURL=typert.remote-client.d.ts.map`; the map source points from `lib` to the business source by a relative path such as `../src/index.ts`. The `/remote` export does not list the map separately; the package `files` field publishes it together with the source.
|
||||
|
||||
Code that needs only static types may use `import type {} from '@deepseek-ai/dsh-goal/remote'`. This import is erased at runtime, loads no JS, and cannot trigger runtime registration. An environment that makes real calls must pass the contribution from a normal value import to the Client Remote Service.
|
||||
|
||||
Workspace resolution for `/remote` must explicitly target generated `lib` artifacts and must not let a general package-to-`src` paths rule redirect it to Host source. Ordinary business imports may continue resolving to SRC or LIB according to each environment's existing rules.
|
||||
|
||||
## Strict consumer API types
|
||||
|
||||
Remote DTS extends the flat endpoint map, direct namespace interface, namespace map, and scoped map without augmenting the global Cordis `Context`:
|
||||
|
||||
```text
|
||||
interface TypeRTRemoteNamespace$676f616c73 {
|
||||
create: (
|
||||
agentId: SessionId,
|
||||
request: CreateGoalRequest,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<CreateGoalResult>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteMap {
|
||||
'goals/create': (
|
||||
agentId: SessionId,
|
||||
request: CreateGoalRequest,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<CreateGoalResult>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteNamespaceMap {
|
||||
goals: TypeRTRemoteNamespace$676f616c73
|
||||
}
|
||||
|
||||
interface TypeRTRemoteScopeMap {
|
||||
'agent:goals/create': (
|
||||
request: CreateGoalRequest,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<CreateGoalResult>
|
||||
}
|
||||
```
|
||||
|
||||
`TypeRTRemoteMap` preserves canonical endpoint signatures for protocol typing and reflection. The root Remote type reads `TypeRTRemoteNamespaceMap` directly instead of deriving methods indirectly through a key-remapped mapped type; the TypeScript Language Service cannot reliably navigate such indirect properties through a declaration map. A namespace interface name encodes the namespace's UTF-8 bytes as hexadecimal, so `goals` deterministically becomes `TypeRTRemoteNamespace$676f616c73`. Different packages generate the same interface name for the same namespace and use module augmentation to merge their methods, while `TypeRTRemoteNamespaceMap.goals` always refers to that one type.
|
||||
|
||||
TypeRT projects `TypeRTRemoteScopeMap` onto a dedicated Scope type according to its Context key. The final programming interface remains:
|
||||
|
||||
```text
|
||||
ctx.remote.goals.create(agentId, request)
|
||||
agentCtx.remote.goals.create(request)
|
||||
```
|
||||
|
||||
The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteScope('agent')` method also omits a separate Scope identity, but generates only the scoped signature. The root `Context` exposes direct namespaces through `ctx.remote`, while `AgentContext.remote` intersects that direct surface with the scoped surface. A future TUI must preserve the same distinction.
|
||||
|
||||
`TypeRTClientRemote` remains platform-independent, and the Browser Client exposes it as `ctx.remote`. If a future TUI reuses this type, it must likewise access it through a dedicated Remote object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps.
|
||||
|
||||
## Client TypeRT and the API Gateway Client face
|
||||
|
||||
TypeRT in a consumer environment maintains both local information and Remote information imported from other environments, but stores them in separate registries:
|
||||
|
||||
```text
|
||||
TypeRT.local 当前环境自己的反射模型
|
||||
TypeRT.remotes 已导入的 Remote contribution
|
||||
```
|
||||
|
||||
`@deepseek-ai/dsh-api-remotes/client` centrally loads the required Remote contributions:
|
||||
|
||||
```text
|
||||
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
|
||||
import sessionsRemote from '@deepseek-ai/dsh-session/remote'
|
||||
|
||||
await ctx.remote.$mount(goalsRemote)
|
||||
await ctx.remote.$mount(sessionsRemote)
|
||||
```
|
||||
|
||||
Client business packages depend only on `@deepseek-ai/dsh-api-remotes/client`, not directly on the API Gateway or the runtime entry of each business `/remote`. API Remotes consumes the shared `TypeRTClientRemote` contract and Cordis `ctx.remote` service, then re-exports declarations so the selected Remote map reaches business compilation. Adding or removing a complete Client capability changes only this assembly point.
|
||||
|
||||
`ctx.remote.$mount()` registers a contribution with `TypeRT.remotes`, installs its namespace Services and concrete methods, and resolves only after they are ready. Its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately.
|
||||
|
||||
The Client Remote Service materializes each `@Remote` descriptor as a real function on a `remote.<namespace>` child Service. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`. For a cancellation-aware descriptor, the generated function accepts a final optional signal and combines it with the contribution mount lifetime; unmounting therefore cancels every in-flight carrier call, while a caller can cancel one call independently.
|
||||
|
||||
Neither a direct descriptor with `scope` nor a `@RemoteScope` descriptor copies functions into every Agent Scope. The Client Remote Service creates one Cordis child Service per namespace, registered as `remote.<namespace>`, and materializes direct and scoped variants on it. Accessing a method through `agentCtx.remote.goals` captures the current Agent Context before returning the callable handle. The method then asks the corresponding Context binder for identity from that Context. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Remote Scope descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call.
|
||||
|
||||
```text
|
||||
root ctx.remote.goals.create(agentId, request)
|
||||
→ direct descriptor
|
||||
→ ctx.connection.rpc.call('/api', 'goals/create', { args })
|
||||
|
||||
agentCtx.remote.goals.create(request)
|
||||
→ remote.goals accessor 捕获 agent Context
|
||||
→ agent binder 从 caller Context 取得 agentId
|
||||
→ 用 agentId 补入同一 direct descriptor 的 lookup 参数
|
||||
→ ctx.connection.rpc.call('/api', 'goals/create', { args })
|
||||
```
|
||||
|
||||
The root `Context` merges only the direct `TypeRTClientRemote` surface. `AgentContext` replaces that property with the intersection of `TypeRTClientRemote` and `TypeRTRemoteScopeApi<'agent'>`, so scoped-only methods remain unavailable from root code. If a caller bypasses the type system and dynamically calls a scoped-only method from Root, the binder reports an explicit error. If the Client already has a Cordis service named `remote.<namespace>`, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service.
|
||||
|
||||
Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The Client Remote Service creates real functions from that data, so the runtime does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection.
|
||||
|
||||
## Cross-environment isomorphism constraints
|
||||
|
||||
Remote API is a consumer capability, not a synonym for Browser API. The shipped runtime implements Browser Client contribution mounting, Connection RPC calls, and Agent Scope association.
|
||||
|
||||
Remote DTS, Remote JS, `TypeRTClientRemote`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api` RPC calls.
|
||||
|
||||
A future TUI can join the same call abstraction without changing business decorators, Remote maps, or the shape of API calls. The TUI-visible API must still be generated exclusively from `@Remote` and `@RemoteScope`; sharing a process with the Host must not allow it to bypass Remote restrictions and expose Service methods directly.
|
||||
|
||||
TUI runtime mounting, carriers, Agent Scope association, and SRC startup wiring are outside this phase.
|
||||
|
||||
The Web already depends on build artifacts such as `lib/client.js`, so it requires a complete `build:lib` before startup. After the Host Remote contract changes, developers rebuild the lib and then start or restart the Web. Incremental watching of the Remote contract is not implemented.
|
||||
|
||||
## SRC and LIB operating modes
|
||||
|
||||
SRC supports local source startup. The `WeakMap` records created by `@Remote` and `@RemoteScope()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor.
|
||||
|
||||
For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object.
|
||||
|
||||
A signature that SRC cannot resolve unambiguously fails on the first invocation that resolves its descriptor; Service mounting records only the decorator marker and does not inspect the JavaScript signature. SRC does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types.
|
||||
|
||||
LIB supports CI, releases, and the prerequisite Web build. TypeRT scans the complete Host project and checks Remote decorators, explicit bindings, service keys, endpoint conflicts, lookup/Context declarations, public-symbol reachability, JSON codecs, result codecs, and that a reserved final `signal` parameter has the global `AbortSignal` type, then generates strict descriptors.
|
||||
|
||||
At runtime, LIB only loads definitions from `lib`; it does not start the TypeScript compiler. The subsequent association of Services, lookup, Context resolution, invocation, and response encoding in the Host Gateway does not depend on whether a descriptor came from permissive SRC parsing or strict LIB generation.
|
||||
|
||||
CI and releases use LIB. Moving all repository coverage to LIB is separate follow-up work and does not block this direct-method-call implementation.
|
||||
|
||||
## Host Gateway resolution
|
||||
|
||||
The Host Gateway registers one `/api` interceptor with Connection and does not maintain a second endpoint registry. Its ownership matcher checks the current TypeRT local registry first, then consults an invalidation-aware set populated by scanning current Cordis Services for `typertGateway` bindings and SRC Remote markers. A Cordis Service change discards the set, so TypeRT definitions and business Services may arrive in either order without making legacy `/api` traffic rescan every Service on each request or letting arbitrary request paths grow the cache.
|
||||
|
||||
Invocation resolves the descriptor, receiver, lookup providers, and Context provider again from current state. A current strict descriptor takes precedence over SRC. After a strict endpoint has appeared, `TypeRTLocalRegistry.hasSeen()` keeps it owned when that descriptor is withdrawn and forbids SRC fallback for the remainder of the registry lifetime; re-registering the strict descriptor restores calls. Removing a Service or provider makes invocation fail explicitly, and the Gateway neither retains invalid objects nor invokes a method with a raw lookup ID.
|
||||
|
||||
An ordinary `@Remote` call retains the original Service instance as receiver. After lookups succeed, the Gateway calls the member identified by `implementation ?? method` with parameters in descriptor order, followed by the carrier signal when the descriptor declares cancellation.
|
||||
|
||||
A `@RemoteScope('agent')` call first asks the Agent Context provider to resolve the wire identity, then reads the descriptor's service key from that Context and invokes the scoped receiver. The business method receives neither a hidden Context parameter nor an Agent ID.
|
||||
|
||||
```text
|
||||
ctx.typertGateway.invoke({ namespace, method, args, signal })
|
||||
→ 查找本地 InvocationDescriptor 与 live receiver
|
||||
→ 按参数 descriptor 读取具名 wire 字段
|
||||
→ codec 解码普通值或 lookup ID
|
||||
→ lookup provider 把 ID 解析为活对象
|
||||
→ direct 使用原 Service;context 先解析 scoped Context 和 Service
|
||||
→ cancellation descriptor 存在时把 signal 追加到业务参数末尾
|
||||
→ Reflect.apply(receiver[implementation ?? method], receiver, orderedArgs)
|
||||
→ result codec 编码业务结果
|
||||
```
|
||||
|
||||
`ctx.typertGateway.invoke()` is the carrier-independent Host entry point. It neither creates an rpcId, RPC envelope, nor HTTP response. It returns only the encoded result or raises a Gateway error that the Connection RPC adapter maps for transport.
|
||||
|
||||
## The shared `/api` call chain
|
||||
|
||||
Connection owns one `/api` route on the HTTP Server. The Gateway mounts a synchronous endpoint ownership test and the Remote RPC handler into Connection:
|
||||
|
||||
```text
|
||||
ctx.connection.rpc.intercept(
|
||||
'/api',
|
||||
endpoint => ownsRemoteEndpoint(endpoint),
|
||||
(endpoint, payload, signal) => {
|
||||
const { namespace, method } = parseEndpoint(endpoint)
|
||||
const { args } = parsePayload(payload)
|
||||
return ctx.typertGateway.invoke({ namespace, method, args, signal })
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
The Gateway claims an endpoint when the Host registry contains its strict descriptor, remembers a withdrawn strict descriptor, or finds a matching `@Remote` marker on an active SRC Service binding. A claimed endpoint stays in the Gateway after payload decoding, descriptor resolution, or invocation fails; only an endpoint that is not Remote-owned reaches the legacy API Proxy fallback.
|
||||
|
||||
The Connection Host half passes one composite FetchHandler to the HTTP bridge. After the bridge creates a standard `Request`, that handler selects either the Gateway RPC FetchHandler or the API Proxy FetchHandler. Both paths reuse the same request/response envelope, rpcId, serialization, trust, transport errors, and `RpcError`. The current physical mapping is:
|
||||
|
||||
```text
|
||||
POST /api/<namespace>/<method>
|
||||
```
|
||||
|
||||
The Remote payload is a named JSON object, not a positional array, and does not carry an `InvocationDescriptor`. A normal Goal call has this payload slot:
|
||||
|
||||
```json
|
||||
{
|
||||
"args": {
|
||||
"agentId": "session-1",
|
||||
"request": {
|
||||
"objective": "finish the migration"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The complete path is:
|
||||
|
||||
```text
|
||||
ctx.remote.goals.create(sessionId, request, signal?)
|
||||
→ Client InvocationDescriptor 编码 { args: { agentId, request } }
|
||||
→ Client 合并 caller signal 与 contribution mount lifetime
|
||||
→ ctx.connection.rpc.call('/api', 'goals/create', { args }, signal)
|
||||
→ Connection 创建 rpcId 和既有 client-request envelope
|
||||
→ 当前 carrier 发送 POST /api/goals/create
|
||||
→ Connection Host half 执行共享 trust,再由 bridge 创建标准 Request
|
||||
→ 复合 FetchHandler 判断 endpoint ownership 并选择目标 FetchHandler
|
||||
→ TypeRT interceptor 调用 ctx.typertGateway.invoke(..., request.signal)
|
||||
→ Host InvocationDescriptor 解码、lookup、receiver 解析并把 signal 注入 Reflect.apply
|
||||
→ result codec 编码
|
||||
→ Connection 写入既有 RPC result 并回送相同 rpcId
|
||||
→ Client result codec 验证并返回 CreateGoalResult
|
||||
```
|
||||
|
||||
Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The adapter converts ordinary Gateway and business-invocation failures to the existing `RpcError` envelope with `code: 'internal'`; an existing RPC error carried by a resolver in `TypeRTLookupFailure` is returned unchanged, preserving stable error codes for cold-resume failures and ownership fences. The Gateway's structured error category remains available only in-process, while the message carries the diagnostic across Connection.
|
||||
|
||||
The Gateway does not handle per-method permissions, caller identity, idempotency, or long-lived connection state. It only propagates cooperative cancellation from Connection into explicitly cancellation-aware business methods. TypeRT endpoints use Connection's trusted-host policy; unclaimed endpoints retain the legacy API Proxy's trust and privileged-method policies. Connection's WebSocket migration remains separate follow-up work.
|
||||
|
||||
## Connection and protocol boundaries
|
||||
|
||||
The Client Remote Service owns Remote contributions, namespace Service materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, endpoint ownership, lookup, Context, and business invocation. Connection sends `/api`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client Remote types.
|
||||
|
||||
The Gateway registers only its ownership matcher and RPC handler with Connection; it does not register an HTTP route. Connection mounts the shared `/api` route into the HTTP Server and gives the bridge one composite FetchHandler; that handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. A future Connection transport can preserve this order without changing the Remote payload, business decorators, generated DTS, Remote API types, or Agent Scope programming interface.
|
||||
|
||||
## Package boundaries
|
||||
|
||||
- `@deepseek-ai/dsh-type-meta`: lightweight protocols for decorators, bindings, lookup, Remote Scope, and descriptors.
|
||||
- TypeRT generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information.
|
||||
- TypeRT runtime: separately stores the current environment's local reflection and imported Remote contributions.
|
||||
- `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict Remote namespace Services and methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges.
|
||||
- `@deepseek-ai/dsh-api-remotes`: the BFF layer; owns the Host Agent/Session resolver, selects Client `/remote` contributions, and exposes the merged Remote types to business packages through the shared `TypeRTClientRemote` contract.
|
||||
- Connection: owns the single HTTP Server/future WebSocket carrier, shared `/api` route and composite FetchHandler, API Proxy fallback, RPC envelope, rpcId, serialization, trust, and error transport.
|
||||
- Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries.
|
||||
- API Proxy Host composition: supplies Web Agent defaults and scope setup to API Remotes and consumes the same `agentFor()` for legacy methods.
|
||||
- Business Service packages: declare bindings, Remote methods, and their request/result types, and export the generated `/remote` subpath.
|
||||
|
||||
## Shipped scope and deferred work
|
||||
|
||||
The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.remote.goals.create(agentId, request)` and `agentCtx.remote.goals.create(request)`. Ordinary cold sessions are resumed through `agentFor()` during lookup, while subagent-owned identities retain the existing `agent-busy` fence; `@RemoteScope('agent')` remains the distinct scoped-receiver mode.
|
||||
|
||||
Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision.
|
||||
|
||||
The package topology is `api/remotes → api/gateway → client/connection → host/webserver`. Connection and WebServer retain their existing paths in this change; moving them later to `api/connection` and `api/webserver` changes package placement rather than these service boundaries. The legacy API Proxy likewise remains under `host/apiproxy` as the fallback for methods not yet migrated to Remote.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Continue using the central API Proxy package.** This would require business methods, Host routes, and Client interfaces to be declared repeatedly in several locations. It would also keep direct calls, stateful interactions, and event streams tied to the same lifecycle, so this alternative is rejected.
|
||||
|
||||
**Perform strict reflection through decorators at runtime.** JavaScript decorators cannot recover erased TypeScript types, public symbol identity, or complete Zod codecs. Injecting a compiler-private symbol into a constructor would also hide the business class's real dependencies, so TypeRT generates strict information at compile time.
|
||||
|
||||
**Use a preload, loader hook, or complete `ts.Program` during SRC startup.** This could reuse LIB analysis but would add requirements to every source startup entry. SRC needs only a usable permissive descriptor, so it uses decorator markers, function parameter names, and explicit providers; strict checks remain in the LIB contract pass.
|
||||
|
||||
**Hand-write the Client interface.** A hand-written interface cannot guarantee that it contains only Remote-marked methods and can drift from Host signatures, lookup IDs, and Zod schemas. Client types are therefore projected automatically from the Host Program.
|
||||
|
||||
**Use a TypeScript language-service/compiler plugin to make the Client understand decorators directly.** This would require editors, Vite, tsc, tsx, and published consumers to install an additional plugin, making integration too invasive. The design instead generates ordinary `.d.ts` files and standard declaration maps.
|
||||
|
||||
**Import complete Host DTS into the Client or TUI.** This would pull in Host Services and Cordis interface merges while exposing unmarked methods to consumers. Remote DTS refers only to public, type-only symbols and augments dedicated Remote maps.
|
||||
|
||||
**Generate only Remote DTS, without JS.** Types would work, but the runtime could not enumerate endpoints, codecs, and Context modes without a Proxy or another hand-written registry. The same Host projection therefore emits a Remote JS contribution as well.
|
||||
|
||||
**Let a top-level `/remote` import register global state implicitly.** The target Cordis Context may not exist when ESM evaluation occurs, and ownership becomes ambiguous across multiple Contexts, HMR, and disposal. A normal value import therefore returns only a contribution, which the environment assembly explicitly mounts through the Client Remote Service.
|
||||
|
||||
**Create a separate transport, HTTP route, or `/api2` channel for Remote.** This would duplicate or split Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle. The shared `/api` interceptor instead keeps one physical route and lets Connection preserve API Proxy as the fallback FetchHandler.
|
||||
|
||||
## Verification
|
||||
|
||||
- Goal Service directly decorates mutation methods whose business signatures already match the Remote contract and keeps `remoteExportCreate(...)` only to adapt `GoalView` into `CreateGoalResult`, without a second route, codec, or Client method list.
|
||||
- A clean `build:lib` emits Host and consumer Remote artifacts before Client compilation, including the business package's JS, DTS, and declaration map under `/remote`.
|
||||
- After `clean`, standalone `typecheck`, `lint`, and `doc-typecheck` regenerate the Remote contracts; the pre-push hook uses the same prepared typecheck, and CI source consumers wait for one shared contract pass.
|
||||
- Importing `@deepseek-ai/dsh-goal/remote` adds the strict `ctx.remote.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace.
|
||||
- Mounting the same import's JS contribution supplies endpoint, parameter, result, lookup, Context, and Zod reflection and materializes the call without a handwritten stub.
|
||||
- Root and Agent-scoped calls cross the real shared `/api` carrier, resolve `agentId` to the live Agent, invoke the original Goal receiver, and return through the existing RPC envelope.
|
||||
- Agent and Session lookups share a single in-flight cold-session resume; ordinary cold sessions receive restored objects, while both cold and live subagent identities return `agent-busy` before business invocation.
|
||||
- The Remote artifacts and maps contain only marked methods and no Browser dependency, preserving the same consumer boundary for a future TUI.
|
||||
- Lifecycle tests withdraw and remount descriptors, Services, lookups, Context providers, and Client namespaces; unavailable dependencies fail without stale calls or raw-ID fallback.
|
||||
- Cancellation tests cover strict generation, SRC final-name recognition, Client signal fusion, Connection-to-Gateway propagation, and Host injection outside wire `args`.
|
||||
- Unclaimed endpoints continue through the existing API Proxy path with its trust, privileged-method, Permission/Approval, and Session event-stream behavior unchanged.
|
||||
|
||||
## Consequences
|
||||
|
||||
Remote API types depend on generated `lib` declarations. Build and gate orchestration must finish the Host contract pass before compiling or semantically analyzing Host and Client consumers; an incorrect order makes a clean command depend on stale artifacts.
|
||||
|
||||
Source navigation requires a Remote package to publish both its declaration map and the `src` file referenced by the map. If package `files` omits either side, types still compile but consumer navigation stops at the generated DTS. The workspace manifest check must therefore treat both as one publication contract.
|
||||
|
||||
The permissive SRC descriptor does not validate the internal structure of ordinary JSON. After a Host Remote signature changes, the Web and strict type consumers must rebuild the lib because no incremental contract watcher exists.
|
||||
|
||||
Canonical public types require business DTOs to have type-only entries, which may expose packages whose Host types and implementation entries are currently mixed. The build rejects those boundaries instead of copying types to conceal them.
|
||||
|
||||
Type imports and runtime contributions have different effects. `import type {}` extends only the static Remote surface. If a real calling environment omits the value contribution, the Client Remote Service must fail with an explicit "Remote not mounted" error.
|
||||
|
||||
Browser and Host each hold their own Zod instances and cannot compare object identities across realms. Consistency is guaranteed only by canonical symbol keys, the same generated model, and wire behavior.
|
||||
|
||||
A consumer may import a Remote contract that is not currently mounted on the Host. The types mean "this protocol capability was selected by the consumer," not that a corresponding Service currently exists in the target process; an unavailable endpoint must fail explicitly at runtime.
|
||||
|
||||
Connection's general channel API must suit both the current HTTP carrier and a future WebSocket carrier. If the Client Remote or Gateway exposes `fetch`, an HTTP request, or a route handle, WebSocket migration will pierce the Remote layer again. Those physical objects must therefore remain internal to Connection.
|
||||
|
||||
Remote endpoints use Connection's `trusted-host` authority. Loopback is accepted by default and LAN callers require an explicit trusted-host configuration, but this layer adds no per-method caller authorization; every trusted host can invoke a mounted Remote endpoint.
|
||||
|
||||
`hasSeen()` favors strict-definition safety over SRC availability. While a strict descriptor is withdrawn, such as during HMR, the Gateway continues to claim the endpoint and reports it unavailable instead of falling back to a weak SRC descriptor. Re-registration restores it; only a TypeRT registry restart forgets the historical strict definition.
|
||||
|
||||
Cancellation-aware Remote signatures receive Connection's request `AbortSignal`, so an HTTP disconnect or Client-side abort reaches ongoing business work without entering the JSON protocol. Cancellation remains cooperative: methods without the reserved final parameter continue running, and a method that receives the signal must pass it to its own cancellable operations or observe it directly.
|
||||
|
||||
Lookup configuration currently operates at key granularity, so every `agent` or `session` parameter uses the same cold-resume policy. A specific Remote that requires live-only semantics must wait for an explicit per-parameter or per-endpoint policy; the business implementation cannot be left to guess whether the object was just resumed.
|
||||
@@ -0,0 +1,529 @@
|
||||
# Agent Note: TypeRT Gateway 定向方法调用
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-02-typert-remote-method-calls.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Host API Proxy 同时承担直接方法调用、带状态交互和 Session 事件流。三者的生命周期、路由语义和客户端编程界面不同,继续共用一个业务导出包会让业务 Service、传输协议、状态机和客户端类型彼此耦合。
|
||||
|
||||
本决策只涵盖一次请求对应一次结果的定向方法调用。Permission、Approval 等带状态交互以及 Session 事件流仍采用独立设计。
|
||||
|
||||
直接方法调用的契约属于实现该行为的业务 Service。业务开发者只需声明哪些方法可以远程调用,无需再同步维护中央 API 接口、路由表、参数转换表、客户端 stub 和 Zod schema。
|
||||
|
||||
Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以不同类型合并同名 Cordis `Context`。Remote 投影不能把完整 Host 声明导入消费端,也不能依赖 Browser 专属类型;未来 TUI 若复用这套编程界面,也只能看到 Remote 标记的方法。本期不实现 TUI 接入,但实现边界不得阻断这种同构复用。
|
||||
|
||||
## 决策
|
||||
|
||||
业务 Service 继承 `GatewayService`,并通过 `@Remote` 或 `@RemoteScope()` 声明可调用方法;已有其他基类的 Service 可以改用 `bindTypeRTGateway()` 暴露同一绑定。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。
|
||||
|
||||
Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client Remote Service;该投影和 Remote 抽象保持平台无关,以便未来 TUI 复用。
|
||||
|
||||
`@deepseek-ai/dsh-api-gateway` 位于 `packages/api/gateway`,提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.remote`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。
|
||||
|
||||
`@deepseek-ai/dsh-api-remotes` 位于 `packages/api/remotes`,是 Gateway 上层的 BFF 层。其 Host 入口负责 Agent/Session 身份解析与 TypeRT lookup 配置;`/client` 入口选择应用对外暴露的生成 Remote contribution。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 契约,而不导入具体 Gateway 实现。
|
||||
|
||||
## 组件和 Cordis 服务
|
||||
|
||||
| 组件 | Cordis 服务 | 职责 |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-type-meta` | 只声明 `ctx.typert` 的最小协议 | `GatewayService`、decorator、binding 回退、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser |
|
||||
| TypeRT registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider |
|
||||
| TypeRT generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` |
|
||||
| API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 |
|
||||
| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、TypeRT 拦截和旧 API Proxy 回退 |
|
||||
| API Gateway 的 Client face | `ctx.remote`、`ctx.remote.<namespace>` | mount Remote contribution,把每个 namespace 实体化为可追踪的 `remote.<namespace>` 子 Service,并把规范调用交给 `ctx.connection.rpc` |
|
||||
| API Remotes | 无新增服务 | 负责 Host Agent/Session lookup 策略,并作为 Client 业务的唯一 facade,选择并挂载 `/remote` contribution,同时暴露所选 API 声明 |
|
||||
| Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider |
|
||||
| Goal 等业务包 | 既有业务 Service | 只声明 binding、Remote 方法和唯一 DTO,并导出生成的 `/remote` 子路径 |
|
||||
|
||||
Host Gateway 不依赖 `ctx.agents`、`ctx.sessions`、`ctx.goals` 或 `ctx.httpServer` 的具体实现。Client Remote 不理解物理 carrier,Connection 也不理解 Goal、Agent、lookup、`InvocationDescriptor` 或 Remote namespace。
|
||||
|
||||
## 业务声明
|
||||
|
||||
普通直接调用使用 `@Remote`。现有方法的参数和结果已经是预期的 Remote 契约时,直接装饰该方法,不为此重命名。只有 wire 契约需要不同的请求或结果形态时,才新增 `remoteExport*` 适配器,并由 decorator 参数声明短 API 名。方法需要哪个业务对象,就在顶层参数位置显式声明该对象:
|
||||
|
||||
```text
|
||||
export class GoalService extends GatewayService {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'goals')
|
||||
}
|
||||
|
||||
create(agent: Agent, request: CreateGoalRequest): GoalView {
|
||||
// Existing business method remains unchanged.
|
||||
}
|
||||
|
||||
@Remote('create')
|
||||
remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult {
|
||||
const view = this.create(agent, request)
|
||||
return { ref: { id: view.id, revision: view.revision } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`goals` 是传给 `super()` 的明确 Cordis service key,并默认作为 wire namespace。只有协议 namespace 确实需要与 service key 不同时,才通过第三个参数传入 `namespace` 选项。
|
||||
|
||||
需要在某类隔离 Context 中查找 Service receiver 时使用 `@RemoteScope()`。Scope identity 不进入业务方法参数:
|
||||
|
||||
```text
|
||||
export class ScopedGoalService extends GatewayService {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'goals')
|
||||
}
|
||||
|
||||
@RemoteScope('agent', 'create')
|
||||
remoteExportCreate(request: CreateGoalRequest): Promise<CreateGoalResult> {
|
||||
// Runs against the goals service resolved from the Agent Context.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
同一个 endpoint 只能选择一种调用模式。需要显式 `Agent` 参数的流程使用 `@Remote`;需要切换到 Agent Context 再解析 scoped receiver 的流程使用 `@RemoteScope('agent')`,两者不会由 TypeRT 根据方法体或参数缺失自动猜测。
|
||||
|
||||
业务包只依赖轻量的 `@deepseek-ai/dsh-type-meta`。它提供 `GatewayService`,以及 decorator、binding 回退、lookup、Remote Scope 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。
|
||||
|
||||
支持协作式取消的方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。这个保留参数不是业务值、lookup 或 JSON 字段。生成的消费方方法将其暴露为最后一个可选参数,因此普通调用保持不变,而拥有取消控制权的调用方可以传入 signal。
|
||||
|
||||
## Decorator 与显式 Gateway facet
|
||||
|
||||
Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteScope('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `GatewayService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。
|
||||
|
||||
SRC 运行时允许 decorator 在 `dsh-type-meta` 内部的 `WeakMap` 记录 prototype、方法名和调用模式。它不向 Service 实例、prototype、constructor 或方法函数写入自定义属性。
|
||||
|
||||
LIB 的严格方法发现、类型解析和 descriptor 生成由 TypeRT compiler 完成。它接受 `GatewayService` 直接 `super()` 调用中的字面量 service key,或显式 binding 回退;生成过程不改写业务源码,也不注入隐藏注册元数据。
|
||||
|
||||
## Lookup 与 Remote Scope 注册
|
||||
|
||||
Gateway 不内置 Agent、Session 或其他业务对象分支。对象所属包同时提供静态声明和运行时 provider:
|
||||
|
||||
```text
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTLookupMap {
|
||||
agent: TypeRTLookup<Agent, SessionId>
|
||||
}
|
||||
}
|
||||
|
||||
ctx.typert.lookups.register('agent', {
|
||||
parameter: 'agent',
|
||||
wire: 'agentId',
|
||||
resolve: sessionId => resolveAgent(sessionId),
|
||||
})
|
||||
```
|
||||
|
||||
静态声明让 TypeRT 知道 `Agent` 在 wire 上对应 `SessionId`;运行时 provider 负责把请求中的 `agentId` 解析为当前活的 `Agent` 对象。缺少任一侧时,LIB 构建或最早可解析的运行时注册直接失败。
|
||||
|
||||
Agent、Session 等 lookup 对象只能各自占据一个顶层参数位置。普通 JSON request 可以作为另一个完整参数传入,但本设计不支持 `request.agent`、对象解构、对象数组、嵌套 lookup 或从任意复杂结构中搜索 ID。
|
||||
|
||||
Remote Scope 使用独立的 merge-extensible map 和 Context provider。Agent 包注册 `agent` provider,负责用 wire identity 找到 Agent Context,并从该 Context 解析 descriptor 指定的 service key;Gateway 不知道 Agent Context 的内部结构。
|
||||
|
||||
Client 侧也注册 `agent` Context binder。binder 只负责从一次调用所在的 Context 取得 `SessionId`;它不枚举 Scope,也不逐个复制方法。scoped namespace 由 Cordis Service tracker 自动 rebind 到当前 Agent Context。
|
||||
|
||||
## InvocationDescriptor
|
||||
|
||||
TypeRT、SRC 弱解析器、Host Gateway 和 Client Remote 之间只交换一种规范描述:
|
||||
|
||||
```text
|
||||
InvocationDescriptor {
|
||||
id: '@deepseek-ai/dsh-goal#goals/create'
|
||||
service: 'goals'
|
||||
namespace: 'goals'
|
||||
method: 'create'
|
||||
implementation: 'remoteExportCreate'
|
||||
invocation: direct | { context: 'agent', wire: 'agentId' }
|
||||
scope?: { context: 'agent', wire: 'agentId' }
|
||||
parameters: [
|
||||
{ name, wire, source: json | lookup, lookup?, codec }
|
||||
]
|
||||
cancellation?: { parameter: 'signal' }
|
||||
result: codec
|
||||
sourceLocation
|
||||
}
|
||||
```
|
||||
|
||||
`method` 是 endpoint 和 Client Remote 使用的外部短名,`implementation` 是 Host receiver 上的真实成员名;两者相同时可省略 `implementation`。`direct` descriptor 保留原始 Service 实例作为 receiver。Context descriptor 先通过对应 Context provider 找到 scoped Context,再以 descriptor 的 service key 解析 receiver。
|
||||
|
||||
严格生成器只在 direct 方法恰好包含一个 lookup 参数、同名 `TypeRTContextMap` 声明存在且两者使用同一 wire 类型 symbol 时写入 `scope`。`scope.wire` 必须指向该 lookup 参数;它声明消费端可以从调用所在 Context 补入这个参数,不改变 Host receiver 或 endpoint。多个 lookup、缺少 Context 声明或 wire 类型不一致时不生成 scoped 投影,其中类型不一致属于构建错误。
|
||||
|
||||
参数顺序来自方法签名,HTTP 字段来自参数名或 lookup 声明。取消 descriptor 只保留最后一个 `signal` 位置,并使其不进入具名 `args`;实际 signal 由 Connection 或直接调用 Gateway 的调用方提供。Gateway 不根据请求内容推断可选字段、Context 类型、lookup 类型或缺失参数,也不会合成业务默认值。
|
||||
|
||||
LIB codec 带有 Zod schema 和“package + 公共 subpath + export name”的规范 `typeSymbol`;SRC codec 只标记 `src-json`。Host 和消费端运行在不同 JavaScript realm 时会各自持有 Zod 实例,但这些实例由同一 TypeRT 模型和 symbol key 生成。
|
||||
|
||||
descriptor 只存在于两端本地 registry。wire 上只有 `/api` channel、endpoint 和 `{ args }` payload;Host 用自己的 descriptor 解码和调用,Client 用自己的对应 descriptor 编码参数和验证结果。
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
每次注册都返回由调用方 Cordis fiber 持有的 disposer。挂载 Client contribution 时,descriptor 集与具体方法会作为一项有明确所有者的操作统一注册。Host Gateway 只缓存 SRC 所认领的 endpoint 名称集合,并在 Cordis Service 集合发生变化时整体丢弃该集合;它不保留 descriptor、Service 或提供方。调用时会从当前状态解析所有活对象,因此移除 strict definition、Service 或提供方会使相应调用不可用,且不会留下陈旧的活对象。
|
||||
|
||||
lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 TypeRT Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。
|
||||
|
||||
业务对象包和 scoped Context 包通过 `lookups.register()` 与 `contexts.registerHost()` 拥有稳定声明和默认 resolver;Host 组合通过 `lookups.configure()` 与 `contexts.configureHost()` 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用身份;配置卸载后恢复 provider 默认 resolver。API Remotes 为 `agent`、`session` lookup 和 `agent` Host Context 创建共享的 `agentFor()` resolver:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,并让旧方法使用该 resolver。`session` lookup 返回解析所得 Agent 的 Session,`agent` Host Context 返回其 Context,因此三种投影共用一个恢复生命周期。
|
||||
|
||||
Registry 的 Host 根入口拥有完整 `TypeRTService` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。
|
||||
|
||||
## 唯一类型、符号与 Zod
|
||||
|
||||
Remote Client DTS 不复制业务 DTO,也不重新声明一个结构相同的影子类型。它只从不携带 Host Cordis merge 的公共纯类型 subpath 引用原始符号:
|
||||
|
||||
```text
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/types'
|
||||
```
|
||||
|
||||
因此 `SessionId`、Agent wire ID、request 和 result 在 Host 与 Browser Client 中都指向同一 TypeScript declaration,未来 TUI 复用时也不需要第二份类型。DTO 的跳转定义、重命名和引用查找回到业务类型的唯一源码位置,而不是停在生成文件中的副本。
|
||||
|
||||
Remote 方法本身使用 declaration map 导航。TypeRT 把 `InvocationModel.location` 固定在 Host 被装饰方法的方法名 token,并在 namespace interface 的对应属性上写入 source-map segment。对于由适配器支撑的 endpoint,TypeScript editor 从 `ctx.remote.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。
|
||||
|
||||
TypeRT 为同一 symbol key 生成 wire Zod codec。Host Gateway 用它校验输入和编码结果,Client Remote 用它编码参数并校验响应;复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。
|
||||
|
||||
Remote 方法引用的命名业务类型必须从纯类型公共 subpath 导出。如果唯一可达入口会带入 Host Service、Cordis `Context` merge 或 Host-only 实现,构建失败并要求业务包提供安全的类型出口。原始值、字面量和 TypeRT 明确支持的简单组合不需要额外命名。
|
||||
|
||||
lookup 参数不会把 `Agent` class 暴露给消费端。Remote 投影引用 lookup 声明中的唯一 ID 类型,例如 `SessionId`;Host 内部仍以唯一的 `Agent` class symbol 完成对象解析。
|
||||
|
||||
## 三种产物与两个 TypeScript Program
|
||||
|
||||
Host 与 Client 仍然只有两个独立 TypeScript Program,但 TypeRT 生成三种性质不同的产物:
|
||||
|
||||
```text
|
||||
Host Program
|
||||
├─ typert.host.js / typert.host.d.ts
|
||||
│ Host 自身的 Service、Event、Object、schema 和 inbound Gateway 信息
|
||||
└─ typert.remote-client.js / typert.remote-client.d.ts / typert.remote-client.d.ts.map
|
||||
Host Remote 对任意消费环境的 wire 投影
|
||||
|
||||
Client Program
|
||||
└─ typert.client.js / typert.client.d.ts
|
||||
Client 自身的 Service、Event、Object 和 schema 信息
|
||||
```
|
||||
|
||||
`remote-client` 是 Host Program 的第二个 emitter,不是第三个 Program,也不是 Client 本地 face。它不包含 Host Cordis merge、Service class、Context class 或实现代码,不进入 Host 本地 reflection registry。
|
||||
|
||||
Host lib 构建负责完成严格 Host 分析并产出 Host 本地 artifact 与 Remote 消费端 artifact;Client lib 随后消费 Remote DTS。完整顺序为:
|
||||
|
||||
```text
|
||||
Host lib build
|
||||
→ 生成 typert.host.{js,d.ts}
|
||||
→ 生成各业务包 lib/typert.remote-client.{js,d.ts,d.ts.map}
|
||||
→ 完成 Client lib 和 typert.client 产物
|
||||
→ Vite 构建 Web
|
||||
```
|
||||
|
||||
现有顶层 `build` 仍表现为先 `build:lib`、再 `build:web`,但 `build:lib` 内部必须先完成 Host 与 Remote artifact,再启动 Client TypeScript 编译。一次干净构建不能依赖上次残留的 `.d.ts`。
|
||||
|
||||
即使主要输入是源文件,需要通过编译器解析消费方 surface 的仓库门禁也有相同的前置条件。公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先执行 Host 契约 pass。门禁调度器仅可在显式的 TypeRT 契约依赖或完整构建依赖完成后使用对应的 `*:contracts-ready` 变体,使并行 lane 既不会读取缺失的声明,也不会针对同一输出并发运行多个生成器。
|
||||
|
||||
## `/remote` 包入口
|
||||
|
||||
每个提供 Remote 方法的业务包导出生成的 `/remote` 子路径:
|
||||
|
||||
```text
|
||||
"./remote": {
|
||||
"types": "./lib/typert.remote-client.d.ts",
|
||||
"default": "./lib/typert.remote-client.js"
|
||||
}
|
||||
```
|
||||
|
||||
消费代码通过业务包本身选择能力:
|
||||
|
||||
```text
|
||||
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
|
||||
```
|
||||
|
||||
该 import 让 `.d.ts` 的 map augmentation 进入当前 TypeScript project,同时把同一契约的 JS descriptor 作为值交给运行时。未 import 的业务包不会扩展当前 project 的 Remote API 类型。
|
||||
|
||||
业务 package 的发布文件必须同时包含 `lib/typert.remote-client.d.ts.map` 和 map 指向的 `src` 文件。生成 DTS 以 `//# sourceMappingURL=typert.remote-client.d.ts.map` 引用相邻 map;map 中的 source 从 `lib` 相对指向业务源码,例如 `../src/index.ts`。`/remote` export 不单独列出 map,package `files` 负责把它与源码一起发布。
|
||||
|
||||
仅需要静态类型时可以使用 `import type {} from '@deepseek-ai/dsh-goal/remote'`;这种 import 在运行时会被擦除,不会加载 JS,也不能触发任何运行时注册。需要真实调用的环境必须把普通 value import 得到的 contribution 交给 Client Remote Service。
|
||||
|
||||
workspace 对 `/remote` 的解析必须明确指向 `lib` 生成物,不能被通用 package-to-`src` paths 规则带回 Host 源码。普通业务 import 仍可按各环境既有规则解析到 SRC 或 LIB。
|
||||
|
||||
## 消费端严格 API 类型
|
||||
|
||||
Remote DTS 同时扩展平面 endpoint map、direct namespace interface、namespace map 和 scoped map,而不扩展全局 Cordis `Context`:
|
||||
|
||||
```text
|
||||
interface TypeRTRemoteNamespace$676f616c73 {
|
||||
create: (
|
||||
agentId: SessionId,
|
||||
request: CreateGoalRequest,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<CreateGoalResult>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteMap {
|
||||
'goals/create': (
|
||||
agentId: SessionId,
|
||||
request: CreateGoalRequest,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<CreateGoalResult>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteNamespaceMap {
|
||||
goals: TypeRTRemoteNamespace$676f616c73
|
||||
}
|
||||
|
||||
interface TypeRTRemoteScopeMap {
|
||||
'agent:goals/create': (
|
||||
request: CreateGoalRequest,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<CreateGoalResult>
|
||||
}
|
||||
```
|
||||
|
||||
`TypeRTRemoteMap` 保留规范 endpoint 签名,供协议类型和反射使用。根 Remote 类型直接读取 `TypeRTRemoteNamespaceMap`,不通过 key-remapped mapped type 间接推导方法;TypeScript Language Service 无法把这种间接属性稳定导航到 declaration map。namespace interface 名由 namespace 的 UTF-8 bytes 编成 hex,`goals` 因而稳定得到 `TypeRTRemoteNamespace$676f616c73`。不同 package 对同一 namespace 生成同名 interface,依靠 module augmentation 合并各自方法,且 `TypeRTRemoteNamespaceMap.goals` 始终引用同一类型。
|
||||
|
||||
TypeRT 把 `TypeRTRemoteScopeMap` 按 Context key 投影到专用 Scope 类型。最终编程界面保持:
|
||||
|
||||
```text
|
||||
ctx.remote.goals.create(agentId, request)
|
||||
agentCtx.remote.goals.create(request)
|
||||
```
|
||||
|
||||
Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteScope('agent')` 方法也省略独立的 Scope identity,但只生成 scoped 签名。根 `Context` 通过 `ctx.remote` 暴露 direct namespace,`AgentContext.remote` 则把该 direct surface 与 scoped surface 取交集。未来 TUI 复用时必须维持相同区分。
|
||||
|
||||
`TypeRTClientRemote` 保持平台无关,Browser Client 通过 `ctx.remote` 暴露它。未来 TUI 若复用该类型,也必须通过专用 Remote 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。
|
||||
|
||||
## Client TypeRT 与 API Gateway Client face
|
||||
|
||||
一个消费环境的 TypeRT 同时维护本地信息和从其他环境导入的 Remote 信息,但两者存放在不同 registry:
|
||||
|
||||
```text
|
||||
TypeRT.local 当前环境自己的反射模型
|
||||
TypeRT.remotes 已导入的 Remote contribution
|
||||
```
|
||||
|
||||
`@deepseek-ai/dsh-api-remotes/client` 集中加载需要的 Remote contribution:
|
||||
|
||||
```text
|
||||
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
|
||||
import sessionsRemote from '@deepseek-ai/dsh-session/remote'
|
||||
|
||||
await ctx.remote.$mount(goalsRemote)
|
||||
await ctx.remote.$mount(sessionsRemote)
|
||||
```
|
||||
|
||||
Client 业务包只引用 `@deepseek-ai/dsh-api-remotes/client`,不直接依赖 API Gateway 或各业务 `/remote` 运行时入口。API Remotes 消费共享的 `TypeRTClientRemote` 契约和 Cordis `ctx.remote` 服务,再重新导出声明,使所选 Remote map 进入业务编译;新增或移除整套 Client 能力只修改这一处 assembly。
|
||||
|
||||
`ctx.remote.$mount()` 把 contribution 注册到 `TypeRT.remotes`,安装它的 namespace Service 和具体方法,并在它们就绪后才 resolve。调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。
|
||||
|
||||
Client Remote Service 把 `@Remote` descriptor 实体化为 `remote.<namespace>` 子 Service 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`。对于支持取消的 descriptor,生成的函数接受最后一个可选 signal,并将其与 contribution 的挂载生命周期合并;因此卸载会取消所有正在进行的 carrier 调用,而调用方也可以单独取消一次调用。
|
||||
|
||||
带 `scope` 的 direct descriptor 和 `@RemoteScope` descriptor 都不为每个 Agent Scope 复制函数。Client Remote Service 为每个 namespace 创建一个注册为 `remote.<namespace>` 的 Cordis 子 Service,并在其上实体化 direct 与 scoped 变体。通过 `agentCtx.remote.goals` 取得方法时,accessor 会在返回可调用句柄前捕获当前 Agent Context。方法再通过对应 Context binder 从该 Context 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Remote Scope descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。
|
||||
|
||||
```text
|
||||
root ctx.remote.goals.create(agentId, request)
|
||||
→ direct descriptor
|
||||
→ ctx.connection.rpc.call('/api', 'goals/create', { args })
|
||||
|
||||
agentCtx.remote.goals.create(request)
|
||||
→ remote.goals accessor 捕获 agent Context
|
||||
→ agent binder 从 caller Context 取得 agentId
|
||||
→ 用 agentId 补入同一 direct descriptor 的 lookup 参数
|
||||
→ ctx.connection.rpc.call('/api', 'goals/create', { args })
|
||||
```
|
||||
|
||||
根 `Context` 只 merge direct `TypeRTClientRemote` surface;`AgentContext` 把该属性替换为 `TypeRTClientRemote` 与 `TypeRTRemoteScopeApi<'agent'>` 的交叉,因而 scoped-only 方法不会暴露给 root 代码。若调用方绕过类型从 Root 动态调用 scoped-only 方法,binder 明确报错。若 Client 已有名为 `remote.<namespace>` 的 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。
|
||||
|
||||
生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。Client Remote Service 据此创建真实函数,因此运行时不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。
|
||||
|
||||
## 跨环境同构约束
|
||||
|
||||
Remote API 是消费端能力,不等同于 Browser API。已交付的运行时实现 Browser Client contribution 挂载、Connection RPC 调用和 Agent Scope 关联。
|
||||
|
||||
Remote DTS、Remote JS、`TypeRTClientRemote`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api` RPC 调用。
|
||||
|
||||
未来 TUI 可以在不改变业务 decorator、Remote maps 和 API 调用形状的前提下接入同一调用抽象。届时 TUI 可见的 API 仍只能由 `@Remote` 和 `@RemoteScope` 生成,不能因为它与 Host 同进程就绕过 Remote 限制直接暴露 Service 方法。
|
||||
|
||||
TUI 的 runtime 挂载、carrier、Agent Scope 关联和 SRC 启动接线均不属于本期实现。
|
||||
|
||||
Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完整 `build:lib`。Host Remote 契约变化后,开发者需重新执行 lib build,再启动或重启 Web;系统不实现 Remote contract 的增量 watch。
|
||||
|
||||
## SRC 与 LIB 运行模式
|
||||
|
||||
SRC 面向本地源码启动。`@Remote` 和 `@RemoteScope()` 的 WeakMap 记录给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。
|
||||
|
||||
例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。
|
||||
|
||||
SRC 无法明确解析的签名会在首次调用解析其 descriptor 时失败;Service 挂载只记录 decorator 标记,不检查 JavaScript 签名。SRC 不会猜测对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 或复杂类型。
|
||||
|
||||
LIB 面向 CI、发布和 Web 前置构建。TypeRT 扫描完整 Host project,检查 Remote decorator、显式 binding、service key、endpoint 冲突、lookup/Context 声明、公共符号可达性、JSON codec、结果 codec,以及保留的最后一个 `signal` 参数是否具有全局 `AbortSignal` 类型,并生成严格 descriptor。
|
||||
|
||||
LIB 运行时只加载 `lib` 中的 definition,不启动 TypeScript compiler。Host Gateway 后续的 Service 关联、lookup、Context 解析、调用和响应编码不区分 descriptor 来自 SRC 弱解析还是 LIB 严格生成。
|
||||
|
||||
CI 和发布运行 LIB。全仓 coverage 全部切换到 LIB 是独立后续工作,不阻塞本次直接方法调用实现。
|
||||
|
||||
## Host Gateway 解析
|
||||
|
||||
Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 endpoint 注册表。ownership matcher 会先检查当前 TypeRT local 注册表,再查询一份可失效的集合;该集合通过扫描当前 Cordis Service 中的 `typertGateway` binding 与 SRC Remote 标记生成。Cordis Service 发生变化时会整体丢弃该集合,因此 TypeRT definition 与业务 Service 可以按任意顺序到达,同时既不会让旧 API Proxy 的 `/api` 流量在每次请求时重新扫描所有 Service,也不会因任意请求路径而扩大缓存。
|
||||
|
||||
每次调用都会重新从当前状态解析 descriptor、receiver、lookup 提供方与 Context 提供方。当前 strict descriptor 优先于 SRC。strict endpoint 一旦出现,即使随后撤回对应 descriptor,`TypeRTLocalRegistry.hasSeen()` 仍会在注册表剩余生命周期内保持对它的认领并禁止回退 SRC;重新注册 strict descriptor 即可恢复调用。移除 Service 或提供方会让调用明确失败;Gateway 既不保留失效对象,也不会以原始 lookup ID 调用方法。
|
||||
|
||||
普通 `@Remote` 调用保留原始 Service 实例作为 receiver。lookup 成功后,Gateway 按 descriptor 的参数顺序调用 `implementation ?? method` 指定的成员;若 descriptor 声明取消,则在这些参数之后追加 carrier signal。
|
||||
|
||||
`@RemoteScope('agent')` 调用先由 Agent Context provider 解析 wire identity,再从该 Context 读取 descriptor 的 service key 并调用 scoped receiver。业务方法不会收到隐藏 Context 参数或 Agent ID。
|
||||
|
||||
```text
|
||||
ctx.typertGateway.invoke({ namespace, method, args, signal })
|
||||
→ 查找本地 InvocationDescriptor 与 live receiver
|
||||
→ 按参数 descriptor 读取具名 wire 字段
|
||||
→ codec 解码普通值或 lookup ID
|
||||
→ lookup provider 把 ID 解析为活对象
|
||||
→ direct 使用原 Service;context 先解析 scoped Context 和 Service
|
||||
→ cancellation descriptor 存在时把 signal 追加到业务参数末尾
|
||||
→ Reflect.apply(receiver[implementation ?? method], receiver, orderedArgs)
|
||||
→ result codec 编码业务结果
|
||||
```
|
||||
|
||||
`ctx.typertGateway.invoke()` 是 carrier-independent 的 Host 入口。它不创建 rpcId、RPC envelope 或 HTTP response;它只返回编码结果,或产生由 Connection RPC adapter 映射的 Gateway 错误。
|
||||
|
||||
## 共享 `/api` 调用链
|
||||
|
||||
Connection 在 HTTP Server 上持有唯一 `/api` route。Gateway 把同步 endpoint ownership 判断和 Remote RPC handler 挂到 Connection:
|
||||
|
||||
```text
|
||||
ctx.connection.rpc.intercept(
|
||||
'/api',
|
||||
endpoint => ownsRemoteEndpoint(endpoint),
|
||||
(endpoint, payload, signal) => {
|
||||
const { namespace, method } = parseEndpoint(endpoint)
|
||||
const { args } = parsePayload(payload)
|
||||
return ctx.typertGateway.invoke({ namespace, method, args, signal })
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Host registry 中存在 strict descriptor、记录过已撤回的 strict descriptor,或 active SRC Service binding 上存在匹配的 `@Remote` 标记时,Gateway 认领该 endpoint。endpoint 一旦被认领,即使 payload 解码、descriptor 解析或调用失败也继续由 Gateway 返回错误;只有不属于 Remote 的 endpoint 才进入旧 API Proxy 回退。
|
||||
|
||||
Connection Host half 把一个复合 FetchHandler 交给 HTTP bridge。bridge 创建标准 `Request` 后,该 handler 再选择 Gateway RPC FetchHandler 或 API Proxy FetchHandler;两条路径复用同一 request/response envelope、rpcId、序列化、trust、transport error 和 `RpcError`。当前物理映射是:
|
||||
|
||||
```text
|
||||
POST /api/<namespace>/<method>
|
||||
```
|
||||
|
||||
Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 `InvocationDescriptor`。普通 Goal 调用的 payload slot 是:
|
||||
|
||||
```json
|
||||
{
|
||||
"args": {
|
||||
"agentId": "session-1",
|
||||
"request": {
|
||||
"objective": "finish the migration"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
完整链路为:
|
||||
|
||||
```text
|
||||
ctx.remote.goals.create(sessionId, request, signal?)
|
||||
→ Client InvocationDescriptor 编码 { args: { agentId, request } }
|
||||
→ Client 合并 caller signal 与 contribution mount lifetime
|
||||
→ ctx.connection.rpc.call('/api', 'goals/create', { args }, signal)
|
||||
→ Connection 创建 rpcId 和既有 client-request envelope
|
||||
→ 当前 carrier 发送 POST /api/goals/create
|
||||
→ Connection Host half 执行共享 trust,再由 bridge 创建标准 Request
|
||||
→ 复合 FetchHandler 判断 endpoint ownership 并选择目标 FetchHandler
|
||||
→ TypeRT interceptor 调用 ctx.typertGateway.invoke(..., request.signal)
|
||||
→ Host InvocationDescriptor 解码、lookup、receiver 解析并把 signal 注入 Reflect.apply
|
||||
→ result codec 编码
|
||||
→ Connection 写入既有 RPC result 并回送相同 rpcId
|
||||
→ Client result codec 验证并返回 CreateGoalResult
|
||||
```
|
||||
|
||||
Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`。adapter 把普通 Gateway 与业务调用失败转换为既有 `RpcError` envelope,并统一使用 `code: 'internal'`;resolver 通过 `TypeRTLookupFailure` 携带的既有 RPC error 则原样返回,使冷恢复失败和 ownership fence 保持稳定错误码。Gateway 的结构化错误分类仅在进程内保留,诊断信息则通过 message 跨 Connection 传递。
|
||||
|
||||
Gateway 不处理逐方法权限、调用者身份、幂等或长连接状态。它只把 Connection 的协作式取消传播给显式支持取消的业务方法。TypeRT endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。
|
||||
|
||||
## Connection 与协议边界
|
||||
|
||||
Client Remote Service 负责 Remote contribution、namespace Service 实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、endpoint ownership、lookup、Context 和业务调用。Connection 把 `/api`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client Remote 类型。
|
||||
|
||||
Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 HTTP route。Connection 把共享 `/api` route 挂到 HTTP Server,并把一个复合 FetchHandler 交给 bridge;该 handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。未来 Connection transport 可以保留相同顺序,而不改变 Remote payload、业务 decorator、生成的 DTS、Remote API 类型或 Agent Scope 编程界面。
|
||||
|
||||
## 包边界
|
||||
|
||||
- `@deepseek-ai/dsh-type-meta`:轻量 decorator、binding、lookup、Remote Scope 和 descriptor 协议。
|
||||
- TypeRT generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。
|
||||
- TypeRT runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。
|
||||
- `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 Remote namespace Service 和方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。
|
||||
- `@deepseek-ai/dsh-api-remotes`:BFF 层;负责 Host Agent/Session resolver,选择 Client `/remote` contribution,并通过共享的 `TypeRTClientRemote` 契约向业务包暴露合并后的 Remote 类型。
|
||||
- Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与复合 FetchHandler、API Proxy 回退、RPC envelope、rpcId、序列化、trust 和错误传输。
|
||||
- Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。
|
||||
- API Proxy Host 组合:向 API Remotes 提供 Web Agent 默认值和 scope 设置,并让旧方法使用同一个 `agentFor()`。
|
||||
- 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。
|
||||
|
||||
## 已交付范围与后续工作
|
||||
|
||||
已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.remote.goals.create(agentId, request)` 与 `agentCtx.remote.goals.create(request)`。普通冷会话在 lookup 时通过 `agentFor()` 恢复,subagent-owned identity 保持既有 `agent-busy` fence;`@RemoteScope('agent')` 仍是独立的 scoped receiver 模式。
|
||||
|
||||
Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。
|
||||
|
||||
包拓扑为 `api/remotes → api/gateway → client/connection → host/webserver`。Connection 与 WebServer 在本次变更中保留既有路径;后续将它们移到 `api/connection` 和 `api/webserver` 只会改变包位置,不会改变这些服务边界。旧 API Proxy 同样保留在 `host/apiproxy` 下,作为尚未迁移到 Remote 的方法的回退路径。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**继续使用中央 API Proxy 包。** 该方案要求业务方法、Host 路由和 Client 接口在多个位置重复声明,也会继续把直接调用、带状态交互和事件流绑在同一生命周期中,因此不采用。
|
||||
|
||||
**让 decorator 在运行时完成严格反射。** JavaScript decorator 无法恢复擦除后的 TypeScript 类型、公共符号身份和完整 Zod codec;向 constructor 注入 compiler 私有 symbol 又会隐藏业务类的真实依赖,因此严格信息由 TypeRT compiler 生成。
|
||||
|
||||
**SRC 启动时使用 preload、loader hook 或完整 `ts.Program`。** 这能复用 LIB 分析,但增加所有源码启动入口的要求。SRC 只需要可用的弱 descriptor,因此采用 decorator 标记、函数参数名和显式 provider;严格检查留给 LIB contract pass。
|
||||
|
||||
**手写 Client interface。** 手写接口不能保证只包含 Remote 标记的方法,也会与 Host 签名、lookup ID 和 Zod schema 漂移,因此 Client 类型从 Host Program 自动投影。
|
||||
|
||||
**使用 TypeScript language-service/compiler plugin 让 Client 直接理解 decorator。** 这会让编辑器、Vite、tsc、tsx 和发布消费者都依赖额外插件,接入面过大,因此生成普通 `.d.ts` 和标准 declaration map。
|
||||
|
||||
**把完整 Host DTS 导入 Client 或 TUI。** 该方案会带入 Host Service 和 Cordis interface merge,并向消费端暴露未标记方法。Remote DTS 只引用纯类型公共符号并扩展专用 Remote maps。
|
||||
|
||||
**只生成 Remote DTS,不生成 JS。** 类型可以成立,但运行时无法枚举 endpoint、codec 和 Context 模式,只能依赖 Proxy 或另一份手写注册表,因此同一次 Host 投影同时生成 Remote JS contribution。
|
||||
|
||||
**让 `/remote` 的顶层 import 偷偷注册全局状态。** ESM 求值时未必已有目标 Cordis Context,多个 Context、HMR 和 dispose 也无法明确归属,因此普通 value import 只返回 contribution,由环境 assembly 的 Client Remote Service 显式挂载。
|
||||
|
||||
**为 Remote 新建独立 transport、HTTP route 或 `/api2` channel。** 这会复制或拆分 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期。共享 `/api` interceptor 保留唯一物理 route,并让 Connection 继续以 API Proxy 作为回退 FetchHandler。
|
||||
|
||||
## 验证
|
||||
|
||||
- Goal Service 直接装饰业务签名已经符合 Remote 契约的变更类方法,仅保留 `remoteExportCreate(...)` 把 `GoalView` 适配为 `CreateGoalResult`,无需第二条路由、第二份 codec 或 Client 方法清单。
|
||||
- 一次干净的 `build:lib` 会在 Client 编译前生成 Host 与消费方 Remote 产物,包括业务包 `/remote` 下的 JS、DTS 和 declaration map。
|
||||
- `clean` 后,单独运行 `typecheck`、`lint` 或 `doc-typecheck` 都会重新生成 Remote 契约;pre-push 钩子使用同一个已包含契约准备步骤的 typecheck,CI 中的源码消费方则等待一次共享的契约 pass。
|
||||
- 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `ctx.remote.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。
|
||||
- 挂载同一次 import 得到的 JS contribution 会提供 endpoint、参数、结果、lookup、Context 和 Zod 反射,并在无需手写 stub 的情况下实体化调用。
|
||||
- Root 与 Agent-scoped 调用会经过真实的共享 `/api` carrier,将 `agentId` 解析为活 Agent,调用原始 Goal receiver,并通过既有 RPC envelope 返回。
|
||||
- Agent 与 Session lookup 会共享同一次并发冷恢复;普通冷会话得到恢复后的对象,冷态或 live subagent identity 均在业务调用前返回 `agent-busy`。
|
||||
- Remote 产物与 map 仅包含已标记的方法,不依赖 Browser,从而为未来 TUI 保留相同的消费方边界。
|
||||
- 生命周期测试会撤回并重新挂载 descriptor、Service、lookup、Context 提供方和 Client namespace;依赖不可用时,调用会失败,且不会使用陈旧调用或回退原始 ID。
|
||||
- 取消测试覆盖严格生成、SRC 末位参数名识别、Client signal 合并、Connection 到 Gateway 的传播,以及 Host 在 wire `args` 之外的注入。
|
||||
- 未认领 endpoint 继续使用既有 API Proxy 路径,其 trust、privileged-method、Permission/Approval 与 Session 事件流行为保持不变。
|
||||
|
||||
## 后果
|
||||
|
||||
Remote API 类型依赖生成的 `lib` 声明,构建与门禁编排必须在对 Host 和 Client 消费方进行编译或语义分析之前完成 Host 契约 pass;顺序错误会使干净环境中的命令依赖陈旧产物。
|
||||
|
||||
源码导航依赖 Remote package 同时发布 declaration map 和 map 指向的 `src`。package `files` 漏掉任一侧时类型仍可编译,但消费端跳转会停在生成 DTS,因此 workspace manifest 校验必须把两者作为同一发布契约。
|
||||
|
||||
SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化后,Web 和严格类型消费方必须重新执行 lib build,因为系统没有增量 contract watcher。
|
||||
|
||||
公共类型唯一性要求业务 DTO 具有纯类型出口,可能暴露现有包中 Host 类型与实现入口混杂的问题。构建会拒绝这些边界,而不是复制类型掩盖问题。
|
||||
|
||||
类型 import 与运行时 contribution 是两种不同效果。`import type {}` 只扩展静态 Remote surface;真实调用环境遗漏 value contribution 时,Client Remote Service 必须以明确的“Remote 未挂载”错误失败。
|
||||
|
||||
Browser 与 Host 各自持有 Zod 实例,不能依赖对象 identity 跨 realm 比较;一致性只由规范 symbol key、同一生成模型和 wire 行为保证。
|
||||
|
||||
消费端可以导入 Host 当前未挂载的 Remote contract。类型表示“该协议能力已被消费端选择”,不保证目标进程当前存在对应 Service;运行时 endpoint 不可用必须明确失败。
|
||||
|
||||
Connection 的通用 channel API 必须同时适合当前 HTTP carrier 和后续 WebSocket carrier。若 Client Remote 或 Gateway 暴露 `fetch`、HTTP request 或 route handle,WebSocket 迁移会再次穿透 Remote 层,因此这些物理对象必须留在 Connection 内部。
|
||||
|
||||
Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接受 loopback;LAN 调用方必须通过显式 trusted-host 配置接入,但本层不增加逐方法调用方授权,因此每个 trusted host 都能调用已挂载的 Remote endpoint。
|
||||
|
||||
`hasSeen()` 优先保障 strict definition 的安全性,而非 SRC 可用性。strict descriptor 撤回时(例如 HMR 期间),Gateway 会继续认领 endpoint 并报告不可用,而不会回退到弱 SRC descriptor。重新注册即可恢复;只有重启 TypeRT 注册表才会忘记历史 strict definition。
|
||||
|
||||
支持取消的 Remote 签名会接收 Connection 请求的 `AbortSignal`,因此 HTTP 断连或 Client 侧 abort 能在不进入 JSON 协议的情况下传递到正在进行的业务工作。取消仍是协作式的:没有保留末位参数的方法会继续运行;收到 signal 的方法必须将它传给自身支持取消的操作,或自行观测它。
|
||||
|
||||
lookup 配置当前以 key 为粒度,因此每个 `agent` 或 `session` 参数都采用同一套冷恢复策略。需要 live-only 语义的特定 Remote 必须等待显式的逐参数或逐 endpoint 策略,不能靠业务实现猜测对象是否刚被恢复。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md
|
||||
2026-08-03-pi-ai-declared-provider-catalog.md: d75b6bdb91d60026636bf320f8c6625590849a41
|
||||
2026-08-03-pi-ai-declared-provider-catalog.zh.md: f8dba9900b1a7a3abcb16c70a35cc18f0c44219f
|
||||
2026-08-03-pi-ai-declared-provider-catalog.md: f908eb6293b77680193fcd8f7be7a9089477855a
|
||||
2026-08-03-pi-ai-declared-provider-catalog.zh.md: ce91abd6dc71f790c72766cd3f819096d596182c
|
||||
+3
-3
@@ -14,7 +14,7 @@ The adapter also streamed through `streamSimple` from `@earendil-works/pi-ai/com
|
||||
|
||||
A provider route is a **declaration**, and the installed catalog is its default. `resolveProfiles` no longer checks route keys against `getBuiltinProviders()`. Instead each route resolves to a materialized model list plus the pi-ai `Provider` that serves it:
|
||||
|
||||
- `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, `maxTokens`. Pricing and input modalities are absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Reasoning is absent for a different reason: a bare capability flag would make pi-ai advertise effort levels with no `thinkingLevelMap` to spell them, so it rides the installed entry or is absent. Materialization spreads the installed entry and overrides those four fields, rather than enumerating the result: an enumerated rebuild silently drops every `Model` field this package does not model, which is how `headers` went missing from an nvidia route once already.
|
||||
- `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — at this note's writing `id`, `name`, `contextWindow`, `maxTokens`; [[2026-08-08-pi-ai-per-model-reasoning-declarations]] later added `reasoningEfforts` and `compat`, which is also where the original "reasoning rides the installed entry or is absent" stance was revisited (a bare capability flag stays rejected; a full per-level declaration with wire spellings does not have its problem). Pricing and input modalities remain absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Materialization spreads the installed entry and overrides the configured fields, rather than enumerating the result: an enumerated rebuild silently drops every `Model` field this package does not model, which is how `headers` went missing from an nvidia route once already.
|
||||
- `provider.ts` builds the route's `Provider`. A catalog route that keeps its catalog protocol **reuses** the installed provider with `getModels()` replaced; every other route is built by `createProvider()` over a protocol table whose entries are the same `@earendil-works/pi-ai/api/*.lazy` factories pi-ai's own provider factories use. That table is narrower than pi-ai's full API set on purpose — it holds only protocols a profile can completely describe with a key, an endpoint, and headers, so Bedrock (SigV4 plus a region), Vertex (project, location, ADC), Azure (provider environment plus an api-version), and Codex (OAuth) are absent rather than offered as routes that cannot authenticate. Catalog routes still reach them through their own provider; only an explicit override is refused.
|
||||
- `adapter.ts` turns each resolution into an **immutable snapshot** — the profiles plus a `createModels()` collection holding those providers — and every operation captures a whole snapshot before its first `await`.
|
||||
- A model's **explicitly configured** `maxTokens` becomes the seam's `defaultMaxTokens`. The value inherited from the installed catalog does not: pi-ai requires `Model.maxTokens` as the model's output *capability*, while `defaultMaxTokens` is a cap the deployment chose to send on requests that name none, and materializing the former as the latter would start capping every request at a number nobody picked.
|
||||
@@ -35,7 +35,7 @@ The configurable-provider directory is now the installed catalog **joined with**
|
||||
|
||||
pi-ai reports a model with no reasoning metadata as supporting the single level `off`, and the adapter used to pass that straight through. It reaches the seam as a one-item effort list, which every surface renders as a picker holding one selectable control — and that control is a lie: `off` becomes an *omitted* reasoning option at dispatch, byte-for-byte the request that naming no effort already produces. A provider whose own default is to think keeps thinking while the surface shows `off` selected.
|
||||
|
||||
`reasoningInfo` therefore omits the seam's `reasoning` field whenever `model.reasoning` is falsy. The condition is the model's own metadata, not where the model came from, so this covers every hand-declared model **and** the 251 installed-catalog models pi-ai marks as non-reasoning. Those previously offered the lone `off`; they now offer nothing, and the surface shows the provider default alone. Models that do carry reasoning metadata are untouched — their level list still crosses the seam unfiltered, `off` included, because there it selects between real alternatives.
|
||||
`reasoningInfo` therefore omits the seam's `reasoning` field whenever `model.reasoning` is falsy. The condition is the model's own metadata, not where the model came from, so this covers every hand-declared model whose entry declares no `reasoningEfforts` ([[2026-08-08-pi-ai-per-model-reasoning-declarations]] made declared efforts carry that metadata) **and** the 251 installed-catalog models pi-ai marks as non-reasoning. Those previously offered the lone `off`; they now offer nothing, and the surface shows the provider default alone. Models that do carry reasoning metadata are untouched — their level list still crosses the seam unfiltered, `off` included, because there it selects between real alternatives.
|
||||
|
||||
### Credentials stay outside pi-ai
|
||||
|
||||
@@ -50,7 +50,7 @@ A route's auth follows from that. A catalog route keeps the installed provider's
|
||||
- **Keep `createProvider()` but skip the `Models` collection**, streaming through `provider.streamSimple(model, ctx, {apiKey})`. Smallest diff and the credential path is untouched, but `createProvider`'s `auth` is a required field that this path never invokes — a required-by-signature implementation with no caller. It also leaves `refreshModels` needing a hand-built `RefreshModelsContext`, and keeps the adapter off the runtime pi-ai actually supports.
|
||||
- **Reuse the installed provider for catalog routes and `createProvider()` only for declared ones**, with no shared resolution. Zero risk to catalog behavior, but catalog materialization, endpoint override, and per-model configuration would each exist twice, and a catalog route that repoints its protocol would have to jump paths mid-resolution. The chosen split confines the asymmetry to provider construction, where it is forced by pi-ai not exposing a built provider's API implementations.
|
||||
- **Rebuild every route through `createProvider()`**, including catalog ones. Fully symmetric, but a built `Provider` does not expose its `api`, so the protocol table would become the ceiling on which providers work — Bedrock loads its Smithy module through a separate entry point and would silently stop working.
|
||||
- **Expose pi-ai's whole `Model` shape** (cost, input modalities, `thinkingLevelMap`, `compat`). Maximum configurability, but no current consumer reads those fields, so a configured price or modality would change nothing while reading as supported.
|
||||
- **Expose pi-ai's whole `Model` shape** (cost, input modalities, `thinkingLevelMap`, `compat`). Maximum configurability, but no current consumer read those fields then, so a configured price or modality would change nothing while reading as supported. The consumer-driven half of this arrived later: [[2026-08-08-pi-ai-per-model-reasoning-declarations]] opened reasoning (as `reasoningEfforts`, not a raw `thinkingLevelMap`) and the two reasoning-dispatch `compat` switches once selectors and dispatch actually consumed them; cost and modalities stay closed for the original reason.
|
||||
|
||||
- **Keep one mutable `Models` collection and re-sync it.** Fewer allocations, and correct for every operation that resolves synchronously. It is exactly wrong for the one that does not: `stream()` awaits a credential between capturing its model and dispatching it.
|
||||
- **Simulate an atomic directory swap with dispose-then-register.** No seam change, and it works whenever the new set is valid — which is the case that never needed atomicity.
|
||||
|
||||
+3
-3
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
提供方路由是一份**声明**,已安装 catalog 是它的默认值。`resolveProfiles` 不再拿路由键去核对 `getBuiltinProviders()`,而是把每条路由解析成一份物化模型列表,外加服务它的 pi-ai `Provider`:
|
||||
|
||||
- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow`、`maxTokens`。定价与输入模态不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。推理缺席则是另一个理由:一个孤立的能力布尔量会让 pi-ai 公布出没有 `thinkingLevelMap` 可供拼写的档位,因此它沿用已安装条目或直接缺席。物化时以已安装条目铺底、再覆盖那四个字段,而不是逐字段枚举结果:枚举式重建会静默丢弃本包未建模的每一个 `Model` 字段——`headers` 就是这样从某条 nvidia 路由上消失过一次。
|
||||
- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——本 note 写就时为 `id`、`name`、`contextWindow`、`maxTokens`;[[2026-08-08-pi-ai-per-model-reasoning-declarations]] 之后加入了 `reasoningEfforts` 与 `compat`,当初「推理沿用已安装条目或直接缺席」的立场也在那里被重新审视(孤立的能力布尔量仍被拒绝;带 wire 拼写的逐档位完整声明没有它那个问题)。定价与输入模态仍不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。物化时以已安装条目铺底、再覆盖已配置的字段,而不是逐字段枚举结果:枚举式重建会静默丢弃本包未建模的每一个 `Model` 字段——`headers` 就是这样从某条 nvidia 路由上消失过一次。
|
||||
- `provider.ts` 构造路由的 `Provider`。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换 `getModels()`;其余路由都由 `createProvider()` 基于一张协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的 `@earendil-works/pi-ai/api/*.lazy` factory。该表刻意窄于 pi-ai 的完整 API 集合——只保留 profile 能用密钥、端点与标头完整描述的协议,因此 Bedrock(SigV4 加 region)、Vertex(project、location、ADC)、Azure(提供方环境加 api-version)与 Codex(OAuth)不在其中,而不是被当作无法认证的路由提供出去。catalog 路由仍可经自己的 provider 抵达它们;被拒的只有显式覆盖。
|
||||
- `adapter.ts` 把每次解析变成一份**不可变快照**——profiles 加上持有这些 provider 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份。
|
||||
- 模型**显式配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`;从已安装 catalog 继承来的那份不会:pi-ai 要求 `Model.maxTokens` 表示模型的输出**能力**,而 `defaultMaxTokens` 是部署选定、发给未点名上限的请求的那个值,把前者物化成后者会让每个请求都被一个无人选择的数字封顶。
|
||||
@@ -35,7 +35,7 @@ Status: implemented
|
||||
|
||||
pi-ai 把没有推理元数据的模型报告为只支持 `off` 一档,而适配器此前原样透传。它抵达 seam 时是一个单元素的 effort 列表,任何界面都会把它渲染成一个只有一项可选控件的选择器——而这个控件在撒谎:`off` 在派发时变成被*省略*的 reasoning 选项,与「不点名任何档位」产出的请求逐字节相同。自身默认就在思考的提供方会继续思考,界面却显示 `off` 已选中。
|
||||
|
||||
因此只要 `model.reasoning` 为假,`reasoningInfo` 就省略 seam 的 `reasoning` 字段。判据是模型自身的元数据,而非模型的来源,所以它覆盖每一个手工声明的模型**以及** pi-ai 标记为不具备推理能力的那 251 个已安装 catalog 模型。它们此前提供那个孤零零的 `off`,现在什么也不提供,界面只剩提供方默认。携带推理元数据的模型不受影响——其档位列表仍不经筛选地穿过 seam、`off` 也在内,因为在那里它是在真实备选之间做选择。
|
||||
因此只要 `model.reasoning` 为假,`reasoningInfo` 就省略 seam 的 `reasoning` 字段。判据是模型自身的元数据,而非模型的来源,所以它覆盖条目未声明 `reasoningEfforts` 的每一个手工声明模型([[2026-08-08-pi-ai-per-model-reasoning-declarations]] 让声明的档位携带这份元数据)**以及** pi-ai 标记为不具备推理能力的那 251 个已安装 catalog 模型。它们此前提供那个孤零零的 `off`,现在什么也不提供,界面只剩提供方默认。携带推理元数据的模型不受影响——其档位列表仍不经筛选地穿过 seam、`off` 也在内,因为在那里它是在真实备选之间做选择。
|
||||
|
||||
### 凭据留在 pi-ai 之外
|
||||
|
||||
@@ -50,7 +50,7 @@ pi-ai 的 `Models` 自带一套凭据概念——按提供方 id 索引的 `Cred
|
||||
- **保留 `createProvider()` 但不建 `Models` 集合**,改由 `provider.streamSimple(model, ctx, {apiKey})` 发起。改动最小且凭据路径原封不动,但 `createProvider` 的 `auth` 是必填字段,这条路上它永远不会被调用——一份因签名而必填、却没有调用方的实现。它还让 `refreshModels` 需要手工构造 `RefreshModelsContext`,并使适配器始终不在 pi-ai 真正支持的运行时上。
|
||||
- **catalog 路由复用已安装提供方,只有声明式路由走 `createProvider()`**,且两者不共享解析。对 catalog 行为零风险,但 catalog 物化、端点覆盖与每模型配置这三件事都要各写两遍,而改指协议的 catalog 路由还得在解析中途跳到另一条路径。已采纳的拆法把不对称收敛在提供方构造这一处——那里的不对称是 pi-ai 不暴露已构造提供方的 API 实现所强加的。
|
||||
- **让每条路由都经 `createProvider()` 重建**,包括 catalog 路由。完全对称,但已构造的 `Provider` 不暴露自己的 `api`,于是协议表会成为「哪些提供方能用」的天花板——Bedrock 经独立入口加载其 Smithy 模块,会因此静默失效。
|
||||
- **完整暴露 pi-ai 的 `Model` 形状**(成本、输入模态、`thinkingLevelMap`、`compat`)。可配置性最大,但这些字段当前没有任何读取方,因此配了价格或模态什么也不会改变,却看起来像是受支持的。
|
||||
- **完整暴露 pi-ai 的 `Model` 形状**(成本、输入模态、`thinkingLevelMap`、`compat`)。可配置性最大,但这些字段当时没有任何读取方,因此配了价格或模态什么也不会改变,却看起来像是受支持的。这条否决里由消费方驱动的那一半后来兑现了:[[2026-08-08-pi-ai-per-model-reasoning-declarations]] 在选择器与分派真正消费之后开放了推理(以 `reasoningEfforts` 的形态,而非裸 `thinkingLevelMap`)和两个推理分派 `compat` 开关;成本与模态仍因原有理由保持关闭。
|
||||
|
||||
- **保留单个可变 `Models` 集合并重新同步。** 分配更少,且对每个同步完成解析的操作都是正确的;唯独对那个不同步的操作恰恰是错的:`stream()` 会在捕获模型与派发模型之间 await 一次凭据。
|
||||
- **用「先 dispose 再注册」模拟目录原子替换。** 无需改 seam,且在新集合有效时确实可用——而那正是从不需要原子性的那种情形。
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md
|
||||
2026-08-04-configuration-source-ownership.md: ef30a22c120af1437f348e52843e1dd45c9837ae
|
||||
2026-08-04-configuration-source-ownership.zh.md: c1567c6e823a0bc8ed8d1f9ed50a11a5d204ede8
|
||||
@@ -0,0 +1,73 @@
|
||||
# Agent Note: One ordering for configuration sources, and what a discovered file may not decide
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-04-configuration-source-ownership.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`$DSH_HOME/.env` had just [become an ordinary environment layer](2026-08-04-credentials-yaml-and-user-environment-layer.md), which left the harness resolving user-facing values from a flattened `process.env` that could no longer say where a value came from. Three consequences followed.
|
||||
|
||||
A key stored through the web page stayed shadowed by an older key in the user's own `.env`, because the credential provider compared "the environment" against its file and the environment now included that file. The migration dead end the split was supposed to remove had simply moved.
|
||||
|
||||
An endpoint could be redirected by the project. The invoking directory's `.env` is materialized like every other layer, and a base URL decides where a resolved API key is sent — so a `DEEPSEEK_BASE_URL` written into a workspace the model can edit would send the user's own credential, and the prompts carrying their code, to whatever host that file named. Nothing about the flattened view could distinguish that from the operator exporting the same variable.
|
||||
|
||||
And `!!js process.env.X` in the shipped composition made the same value reachable twice: once through the entry config and once through whatever ladder its consumer applied, with the winner decided by layer order rather than by what the value means.
|
||||
|
||||
## Decision
|
||||
|
||||
**One ordering for non-secret values.** Every configurable value that is not itself a credential resolves in the same order; the domains differ only in which tiers exist.
|
||||
|
||||
```text
|
||||
explicit for this run per-operation override, CLI argument
|
||||
> user settings settings.yaml
|
||||
> composition profile bundles, user patch layers, --patch overlays
|
||||
> this launch's shell inherited process environment
|
||||
> discovered file <invocation cwd>/.env, then $DSH_HOME/.env
|
||||
> defaults schema default, provider public default
|
||||
```
|
||||
|
||||
Settings sit above composition because that is what the [settings seam](2026-07-28-user-settings-seam.md) does: a plugin registers its cordis entry config as the `base` layer and the user's section layers over it, and the seam cannot tell a value a profile's bundles set from one its user patch layer or a `--patch` overlay set — all arrive as entry config. The product CLI has no lever above stored settings, so a deployment that must pin a field against a user's settings ships its own bin or loader tree, or mounts no settings provider at all. Composition still outranks the environment, so a stale `DEEPSEEK_BASE_URL` in a shell cannot rewrite a configured endpoint.
|
||||
|
||||
**Credentials keep a narrower, separate ordering**, and this note does not unify them:
|
||||
|
||||
```text
|
||||
inherited process environment (read-only, wins)
|
||||
> $DSH_HOME/.credentials.yaml (provider-managed, writable)
|
||||
> <invocation cwd>/.env
|
||||
> $DSH_HOME/.env
|
||||
```
|
||||
|
||||
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. `EnvironmentSnapshot.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.
|
||||
|
||||
**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`), 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.
|
||||
|
||||
The line is that these take effect with no user action, before any turn, outside the permission policy and the sandbox. `DSH_PERMISSION_MODE` would switch off the approvals that make trusting a project meaningful at all, and `BASH_ENV` runs a file of the project's choosing on every single `bash -c` the bash tool issues — the project's code running under the agent's policy is the deal; the project rewriting that policy is not. Enumerating these is a losing game one variable at a time, which is why the whole `DSH_*` namespace is denied rather than an audited subset, and why the list is organised by what a variable *does* rather than by which runtime owns it. There is no opt-out: an escape hatch would have to be readable from somewhere, and anything a discovered file could set is the hole itself.
|
||||
|
||||
**`packages/util/environment` owns the snapshot**, deliberately as a utility rather than a three-package capability seam. The snapshot is frozen before Cordis starts and injected once by the launcher, so there is no runtime implementation to swap; consumers need types and pure functions, which a `util/` package gives them without depending on a UI package. `environmentOf(ctx)` returns the launcher's snapshot, or the inherited environment as the only layer — an SDK host or bare `cordis.yml` discovered no files, so its single layer really is what it was launched with, and the same trusted lookups keep working there unchanged.
|
||||
|
||||
**`verify-config-source-ownership`** is a narrow tripwire for the ordinary single-line form of an `apiKey`/`baseURL`/`headers` environment inline in shipped Cordis configuration. Removing those inlines is what makes the deployment tier meaningful — with the shipped tree silent on `baseURL`, a present value means a human or deployment set it. Adapters own actual resolution; the gate makes no repository-wide claim about `process.env` access.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The web credential form now takes effect against an older key in the user's `.env`; only a key exported in the launching shell still makes it read-only, and the diagnostic says so.
|
||||
- A `.env` holding `DSH_*`, `PATH`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break.
|
||||
- Composition is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree.
|
||||
- Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all; the environment package records the remaining subprocess reach as a limitation.
|
||||
- Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request seam resolution is separate work.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Unify credentials into the non-secret ordering, by who authored each source.** Attempted and abandoned: it reads well, but the settings seam already fixes composition *below* the user section, so "authored by deployment" is not a tier the seam can express — and moving `.credentials.yaml` above the launching environment would take away the one override CI, containers, and a per-run `DEEPSEEK_API_KEY=…` depend on. Two orderings that each say why they are shaped that way beat one that describes neither accurately.
|
||||
|
||||
**Withhold routing and credentials from the invoking project until it is explicitly trusted.** Rejected as the product's stance: a checkout is trusted by default, with no prompt and no stored trust record. The residual is real and worth naming — cloning a repository that carries a `.env` naming another endpoint or key routes that session through it — and a later project-trust gate is where that gets addressed, not a rule that makes the common case require ceremony.
|
||||
|
||||
**Audit an allowlist of `DSH_*` variables a `.env` may set.** Rejected: the list would have to be re-audited on every new switch, and the failure mode of forgetting is silent. Denying the namespace fails safe.
|
||||
|
||||
**Rank a bootstrap variable below the process layer instead of rejecting it.** Rejected: `PATH` and `NODE_OPTIONS` have no meaningful "loser" behavior — a user who put one in a `.env` believes it applies, and silently ignoring it is the "my setting has no effect" failure this whole series exists to remove.
|
||||
|
||||
**Build the snapshot as a three-package capability seam (`environment` / `environment-local` / consumers).** Rejected as premature: the producer runs before Cordis exists and there is no second implementation to select. The repository rule is to not split preemptively.
|
||||
|
||||
**Stop materializing the layers into `process.env`.** Deferred, not rejected: it would keep project variables out of child processes entirely, but it silently breaks any user patch layer that reads `!!js process.env.X`. The snapshot is already the authority for everything the harness resolves, so this can land later without changing any ladder.
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
# Agent Note: 配置来源的统一顺序,以及被发现的文件不得决定什么
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-04-configuration-source-ownership.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`$DSH_HOME/.env` 刚刚[变成普通环境层](2026-08-04-credentials-yaml-and-user-environment-layer.md),这使得 harness 解析面向用户的值时面对的是一个压平的 `process.env`,再也说不清某个值来自哪里。由此产生三个后果。
|
||||
|
||||
通过 Web 页面存下的密钥仍然被用户自己 `.env` 里更旧的密钥遮蔽,因为凭据 provider 是拿「环境」与自己的文件比较,而现在环境包含了那个文件。这次拆分本该消除的迁移死路,只是换了个位置。
|
||||
|
||||
endpoint 可以被项目重定向。调用目录的 `.env` 和其他层一样会被物化,而 base URL 决定已解析的 API key 发往何处——于是写进模型可编辑工作区的 `DEEPSEEK_BASE_URL`,会把用户自己的凭据、以及承载其代码的提示词,一起发给该文件指定的任何主机。压平的视图无法把这件事和运维显式 export 同一个变量区分开。
|
||||
|
||||
而已交付组合里的 `!!js process.env.X` 让同一个值有两条抵达路径:一条经 entry config,一条经消费方各自的 ladder,胜负取决于层序而非这个值的语义。
|
||||
|
||||
## Decision
|
||||
|
||||
**非密钥值走同一条顺序。** 每个本身不是凭据的可配置值都按同一顺序解析;各领域的差别只在于哪些层存在。
|
||||
|
||||
```text
|
||||
explicit for this run per-operation override, CLI argument
|
||||
> user settings settings.yaml
|
||||
> composition profile bundles, user patch layers, --patch overlays
|
||||
> this launch's shell inherited process environment
|
||||
> discovered file <invocation cwd>/.env, then $DSH_HOME/.env
|
||||
> defaults schema default, provider public default
|
||||
```
|
||||
|
||||
自上而下依次是:本次运行的显式意图、用户 settings、composition、本次启动的 shell、被发现的文件、默认值。
|
||||
|
||||
settings 在 composition 之上,因为 [settings seam](2026-07-28-user-settings-seam.md) 就是这么做的:插件把自己的 cordis entry config 注册为 `base` 层,用户 section 叠加其上,而 seam 无法区分某个值是 profile 的 bundle 设的,还是它的用户 patch 层或某个 `--patch` overlay 设的——它们都以 entry config 的形式抵达。产品 CLI(命令行界面)没有高于已存 settings 的手段,因此需要把某字段钉死、不被用户已存 settings 覆盖的部署方,应自带 bin 或 loader 配置树,或者干脆不挂载 settings provider。composition 仍然高于环境,所以 shell 里陈旧的 `DEEPSEEK_BASE_URL` 无法改写已配置的 endpoint。
|
||||
|
||||
**凭据保留一条更窄的独立顺序**,本 Note 不把它并入上表:
|
||||
|
||||
```text
|
||||
inherited process environment (read-only, wins)
|
||||
> $DSH_HOME/.credentials.yaml (provider-managed, writable)
|
||||
> <invocation cwd>/.env
|
||||
> $DSH_HOME/.env
|
||||
```
|
||||
|
||||
继承环境优先,因为 `DEEPSEEK_API_KEY=… dsh`、CI 机密与容器 `-e` 是运维必须能按次施加、且无需改动机器状态的那一种覆盖;而它无法从进程内部修改,就必须*可见地*只读。配置本应只携带*引用*——解析哪个名字——该名字本身遵循上面的非密钥顺序。
|
||||
|
||||
**harness 被启动于其中的项目默认可信,且不做询问。** 一个 checkout 可以携带自己的 endpoint、自己的普通变量和自己的密钥;密钥排在受管存储之下,因此通过 Models 页存下的密钥绝不会被 checkout 中恰好带有的那一个顶掉。`EnvironmentSnapshot.getFrom(name, sources)` 仍然只搜索调用方点名的层,省略某层仍是拒绝而不是降级——该机制是为「某一层必须不可达」的那些决策准备的,而项目层今天不在其列。
|
||||
|
||||
**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何抵达与信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。
|
||||
|
||||
这条界线在于:它们无需任何用户动作、在任何一轮开始之前、且在权限策略与沙箱之外就生效。`DSH_PERMISSION_MODE` 会关掉让「信任项目」根本成立的那道审批,而 `BASH_ENV` 会在 bash 工具发出的每一次 `bash -c` 上执行项目指定的文件——项目的代码在 agent 的策略下运行是约定,项目改写那份策略不是。一个变量一个变量地枚举是必输的游戏,所以整个 `DSH_*` 命名空间被拒绝而不是只拒绝一份经审查的子集,也所以这份清单是按变量*做什么*而不是按哪个运行时拥有它来组织的。不设逃生门:逃生门本身总得从某处读取,而任何被发现的文件能设置的东西,就是那个漏洞本身。
|
||||
|
||||
**`packages/util/environment` 拥有该快照**,刻意做成 utility 而不是三包能力 seam。快照在 Cordis 启动前就冻结,并由启动器一次性注入,因此不存在需要切换的运行时实现;消费方需要的只是类型和纯函数,而 `util/` 包能提供这些且不必依赖 UI 包。`environmentOf(ctx)` 返回启动器的快照,或者返回只含继承环境的那一层——SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,它那唯一一层确实就是它被启动时的环境,因此同样的受信查询在那里原样继续工作。
|
||||
|
||||
**`verify-config-source-ownership`** 仅作为一道窄门禁,检查已交付 Cordis 配置中从环境内联 `apiKey`/`baseURL`/`headers` 的普通单行写法。删除这些内联正是「部署层」得以成立的原因——已交付配置树对 `baseURL` 保持沉默之后,「有值」就意味着「人或部署设过它」。实际解析由适配器负责;该门禁不声称覆盖仓库范围内的 `process.env` 访问。
|
||||
|
||||
## Consequences
|
||||
|
||||
- Web 凭据表单现在能压过用户 `.env` 里更旧的密钥;只有在启动 shell 里 export 的密钥才会让它变成只读,诊断信息也会这么说。
|
||||
- 含 `DSH_*`、`PATH` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。
|
||||
- composition 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。
|
||||
- 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件;其余变量抵达子进程的限制记录在环境包中。
|
||||
- Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求经 seam 解析是另一件事。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**按「来源由谁书写」把凭据并入非密钥顺序。** 尝试过并放弃:它读起来很顺,但 settings seam 已经把 composition 固定在用户 section *之下*,因此「部署授权」根本不是该 seam 能表达的一层;而把 `.credentials.yaml` 抬到启动环境之上,会夺走 CI、容器和一次性 `DEEPSEEK_API_KEY=…` 所依赖的那唯一一种覆盖。两条各自说清自身形状成因的顺序,好过一条两边都描述不准的顺序。
|
||||
|
||||
**在项目被显式信任之前,不给它路由与凭据能力。** 作为产品立场被否决:checkout 默认可信,不询问,也不存储信任记录。残留风险是真实的、值得写明——克隆一个携带 `.env`、其中指定了另一个 endpoint 或密钥的仓库,会让该会话经由它——处理它的地方是日后的 project trust 门禁,而不是一条让常见情形都要走仪式的规则。
|
||||
|
||||
**审查出一份 `.env` 可设置的 `DSH_*` 白名单。** 否决:每新增一个开关都要重新审查,而遗漏的失败模式是静默的。拒绝整个命名空间是 fail safe。
|
||||
|
||||
**把 bootstrap 变量排在 process 层之下,而不是拒绝它。** 否决:`PATH` 和 `NODE_OPTIONS` 没有有意义的「输了之后」行为——把它写进 `.env` 的用户认为它生效,而静默忽略正是整个系列要消除的那种「我的设置没有效果」。
|
||||
|
||||
**把快照做成三包能力 seam(`environment` / `environment-local` / 消费方)。** 作为过早拆分而否决:生产方在 Cordis 存在之前就运行,也没有第二个实现需要选择。仓库规则是不要预先拆分。
|
||||
|
||||
**不再把各层物化进 `process.env`。** 延后而非否决:它能让项目变量彻底进不了子进程,但会静默破坏任何读 `!!js process.env.X` 的用户 patch 层。快照已经是 harness 解析一切的依据,因此这件事以后落地也不改变任何 ladder。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md
|
||||
2026-08-04-credentials-yaml-and-user-environment-layer.md: 4ecbc41adf4e22c74ecf425c2caf628efdf7cf54
|
||||
2026-08-04-credentials-yaml-and-user-environment-layer.zh.md: 370179b442783f4f8ecd8e3badbd236a924f5f81
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# Agent Note: Splitting the credential store from the user environment layer
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-04-credentials-yaml-and-user-environment-layer.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`$DSH_HOME/.env` carried two incompatible jobs. It was the writable secret store of [`credentials-local`](../../../../packages/credentials/credentials-local/README.md), so no surface could hoist it into `process.env` — hoisting would make every stored key read as a read-only launch override and block rotation from the Models page. But its name and dotenv format promise an environment file, so users put non-secrets in it and those values reached nothing: a `DEEPSEEK_BASE_URL` beside a working `DEEPSEEK_API_KEY` in the same file was silently ignored, because only the credential provider read the document and it addresses credential references alone.
|
||||
|
||||
One file cannot be both a store the Harness owns and isolates and a layer that propagates by ordinary environment rules. The [request-level credential decision](2026-07-29-request-level-llm-config-credentials.md) chose dotenv to match peer products' home `.env`, and the conflation was not visible until a non-secret needed the same file.
|
||||
|
||||
## Decision
|
||||
|
||||
The two jobs become two files under the Harness home.
|
||||
|
||||
**`.credentials.yaml` is the provider-managed store.** A strict YAML mapping of `CredentialRef` to non-empty string, with no `version` field and no wrapper level:
|
||||
|
||||
```yaml
|
||||
DEEPSEEK_API_KEY: sk-…
|
||||
OPENAI_API_KEY: sk-…
|
||||
```
|
||||
|
||||
Because the document holds credentials and nothing else, every deviation is a rejection rather than a skipped entry: a non-mapping root, a key that is not a POSIX identifier, a non-string value, an empty string, a duplicate key, and malformed YAML all fail — loud at boot and at a write, warn-and-keep-the-last-good-snapshot on a live reload. A silently ignored key would read as "the secret I stored has no effect", which is the failure this change exists to remove. The dotenv physical-line editor is replaced by a patch of the parsed document, so comments and untouched entries keep their formatting, any string value round-trips (multi-line included), and no entry is unwritable for want of a quoting style. The writer lock, read-modify-write, atomic `0600` write under a `0700` directory, exact-path watcher, content-equality self-write suppression, and quiescent disposal are unchanged.
|
||||
|
||||
**`$DSH_HOME/.env` is the user's ordinary environment layer.** `loadLayeredEnv` in [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) parses the invoking directory's `.env` and then the Harness home's, giving `user < project < inherited` by materializing each accepted value only when the process has no higher-layer value. The Harness home is resolved from the inherited environment *before* either file loads, so a project `.env` cannot redirect which user document is read. Only the product CLI layers these files; SDK and example bins keep loading their own directory through `loadEnv` and must not inherit a developer's `$DSH_HOME`.
|
||||
|
||||
Credential precedence distinguishes the inherited environment from discovered files: the inherited value stays the read-only per-run override, the managed document wins next, and project then user `.env` values remain writable fallbacks. A `set` therefore replaces a discovered-file value instead of rejecting a write that only the flattened `process.env` view would consider shadowed.
|
||||
|
||||
There is no migration. A key already in `$DSH_HOME/.env` keeps resolving as a fallback, while the managed document wins as soon as the Models page stores that reference.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Given up: a key left in `$DSH_HOME/.env` is materialized into `process.env`, so it reaches subprocesses under the [subprocess credential scrub](../../../../packages/subprocess/subprocess/README.md) rather than staying inside the provider. It remains a writable fallback below `.credentials.yaml`; a secret the Harness should own and isolate belongs in the managed document, which is never materialized.
|
||||
- Bought: a non-secret in the user's `.env` finally takes effect, which was the original defect; the document format can reject what it cannot serve; and `0600` covers a file that holds only secrets instead of a file users are told to put ordinary configuration in.
|
||||
- The `0600` the provider writes is also enforced on what it reads: on POSIX, a document with any group or other permission bit fails the launch before its contents are read, at boot and on every reload, and the diagnostic names the `chmod 600` repair. Windows has no mode to inspect — its ACLs are not expressible here — so the check is skipped rather than faked.
|
||||
- The `0600` boundary still stops other OS users and not the model, unchanged by this split — the [provider README](../../../../packages/credentials/credentials-local/README.md) owns that limit and the keychain-provider deferral.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep one `$DSH_HOME/.env` and teach the CLI to hoist it.** Rejected: hoisting the store is precisely what makes stored keys unrotatable, which is why [app-boot documented the exclusion](../../../../packages/ui/app-boot/README.md) in the first place. The conflict is the file's two jobs, not the loader.
|
||||
|
||||
**`$DSH_HOME/.credentials.env` — a second dotenv file.** Rejected: dotenv suits an environment layer but cannot express "a managed document indexed by credential reference". It cannot reject a non-string or an unaddressable key, and its line editor already refused values it could not quote, leaving entries readable but unwritable.
|
||||
|
||||
**Add a `version` field to the new document.** Rejected: the format is one schema-constrained string mapping with no historical variant to discriminate. While the product is unreleased, changing the structure and rejecting the old one beats promising a migration protocol.
|
||||
|
||||
**Migrate credential-shaped keys out of `$DSH_HOME/.env` on first run.** Rejected: migration code turns a short-lived format into a long-lived maintenance surface, and classifying which keys in an unknown file are secrets is exactly the ambiguity this split removes. The old file keeps working as environment, which is a truthful outcome rather than a silent one.
|
||||
|
||||
**Drop the user `.env` layer entirely and keep only the inherited environment.** Rejected here as out of scope: it is a coherent design (fewer layers, one place per value), but it removes a workflow users have, and the layering question belongs with the deferred precedence decision rather than with this split.
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# Agent Note: 把凭据存储与用户环境层拆开
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-04-credentials-yaml-and-user-environment-layer.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`$DSH_HOME/.env` 同时承担了两件互不相容的工作。它是 [`credentials-local`](../../../../packages/credentials/credentials-local/README.md) 的可写密钥存储,因此任何表层都不能把它提升进 `process.env`——一旦提升,每个已存密钥都会读作只读的启动时覆盖,从而阻断从 Models 页轮换密钥。但它的文件名和 dotenv 格式承诺的是一个环境文件,于是用户把非密钥值放进去,而那些值哪儿也到不了:同一个文件里,一个能用的 `DEEPSEEK_API_KEY` 旁边的 `DEEPSEEK_BASE_URL` 会被静默忽略,因为只有凭据 provider 读这份文档,而它只寻址凭据引用。
|
||||
|
||||
一个文件无法既是由 Harness 拥有并隔离的存储,又是按普通环境规则传播的层。[请求级凭据决策](2026-07-29-request-level-llm-config-credentials.md)当初选择 dotenv 是为了对齐同类产品的 home `.env`,而这种混同直到有非密钥值需要用同一个文件时才暴露出来。
|
||||
|
||||
## Decision
|
||||
|
||||
两件工作在 Harness home 下拆成两个文件。
|
||||
|
||||
**`.credentials.yaml` 是 provider 管理的存储。** 一个从 `CredentialRef` 到非空字符串的严格 YAML mapping,没有 `version` 字段,也没有包装层:
|
||||
|
||||
```yaml
|
||||
DEEPSEEK_API_KEY: sk-…
|
||||
OPENAI_API_KEY: sk-…
|
||||
```
|
||||
|
||||
因为该文档只存放凭据、别无他物,任何偏离都是拒绝而不是跳过条目:非 mapping 的根、非 POSIX 标识符的键、非字符串值、空字符串、重复键以及格式错误的 YAML 全部失败——启动时和写入时响亮失败,运行期热重载则告警并保留最后可用快照。被静默忽略的键读起来就是「我存进去的密钥没有生效」,而这正是本次变更要消除的失败。dotenv 物理行编辑器被替换为对已解析文档打补丁,因此注释与未触及条目的排版都会保留,任何字符串值都能往返(含多行),也不会再有条目因为缺少可用引号样式而不可写。写锁、read-modify-write、`0700` 目录下的 `0600` 原子写、精确路径 watcher、按内容相等抑制自写、以及 dispose 时的完全停稳,均保持不变。
|
||||
|
||||
**`$DSH_HOME/.env` 是用户的普通环境层。** [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) 中的 `loadLayeredEnv` 先解析调用目录的 `.env`,再解析 Harness home 的,并且只在进程中没有更高层值时物化每个已接受的值,从而得到 `用户 < 项目 < 继承`。Harness home 在两个文件加载*之前*就从继承的环境解析完毕,因此项目 `.env` 无法改变读取哪份用户文档。只有产品 CLI(命令行界面)叠加这两个文件;SDK 与示例 bin 仍通过 `loadEnv` 加载各自的目录,绝不继承开发者的 `$DSH_HOME`。
|
||||
|
||||
凭据优先级会区分继承环境与发现的文件:继承值仍是只读的按次覆盖,其后是受管文档,再后是仍可写的项目与用户 `.env` 后备值。因此 `set` 会替换发现文件中的值,而不是因为扁平化的 `process.env` 视图认为写入会被遮蔽就加以拒绝。
|
||||
|
||||
不做迁移。已经放在 `$DSH_HOME/.env` 里的密钥会继续作为后备值解析;Models 页一旦存储该引用,受管文档就会优先。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 放弃的:留在 `$DSH_HOME/.env` 里的密钥会被物化进 `process.env`,因而会按[子进程凭据清洗](../../../../packages/subprocess/subprocess/README.md)的规则抵达子进程,而不再留在 provider 内部。它仍是 `.credentials.yaml` 之下的可写后备值;需要由 Harness 拥有并隔离的密钥属于受管文档,后者永不物化。
|
||||
- 换来的:用户 `.env` 里的非密钥值终于生效,这正是最初的缺陷;文档格式可以拒绝它无法承担的内容;`0600` 保护的是一个只存密钥的文件,而不是一个我们同时叫用户往里写普通配置的文件。
|
||||
- provider 写入时用的 `0600` 同样约束它读取的内容:在 POSIX 上,只要文档带有任何 group 或 other 权限位,就会在读取内容之前让启动失败——启动时与每次 reload 都检查,诊断里给出 `chmod 600` 的修复命令。Windows 没有可检查的 mode(其 ACL 无法在此表达),因此跳过该检查而不是伪造它。
|
||||
- `0600` 这条边界仍然只挡其他 OS 用户、挡不住模型,本次拆分未改变这一点——该限制及 keychain provider 的延后项归 [provider README](../../../../packages/credentials/credentials-local/README.md) 所有。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留单一的 `$DSH_HOME/.env`,让 CLI 去提升它。** 否决:提升存储本身正是让已存密钥无法轮换的原因,这也是 [app-boot 当初记录该排除](../../../../packages/ui/app-boot/README.md)的理由。冲突来自这个文件的两份工作,而不是加载器。
|
||||
|
||||
**`$DSH_HOME/.credentials.env`——第二个 dotenv 文件。** 否决:dotenv 适合环境层,却无法表达「一份按凭据引用索引的受管文档」。它无法拒绝非字符串或无法寻址的键,而且它的行编辑器本来就会拒绝无法加引号的值,留下可读却不可写的条目。
|
||||
|
||||
**给新文档加 `version` 字段。** 否决:该格式只有一个受 schema 约束的字符串 mapping,没有需要判别的历史变体。在未发布阶段,直接修改结构并拒绝旧结构,好过提前承诺迁移协议。
|
||||
|
||||
**首次运行时把形似凭据的键从 `$DSH_HOME/.env` 迁出。** 否决:迁移代码会把短命格式变成长期维护面,而判断一个未知文件里哪些键是密钥,恰恰是本次拆分要消除的歧义。旧文件继续作为环境工作,这是诚实的结果,而不是静默的结果。
|
||||
|
||||
**彻底取消用户 `.env` 层,只保留继承的环境。** 在此处否决为超出范围:它本身是自洽的设计(层次更少、每个值只有一处来源),但会移除用户已有的工作流,而分层问题属于那个被延后的优先级决策,不属于本次拆分。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user