Compare commits
825
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2e3b2a012 | ||
|
|
dd393c1820 | ||
|
|
7588e1ea11 | ||
|
|
58956c1a8a | ||
|
|
83253e0d16 | ||
|
|
9d83e3e534 | ||
|
|
8870a13aa9 | ||
|
|
f05e0fb6b9 | ||
|
|
7aa73877e6 | ||
|
|
516bfd5f30 | ||
|
|
16953f2b8e | ||
|
|
fbfc8123e3 | ||
|
|
63187de80f | ||
|
|
9b308c9568 | ||
|
|
2b521b31f1 | ||
|
|
0b3248e67d | ||
|
|
d7ecdcf02c | ||
|
|
60a3d0fbc2 | ||
|
|
950474605f | ||
|
|
a7bd71e024 | ||
|
|
0a9c774617 | ||
|
|
3c4c6c3d7b | ||
|
|
556857bcd2 | ||
|
|
b53fed39d7 | ||
|
|
b9587ada12 | ||
|
|
8803287f71 | ||
|
|
4e0a405a9b | ||
|
|
2ab73152ae | ||
|
|
7f69a3ec37 | ||
|
|
43bf1d3f93 | ||
|
|
454caf0280 | ||
|
|
bd60582403 | ||
|
|
9961eedec8 | ||
|
|
cd95dda1bd | ||
|
|
9e050260e4 | ||
|
|
fb84576ea7 | ||
|
|
a8f9d81f3a | ||
|
|
4fabbd39c5 | ||
|
|
0bfd594875 | ||
|
|
220103670a | ||
|
|
fc0c885287 | ||
|
|
ee35e40bc8 | ||
|
|
7c9b29563d | ||
|
|
c6b3900d2a | ||
|
|
d7dcc5f5af | ||
|
|
177eacbe8b | ||
|
|
c5432b94f9 | ||
|
|
7214a97723 | ||
|
|
3b4122ac16 | ||
|
|
1d55cab9e6 | ||
|
|
644f6abd7c | ||
|
|
a7b5c8a043 | ||
|
|
f5804826a8 | ||
|
|
8a41f7a8e1 | ||
|
|
56c4c3e47c | ||
|
|
bafe887d6d | ||
|
|
dfda1d6aaf | ||
|
|
86c61e24e9 | ||
|
|
99bffa7f9a | ||
|
|
b443c4d246 | ||
|
|
7e4d9edc13 | ||
|
|
00bfdfbce6 | ||
|
|
1eee75b424 | ||
|
|
4ee9e055d5 | ||
|
|
6d99a8a53a | ||
|
|
a5fc275a56 | ||
|
|
d6a699be19 | ||
|
|
74bf7aaa2f | ||
|
|
aed212657a | ||
|
|
e9f1725b11 | ||
|
|
0d44848ba6 | ||
|
|
8eede0f60d | ||
|
|
057b4879cf | ||
|
|
4029451fa7 | ||
|
|
9d6c964985 | ||
|
|
2e2489b888 | ||
|
|
7b6b8a2baa | ||
|
|
2b631c0646 | ||
|
|
c83f02ad81 | ||
|
|
5b671d7df3 | ||
|
|
b27d861741 | ||
|
|
4158a96e14 | ||
|
|
da042b78ff | ||
|
|
1349af8e97 | ||
|
|
0b5fca5c68 | ||
|
|
ba5ce5700c | ||
|
|
96ac11885e | ||
|
|
f42fddad34 | ||
|
|
456162410b | ||
|
|
4f0adbc99a | ||
|
|
cc5a7697e7 | ||
|
|
1eaba2809b | ||
|
|
6c4a2fb294 | ||
|
|
224f75dc63 | ||
|
|
30ea609363 | ||
|
|
a6ee97ebe1 | ||
|
|
0f1164cdee | ||
|
|
4b3cfcfaf3 | ||
|
|
cad50c2504 | ||
|
|
9708c4bb42 | ||
|
|
152e0f42ca | ||
|
|
7a0afea27f | ||
|
|
fac33d7ba7 | ||
|
|
8053f3d450 | ||
|
|
6bc836c0c0 | ||
|
|
1b56cf6013 | ||
|
|
da1df501b8 | ||
|
|
43d00069a9 | ||
|
|
bc55f10a46 | ||
|
|
d85bdd80c1 | ||
|
|
c93bfee16e | ||
|
|
e7feedab5e | ||
|
|
d38391d629 | ||
|
|
94a2b0a248 | ||
|
|
5b0d65fd72 | ||
|
|
2059bdf78e | ||
|
|
ebd7e82075 | ||
|
|
939222a4ad | ||
|
|
d6ed693f23 | ||
|
|
88dfd72537 | ||
|
|
0199633269 | ||
|
|
700e8df91a | ||
|
|
2f2831f033 | ||
|
|
ff3ccb4d7c | ||
|
|
3481d4043e | ||
|
|
d0a1ab8723 | ||
|
|
c73dcecb96 | ||
|
|
284758cbc3 | ||
|
|
c76e371874 | ||
|
|
814f447b8f | ||
|
|
5e44fc79d3 | ||
|
|
7a032b6366 | ||
|
|
237618ca33 | ||
|
|
e94241f601 | ||
|
|
5ee343f735 | ||
|
|
f4c1e31ba8 | ||
|
|
838dbbe0d0 | ||
|
|
69a13cef6d | ||
|
|
bc4fde06ac | ||
|
|
189ca97ec9 | ||
|
|
08f646da36 | ||
|
|
454825ca38 | ||
|
|
4b88085c2a | ||
|
|
2c0596c1ba | ||
|
|
ba4b8fddeb | ||
|
|
2567d35cdf | ||
|
|
843029448a | ||
|
|
d1b1488ed3 | ||
|
|
bd24f8a17f | ||
|
|
dc14f5e4f9 | ||
|
|
48007d76f2 | ||
|
|
fd5d68586f | ||
|
|
9606b69545 | ||
|
|
43846e7032 | ||
|
|
9da63ca94d | ||
|
|
6207453d36 | ||
|
|
d692289533 | ||
|
|
c57fb6f7ef | ||
|
|
1094431de9 | ||
|
|
a25e5fd8a9 | ||
|
|
3e53a08f36 | ||
|
|
f2f9700de2 | ||
|
|
64560eaff1 | ||
|
|
93b30b9765 | ||
|
|
be58ae0577 | ||
|
|
4d07fbcb85 | ||
|
|
ce28b0672f | ||
|
|
39872bafe6 | ||
|
|
c78fdb583a | ||
|
|
693621dcad | ||
|
|
dcfd8c299d | ||
|
|
5c283d27e2 | ||
|
|
4321766ead | ||
|
|
e9b3f80f0b | ||
|
|
d80891921b | ||
|
|
ab904f4b6b | ||
|
|
063165a800 | ||
|
|
0ba6c25807 | ||
|
|
452fe6de55 | ||
|
|
887c1efd7a | ||
|
|
0f6e7442d7 | ||
|
|
6635cddaa4 | ||
|
|
40c9766a8d | ||
|
|
228f3aef83 | ||
|
|
72549e91a0 | ||
|
|
cd2b9ca04a | ||
|
|
207dce0bee | ||
|
|
c9038b4b31 | ||
|
|
387e437af4 | ||
|
|
6d8a400d79 | ||
|
|
37d27fcdf9 | ||
|
|
4d831f2d14 | ||
|
|
bfe82d4c2d | ||
|
|
a4176ef851 | ||
|
|
f39a37a523 | ||
|
|
f3d7ef2b95 | ||
|
|
58144ccf78 | ||
|
|
e807447288 | ||
|
|
713b1aacad | ||
|
|
385f651c5f | ||
|
|
8715fd2a87 | ||
|
|
bc840b483e | ||
|
|
092d16d7ab | ||
|
|
9736136d95 | ||
|
|
fbbcbf33fe | ||
|
|
f95e781f3d | ||
|
|
fe460f7fd1 | ||
|
|
581803bf57 | ||
|
|
b42b5b5e94 | ||
|
|
594305ce19 | ||
|
|
25d675fcf3 | ||
|
|
4977ccc965 | ||
|
|
3c05dcb537 | ||
|
|
5ab91467e2 | ||
|
|
79dda6cf45 | ||
|
|
e2fa69f5e2 | ||
|
|
9f69a7fb16 | ||
|
|
661135fe29 | ||
|
|
ba731d1c9e | ||
|
|
867c47c415 | ||
|
|
59623c8ff4 | ||
|
|
0274a6dd75 | ||
|
|
924282cbcd | ||
|
|
532e712c7b | ||
|
|
3dcabbba44 | ||
|
|
e3cb6dd352 | ||
|
|
80e34ce709 | ||
|
|
99462aef98 | ||
|
|
399d7313c4 | ||
|
|
2db4bdd31d | ||
|
|
5dda764ed3 | ||
|
|
94ff82f57b | ||
|
|
2faa751be9 | ||
|
|
96a66ba478 | ||
|
|
7b1c989332 | ||
|
|
edcde102e9 | ||
|
|
5ad38e5b94 | ||
|
|
6982a8f53c | ||
|
|
f7a4f0b95c | ||
|
|
5212603a4b | ||
|
|
2c21c7a03e | ||
|
|
93a51e4dc2 | ||
|
|
bb407e309d | ||
|
|
9ddc35c26b | ||
|
|
08f42810b9 | ||
|
|
9ea82286f2 | ||
|
|
11ec7cc52a | ||
|
|
92d4410ada | ||
|
|
ae4919634d | ||
|
|
cadd3ac05a | ||
|
|
5924d4c152 | ||
|
|
e72c9e4b13 | ||
|
|
36997d853c | ||
|
|
59ec2d3e94 | ||
|
|
475f5648b3 | ||
|
|
a0656e24b3 | ||
|
|
0aa1ef0d1e | ||
|
|
537102a052 | ||
|
|
e11b2a5558 | ||
|
|
f830cfd66a | ||
|
|
c2a5540ddb | ||
|
|
555c04bdfc | ||
|
|
b4983e92df | ||
|
|
03f439df9e | ||
|
|
97e7223d5f | ||
|
|
a2784a223e | ||
|
|
3a445b802d | ||
|
|
d927cbff99 | ||
|
|
7264906f99 | ||
|
|
336ebb235e | ||
|
|
bcdaed38cc | ||
|
|
3c21d4c28c | ||
|
|
c4855581c3 | ||
|
|
3a98d05a3d | ||
|
|
d5e6b4e2b2 | ||
|
|
5045631a9a | ||
|
|
fde15a8394 | ||
|
|
ace0c4619e | ||
|
|
606b85cd58 | ||
|
|
b72919827a | ||
|
|
3e0e419bd2 | ||
|
|
657482d1dd | ||
|
|
1f1537914f | ||
|
|
6c87f030a3 | ||
|
|
36bd297293 | ||
|
|
795a9cc226 | ||
|
|
55660e8a9e | ||
|
|
8fda2f620a | ||
|
|
44008a79ca | ||
|
|
4550bb6e69 | ||
|
|
404097f813 | ||
|
|
939e6907da | ||
|
|
71e96aebae | ||
|
|
b76a6d9963 | ||
|
|
a433eec5e7 | ||
|
|
c846beaf95 | ||
|
|
eda67c4a64 | ||
|
|
92b3b02622 | ||
|
|
e186c218c0 | ||
|
|
7599652627 | ||
|
|
c26350c303 | ||
|
|
0f9d944f98 | ||
|
|
7627622c7a | ||
|
|
3ec2191246 | ||
|
|
19d87778a1 | ||
|
|
e7bde97aa0 | ||
|
|
b4c69b0f64 | ||
|
|
180bdede47 | ||
|
|
f7ef7103f3 | ||
|
|
f7c9170b3b | ||
|
|
314ccf08af | ||
|
|
ef8f166ccc | ||
|
|
3fc9027a01 | ||
|
|
53f42a781b | ||
|
|
c4d8ee5ff2 | ||
|
|
920e917988 | ||
|
|
b7622e9f13 | ||
|
|
756b10fcfe | ||
|
|
c28639587a | ||
|
|
657e68186a | ||
|
|
7cbb052661 | ||
|
|
8e468c13a0 | ||
|
|
934d4b6501 | ||
|
|
e7dc1322a3 | ||
|
|
b80e22ec2d | ||
|
|
b75b8ce49e | ||
|
|
7729e0ffee | ||
|
|
b17e90f78a | ||
|
|
f1f6ca1c77 | ||
|
|
839a6fa995 | ||
|
|
3aee29c657 | ||
|
|
189d96920c | ||
|
|
da1bc2523a | ||
|
|
1d6b898ef6 | ||
|
|
e33834f522 | ||
|
|
999b677e18 | ||
|
|
dd0fe585dd | ||
|
|
614ff04842 | ||
|
|
f8828b2ce1 | ||
|
|
f9ec0c28ed | ||
|
|
f96fbba2db | ||
|
|
c9a702a267 | ||
|
|
dad135dd41 | ||
|
|
babf22b4cf | ||
|
|
8932b8670e | ||
|
|
a189104d81 | ||
|
|
a04bffb7ed | ||
|
|
f37eb3de47 | ||
|
|
e19f62a553 | ||
|
|
5e7c984663 | ||
|
|
be9d401c33 | ||
|
|
7d8546b4db | ||
|
|
6c0e258b27 | ||
|
|
0b43d89d5f | ||
|
|
cb3df20cf3 | ||
|
|
3f1f7da7c3 | ||
|
|
fe834c7f92 | ||
|
|
0707f4af3e | ||
|
|
d058899194 | ||
|
|
f1c1ff5a08 | ||
|
|
61cd1a3f91 | ||
|
|
c41b2895eb | ||
|
|
06c01ce2e8 | ||
|
|
1c06f1d3ac | ||
|
|
154b2a3cd6 | ||
|
|
da313e4fdc | ||
|
|
aed565f382 | ||
|
|
f121b09fb1 | ||
|
|
2b64ea5f1a | ||
|
|
96fa1dfac8 | ||
|
|
a39d309187 | ||
|
|
0bb083d898 | ||
|
|
66ac8cc094 | ||
|
|
612afd1eaa | ||
|
|
ea60e1c8a7 | ||
|
|
b08310ac1d | ||
|
|
40c5ef4fc3 | ||
|
|
0dc6ce26d7 | ||
|
|
dfae5f0351 | ||
|
|
5b8c8b781f | ||
|
|
88ab8e9133 | ||
|
|
b52966922a | ||
|
|
8dc30d270c | ||
|
|
614cffda8f | ||
|
|
e57e7dc57f | ||
|
|
7263193655 | ||
|
|
371dce2469 | ||
|
|
e970408acd | ||
|
|
a8d960ecb6 | ||
|
|
c153123bdd | ||
|
|
28d478a80d | ||
|
|
012475f7ae | ||
|
|
ad05ed6ad8 | ||
|
|
7f47954cad | ||
|
|
514f92ba53 | ||
|
|
5bc792f629 | ||
|
|
a56619d9f7 | ||
|
|
40b20be9f5 | ||
|
|
99a49712e6 | ||
|
|
08edb3cf10 | ||
|
|
ae19d9383b | ||
|
|
a12d30df2a | ||
|
|
642b538fe0 | ||
|
|
fe03b0686d | ||
|
|
76235ecfb7 | ||
|
|
0ffbd65843 | ||
|
|
7005d500b4 | ||
|
|
bbd899d478 | ||
|
|
a5a9746277 | ||
|
|
c389f96bf3 | ||
|
|
256afa848c | ||
|
|
eec9c3b694 | ||
|
|
2c45dfe71e | ||
|
|
47434e8554 | ||
|
|
e73906d06f | ||
|
|
ccb861d0e2 | ||
|
|
5b039c1796 | ||
|
|
3ef3bc17c5 | ||
|
|
de01754f1e | ||
|
|
4eebc70df2 | ||
|
|
707d0e7e52 | ||
|
|
08cfbd8970 | ||
|
|
4a60189a63 | ||
|
|
b92cc94122 | ||
|
|
77690644b5 | ||
|
|
755e3ceca7 | ||
|
|
e939a482e9 | ||
|
|
5e19460c28 | ||
|
|
4fef2786a9 | ||
|
|
fe5aee7e71 | ||
|
|
bd4199f48c | ||
|
|
508831624f | ||
|
|
a7c7e99652 | ||
|
|
108a7478c9 | ||
|
|
7e017046ca | ||
|
|
d10af0654f | ||
|
|
b24ebc8cce | ||
|
|
b67e0a838c | ||
|
|
4909550782 | ||
|
|
9241e10a47 | ||
|
|
9e7c570094 | ||
|
|
9f2b07cea8 | ||
|
|
4ce4f0bac4 | ||
|
|
3a85ac6d81 | ||
|
|
4a4b86d9b0 | ||
|
|
4429763445 | ||
|
|
bc2174e3aa | ||
|
|
f7b6a13321 | ||
|
|
b0a7d2ce3b | ||
|
|
ad38d63626 | ||
|
|
4a89178785 | ||
|
|
dbb9db5f34 | ||
|
|
3997f36999 | ||
|
|
2cb684c754 | ||
|
|
267d2f8eb9 | ||
|
|
68643a07f9 | ||
|
|
753effe602 | ||
|
|
2555474a7d | ||
|
|
7a7b7d5a53 | ||
|
|
ba05b7d49d | ||
|
|
8935c3d725 | ||
|
|
0348599f04 | ||
|
|
dd25e1e6b3 | ||
|
|
110db78707 | ||
|
|
016af7c67b | ||
|
|
8d0d729091 | ||
|
|
053bf8aaad | ||
|
|
34832ddb2e | ||
|
|
98c043ab0f | ||
|
|
5c98a81a50 | ||
|
|
d3281d0218 | ||
|
|
2f630626b8 | ||
|
|
71a291cbc4 | ||
|
|
4bcabe6da4 | ||
|
|
23b48b7577 | ||
|
|
9a5ed6fb60 | ||
|
|
f3c98e4539 | ||
|
|
89bafdb945 | ||
|
|
90307dd7d6 | ||
|
|
1dc95884a1 | ||
|
|
b000ca44f6 | ||
|
|
3ae70558e7 | ||
|
|
f4e10a61b1 | ||
|
|
0deeec02c4 | ||
|
|
153d54a2f2 | ||
|
|
d792a694ef | ||
|
|
710823fd24 | ||
|
|
f91f33094a | ||
|
|
c4ad45378d | ||
|
|
07b9d2e780 | ||
|
|
89f4acbd74 | ||
|
|
67c5136c7e | ||
|
|
ba4a536030 | ||
|
|
5acf3cd09b | ||
|
|
4ee338532a | ||
|
|
8b8a051c7a | ||
|
|
1780d0d355 | ||
|
|
2c2092e2c6 | ||
|
|
dbd28a8071 | ||
|
|
9baebd8f3f | ||
|
|
09e56a4b4a | ||
|
|
201987b374 | ||
|
|
a7661e2672 | ||
|
|
3599ff5119 | ||
|
|
d718965f00 | ||
|
|
f277ec0e06 | ||
|
|
ead14339bc | ||
|
|
7ef325619f | ||
|
|
930e4d67ea | ||
|
|
4b009e7f94 | ||
|
|
f81c5072bd | ||
|
|
b0ba955be2 | ||
|
|
476918da0a | ||
|
|
a062772d7d | ||
|
|
b685a9a6b0 | ||
|
|
5fd247f64c | ||
|
|
b2a3a0335d | ||
|
|
7a715e7919 | ||
|
|
0c823f4b6f | ||
|
|
7ebde31dde | ||
|
|
d9042f61e1 | ||
|
|
b7f66f4440 | ||
|
|
50cce9c28d | ||
|
|
fffd39b101 | ||
|
|
2aaa88ca1a | ||
|
|
ce9f124e0e | ||
|
|
e2b3800a7e | ||
|
|
9da174c304 | ||
|
|
275ff714cf | ||
|
|
f7b1658ff2 | ||
|
|
0d0914b814 | ||
|
|
70ae2354d0 | ||
|
|
19d167cec2 | ||
|
|
95400aa52c | ||
|
|
85dcebcda4 | ||
|
|
8d0f45febc | ||
|
|
5b2c9ba2d2 | ||
|
|
1ca0df2e24 | ||
|
|
0ff0850678 | ||
|
|
d1e9254454 | ||
|
|
4a9c180e2d | ||
|
|
dda0f05999 | ||
|
|
12dd429331 | ||
|
|
2eccae5631 | ||
|
|
73865675a4 | ||
|
|
c1be95a0cd | ||
|
|
a4f6250be9 | ||
|
|
086b94d4f6 | ||
|
|
ff33f79eb1 | ||
|
|
21db102cea | ||
|
|
d43d93d89f | ||
|
|
a4e445236f | ||
|
|
d3ed6d6f88 | ||
|
|
57885bd73d | ||
|
|
e335d6af08 | ||
|
|
6d55da993d | ||
|
|
4879a8a33a | ||
|
|
74a6d30aba | ||
|
|
cbbbb30214 | ||
|
|
78f03ffef9 | ||
|
|
25b28a14cd | ||
|
|
76cf592234 | ||
|
|
f68c4239e4 | ||
|
|
7340438fe7 | ||
|
|
fdd6380503 | ||
|
|
0125f9019e | ||
|
|
78ee959a5b | ||
|
|
694d250f7a | ||
|
|
03ce9c2ab3 | ||
|
|
fbb385c58f | ||
|
|
d006772ab6 | ||
|
|
d49cbed379 | ||
|
|
b648d3712f | ||
|
|
8188dc55f9 | ||
|
|
35bc3a3c60 | ||
|
|
541dc51e9e | ||
|
|
8551b04e95 | ||
|
|
b759ad91e8 | ||
|
|
8b250f5df5 | ||
|
|
ed3c7efade | ||
|
|
24f6b30d10 | ||
|
|
1ed20364cd | ||
|
|
571ee55ca6 | ||
|
|
ab62273984 | ||
|
|
313a8958c4 | ||
|
|
31b3f3bc44 | ||
|
|
8def94aa5a | ||
|
|
691861091f | ||
|
|
6cb0bc1e69 | ||
|
|
afec36839d | ||
|
|
b2d1cd942e | ||
|
|
1fb187b31e | ||
|
|
179699a8c8 | ||
|
|
621d03d003 | ||
|
|
cea0aff474 | ||
|
|
4574602c07 | ||
|
|
85421f31bc | ||
|
|
c562a4193c | ||
|
|
6861ac7459 | ||
|
|
044777fc2b | ||
|
|
f7b3f237bd | ||
|
|
af87e576da | ||
|
|
42955a346a | ||
|
|
fc1bc118e9 | ||
|
|
326f5ef869 | ||
|
|
627747d8fa | ||
|
|
2b363223da | ||
|
|
99cffb4de7 | ||
|
|
84e94d8394 | ||
|
|
1721a9c566 | ||
|
|
7e253f3995 | ||
|
|
b3116b6048 | ||
|
|
9c8884284f | ||
|
|
8d0bf14eb6 | ||
|
|
095c51aa1c | ||
|
|
6525195953 | ||
|
|
4028686136 | ||
|
|
425a0a55e3 | ||
|
|
2ce9738564 | ||
|
|
c988a6796f | ||
|
|
fcbadb07db | ||
|
|
f7c5621db6 | ||
|
|
15345af731 | ||
|
|
cc69d5fcac | ||
|
|
4158a81188 | ||
|
|
362ee7931d | ||
|
|
f50dce316c | ||
|
|
350dcad963 | ||
|
|
e61d9fc0c7 | ||
|
|
6ad0799419 | ||
|
|
4107ae7516 | ||
|
|
71b0d83822 | ||
|
|
1439887241 | ||
|
|
5f2c676581 | ||
|
|
a4dcff874e | ||
|
|
83b11feb70 | ||
|
|
2890cf3a18 | ||
|
|
b47bfa711b | ||
|
|
ab74a93ed3 | ||
|
|
449fb9c71e | ||
|
|
cfc10f7e1f | ||
|
|
e9957a2671 | ||
|
|
49e1050092 | ||
|
|
9e5f8a1e44 | ||
|
|
5c48f8101a | ||
|
|
6579d53a97 | ||
|
|
8f48afe851 | ||
|
|
ab7f19cd3d | ||
|
|
c09ca68e6f | ||
|
|
3d898cb954 | ||
|
|
d551f5a118 | ||
|
|
e6499cd2cc | ||
|
|
6f5fc05ea3 | ||
|
|
9fa3c8e7f6 | ||
|
|
e04cfc4c87 | ||
|
|
9efabace86 | ||
|
|
a592e70ebc | ||
|
|
1a9bef60c2 | ||
|
|
19fdc37448 | ||
|
|
8cdc6eba02 | ||
|
|
7af8a596ce | ||
|
|
eba829784b | ||
|
|
454b80b90e | ||
|
|
fe2ac749b9 | ||
|
|
f92eff58e0 | ||
|
|
ae2be09813 | ||
|
|
7901c6bc9c | ||
|
|
616803de74 | ||
|
|
77b6b10c21 | ||
|
|
237ed693c1 | ||
|
|
af07d59d10 | ||
|
|
1616c99dae | ||
|
|
b4d8a31ae8 | ||
|
|
dac85fada1 | ||
|
|
eba45d9add | ||
|
|
db5e57347b | ||
|
|
0a02da4ba6 | ||
|
|
4c9f5efc07 | ||
|
|
c29b97ad94 | ||
|
|
3607b320c7 | ||
|
|
bba5ec5f7a | ||
|
|
e8e0ec4176 | ||
|
|
7b182e95e1 | ||
|
|
1adcd8203a | ||
|
|
8bde913784 | ||
|
|
be53ea7dff | ||
|
|
ee956c720d | ||
|
|
3cc05093d2 | ||
|
|
573485e8b5 | ||
|
|
320354a8e0 | ||
|
|
6d11955852 | ||
|
|
368ec64489 | ||
|
|
e53df8b64a | ||
|
|
5895f46b34 | ||
|
|
eaae3502ae | ||
|
|
d8d86b7eb8 | ||
|
|
eb4259e405 | ||
|
|
bad4254d71 | ||
|
|
82ed067354 | ||
|
|
d56f2d2cb4 | ||
|
|
4cacd5829d | ||
|
|
705f84f952 | ||
|
|
f7a6221158 | ||
|
|
40539e9002 | ||
|
|
06cee18430 | ||
|
|
b9a4b682c4 | ||
|
|
8b94ed9c21 | ||
|
|
fd1e51b097 | ||
|
|
78258bf6fd | ||
|
|
eac164392d | ||
|
|
a49376a2ca | ||
|
|
a3208573a6 | ||
|
|
5813c14dee | ||
|
|
9e5745de3b | ||
|
|
d43addeb5c | ||
|
|
42c7317bac | ||
|
|
a19065017f | ||
|
|
5d9603b763 | ||
|
|
64ca04e8fb | ||
|
|
81c6f740e0 | ||
|
|
588be2d3c5 | ||
|
|
737a965e92 | ||
|
|
b55a8e4777 | ||
|
|
e09407e655 | ||
|
|
75c7bbb88d | ||
|
|
fe0b7f2814 | ||
|
|
0928bb2c16 | ||
|
|
56d6c25c9d | ||
|
|
ff146b84a3 | ||
|
|
14baecfa86 | ||
|
|
206e79ee3d | ||
|
|
1553862ac1 | ||
|
|
891f07a2d0 | ||
|
|
8d06ebb5ac | ||
|
|
79b3d7a594 | ||
|
|
e9f8641e76 | ||
|
|
63795eaa5c | ||
|
|
1d6633ae04 | ||
|
|
3f848e1aa0 | ||
|
|
c724617593 | ||
|
|
34f897d1ca | ||
|
|
e26fc35d4b | ||
|
|
0c84f773d9 | ||
|
|
3e526474a3 | ||
|
|
f749b0dfb7 | ||
|
|
b272d985f5 | ||
|
|
9f05e5a077 | ||
|
|
29e60f2c30 | ||
|
|
94651815f2 | ||
|
|
633b3c0f69 | ||
|
|
44d7ebf12f | ||
|
|
e187001b04 | ||
|
|
eeda3cb562 | ||
|
|
22461aedb0 | ||
|
|
46d01068c6 | ||
|
|
0b8956aab9 | ||
|
|
6bc42c47c2 | ||
|
|
d5363c8839 | ||
|
|
600dff4ca4 | ||
|
|
8292ea45ba | ||
|
|
02b889bff6 | ||
|
|
c8f76ca399 | ||
|
|
8fa72e3d60 | ||
|
|
3c9410904e | ||
|
|
a596a02bbd | ||
|
|
c3447a2c15 | ||
|
|
6f4b38ea2e | ||
|
|
ec51b7651f | ||
|
|
dc160810da | ||
|
|
6f9ef57a44 | ||
|
|
f90d57f389 | ||
|
|
c91b68409c | ||
|
|
a96ec3dbd6 | ||
|
|
f026514610 | ||
|
|
13f079812e | ||
|
|
6aaba0c334 | ||
|
|
1eebb7c312 | ||
|
|
194bad298a | ||
|
|
3f0b7779e6 | ||
|
|
9699772fff | ||
|
|
cb6685d5b8 | ||
|
|
5f135ea977 | ||
|
|
03ec1ca57c | ||
|
|
39517fb22b | ||
|
|
8e1c47f82f | ||
|
|
a41c3a300e | ||
|
|
a2de3af0d2 | ||
|
|
700d5407fb | ||
|
|
2aeb9920ee | ||
|
|
17774f7fb1 | ||
|
|
d776df1106 | ||
|
|
fcad57b3af | ||
|
|
c33fe6265e | ||
|
|
fc746f7851 | ||
|
|
35371d0f59 | ||
|
|
b9b2a37528 | ||
|
|
1101422362 | ||
|
|
ea222a1f72 | ||
|
|
f3858fc2a7 | ||
|
|
77d0ee38d2 | ||
|
|
c46c3df2f2 | ||
|
|
882a87c4bc | ||
|
|
37c5dc4b35 | ||
|
|
8b0ea3e461 | ||
|
|
e5f2fbd9a2 | ||
|
|
4b1683c287 | ||
|
|
a20c5ad0a3 | ||
|
|
31ba0d6ae3 | ||
|
|
87d041b2c1 | ||
|
|
f9227e0be6 | ||
|
|
6ea9dd565b | ||
|
|
8eb0c50e74 | ||
|
|
43bc150338 | ||
|
|
9ff0212fbd | ||
|
|
90b7ac94e2 | ||
|
|
bd3b651ea8 | ||
|
|
df2bae9639 | ||
|
|
633daf6624 | ||
|
|
aaf50dc39a | ||
|
|
903a926897 | ||
|
|
52b84e8564 | ||
|
|
2ef85b1e17 | ||
|
|
19444907f0 | ||
|
|
71c34ff13a | ||
|
|
ebce3a5f04 |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.md
|
||||
2026-09-08-web-explicit-file-delivery.md: ff2ddeb59ded05b70006dce217df2966eab9d4f2
|
||||
2026-09-08-web-explicit-file-delivery.zh.md: 85b09ac82c83365b1c198168b78aa7ac84ef216f
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note: Web delivers explicit file snapshots
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-08
|
||||
|
||||
English | [中文](2026-09-08-web-explicit-file-delivery.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Workspace links read live paths, so edits or deletion can invalidate a final deliverable. Files created through shell commands also lack first-party editor mutation records. Delivery needs an explicit operation and saved bytes without expanding Session ZIP exports.
|
||||
|
||||
## Decision
|
||||
|
||||
The [present tool](../../../../packages/fs/tool-present/README.md) owns execution, immutable snapshots, delivery types, and the durable event. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) owns authenticated snapshot actions and browser rendering, with type-only imports from the tool’s `./types` entry. The `standard`, `ptc`, and `cordis` presets mount the tool package; `minimal` retains its two-tool training configuration. The existing attachment service saves immutable bytes; successful final `tools/result` notifications append `deliverables/presented` to the calling Session. Native and nested calls use the same recorder. A later enclosing program failure does not undo a completed nested delivery. Blocked tool results publish none.
|
||||
|
||||
Download and native-open requests authorize a reference by the viewed Session, event sequence, and file index. The event stores no Session ID, so forked history uses the child's own log. The existing produced-file row keeps its names and behavior. Session ZIP retains delivery events but does not collect their attachment bytes.
|
||||
|
||||
Card and closing-mention gestures open a verified private copy with the existing native-command utility. A POST expresses the desktop side effect; GET remains a byte read. Each gesture receives a new copy so application edits cannot corrupt the immutable attachment or alter later opens. Successful copies survive until plugin disposal for applications that read lazily; failed copies are removed immediately, and disposal awaits cancelled work before cleanup.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A Host tool subpath in the UI package** couples preset installation to browser packaging and requires extra published entries. An ordinary tool package preserves shared filesystem and tool error classes through the repository’s peer dependency rules.
|
||||
|
||||
**Live workspace links** cannot preserve a delivered version after edits or deletion. Opening the attachment store’s own path instead would expose immutable saved bytes to application writes.
|
||||
|
||||
**Generic artifact fields throughout tools, dispatch, and Session** would broaden unrelated APIs for one Web feature. A plugin-owned event uses existing extension points and avoids parent-result forwarding.
|
||||
|
||||
**Tool text as the durable index** is unreliable because post-processing and spill can replace ordinary or nested result text. Each plugin instance retains its own completed snapshots by execution identity and publishes them only on a successful final result. Same-name scoped replacements cannot create or duplicate another instance’s delivery records.
|
||||
|
||||
**Descriptor-bound filesystem extensions** would change multiple capability providers. This feature uses existing bounded reads with containment and before/after version checks. Those checks reject ordinary concurrent changes but do not guarantee atomic confinement against swap-and-restore; stronger filesystem guarantees belong to the filesystem provider.
|
||||
|
||||
## Consequences
|
||||
|
||||
The implementation adds no artifact service or attachment format. Unreferenced snapshots can remain after partial failure; attachment retention remains service-owned. A downstream build must understand the new required event to read the log. The generated Session event inventory records that requirement without changing released format generations.
|
||||
|
||||
The delivery event is required-on-read because it is the authorization index for saved bytes, not only display metadata. Skipping it would allow an older reader to reconstruct or fork a Session without its completed deliveries. Unsupported readers refuse that loss instead of silently dropping the references.
|
||||
|
||||
Focused tests cover snapshot bytes, invalid inputs, blocked results, HTTP integrity, native-open copy isolation, retry and disposal, turn isolation, and fork-addressed actions. The recorded Web scenario covers nested completion followed by an enclosing failure, source deletion, reload, native-open gestures without browser downloads, and ZIP exclusion.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note: Web 显式交付文件快照
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-08
|
||||
|
||||
[English](2026-09-08-web-explicit-file-delivery.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
工作区链接读取当前路径,因此编辑或删除会使最终交付文件失效。通过 shell 命令创建的文件也没有第一方编辑器修改记录。交付需要显式操作和保存的字节,同时不扩大 Session ZIP 导出内容。
|
||||
|
||||
## 决策
|
||||
|
||||
[present 工具](../../../../packages/fs/tool-present/README.zh.md)拥有执行、不可变快照、交付类型和持久事件。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)拥有认证快照操作和浏览器渲染,仅从工具的 `./types` 入口导入类型。`standard`、`ptc` 与 `cordis` preset 挂载工具包;`minimal` 保留双工具训练配置。现有 attachment 服务保存不可变字节;成功的最终 `tools/result` 通知将 `deliverables/presented` 追加到调用方 Session。原生与嵌套调用使用同一个记录器。外层程序随后失败不会撤销已完成的嵌套交付。被阻止的工具结果不发布交付。
|
||||
|
||||
下载与原生打开请求通过当前查看的 Session、事件序号与文件索引授权引用。事件不保存 Session ID,因此 fork 历史使用子 Session 自己的日志。现有产出文件行保留其名称和行为。Session ZIP 保留交付事件,但不收集其中引用的 attachment 字节。
|
||||
|
||||
卡片和收尾引用操作通过现有 native-command 工具,在默认应用中打开经过校验的私有副本。POST 表达桌面副作用;GET 仍仅读取字节。每次操作创建新副本,避免应用内编辑损坏不可变 attachment 或改变后续打开的内容。成功副本保留到插件释放,以支持延迟读取的应用;失败副本立即删除,释放时先等待取消的操作结束再清理。
|
||||
|
||||
## 已考虑的替代方案
|
||||
|
||||
**在 UI 包中提供 Host 工具子路径**会将 preset 安装与浏览器打包耦合,并要求额外发布入口。普通工具包通过仓库 peer dependency 规则保留共享的文件系统和工具错误类。
|
||||
|
||||
**实时工作区链接**无法在编辑或删除后保留已交付版本。直接打开 attachment 存储路径则会使不可变保存字节暴露于应用写入。
|
||||
|
||||
**在工具、dispatch 和 Session 中增加通用 artifact 字段**会为单个 Web 功能扩大无关 API。插件拥有的事件使用现有扩展点,并省去父调用结果转发。
|
||||
|
||||
**将工具文本作为持久索引**并不可靠,因为后处理与 spill 可以替换普通或嵌套结果文本。每个插件实例按执行对象保留自身已完成的快照,仅在最终结果成功时发布。同名作用域替代工具不能创建或重复其他实例的交付记录。
|
||||
|
||||
**基于文件描述符的文件系统扩展**会修改多个能力提供方。本功能使用现有有界读取,并检查路径包含关系及读取前后的版本。这些校验会拒绝普通并发变化,但不保证对替换后复原提供原子路径限制;更强的文件系统保证属于文件系统提供方。
|
||||
|
||||
## 影响
|
||||
|
||||
实现不增加 artifact 服务或 attachment 格式。部分失败后可能留下无引用快照;attachment 保留策略仍由服务拥有。下游构建必须理解新必需事件才能读取日志。生成的 Session 事件清单记录该要求,不修改已发布的格式代际。
|
||||
|
||||
交付事件要求读取端识别,因为它是保存字节的授权索引,不只是显示元数据。跳过事件会让旧读取端在重建或分叉 Session 时丢失已完成的交付。不支持该事件的读取端拒绝读取,避免静默丢弃引用。
|
||||
|
||||
定向测试覆盖快照字节、无效输入、被阻止的结果、HTTP 完整性、原生打开的副本隔离、重试与释放、turn 隔离及使用 fork 地址的操作。录制 Web 场景覆盖嵌套调用完成后外层失败、源文件删除、重新加载、不触发浏览器下载的原生打开操作和 ZIP 排除。
|
||||
@@ -1381,6 +1381,9 @@
|
||||
"feature/2026-09-01-web-superellipse-corner-smoothing.i18n.yaml": "sha256:50afdbe5b5e19889918af6d86ab3218c05205be35938b6d33d158c60777e3b58",
|
||||
"feature/2026-09-01-web-superellipse-corner-smoothing.md": "sha256:b1445101c49e74bbcb4f607af850cd6df105d4034828d0dd47081e8079148f15",
|
||||
"feature/2026-09-01-web-superellipse-corner-smoothing.zh.md": "sha256:1a278c417c0d7de3b4c3c35061b419303b4a1a0707831c283d8f862ab9b6fd23",
|
||||
"feature/2026-09-08-web-explicit-file-delivery.i18n.yaml": "sha256:99daae539cc8fd7376ce0265538bee21e1e33f3c0d77c8cc4e011f94b4e9568a",
|
||||
"feature/2026-09-08-web-explicit-file-delivery.md": "sha256:bb416b1e8be081e6cb6af17792eb1a442172ff114c3a3577e5ef06a77eb57093",
|
||||
"feature/2026-09-08-web-explicit-file-delivery.zh.md": "sha256:00a642380e1f6ac9d5cd840e021f4e3e4ae68a289cb6344eb3dcd73a6fd81f4d",
|
||||
"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",
|
||||
|
||||
@@ -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-14-session-persistence.md
|
||||
2026-06-14-session-persistence.md: 55c1bbab94bb7854fd6bbcaace56c30dbcdb01dc
|
||||
2026-06-14-session-persistence.zh.md: 16546ab61773da47064de8388803e92c8b454eae
|
||||
2026-06-14-session-persistence.md: d79975e1fedb6efcd0e4ea83bc799bb158082e41
|
||||
2026-06-14-session-persistence.zh.md: f70b8005a254cc6a9ea8ada31c32e5552ee0c8b2
|
||||
@@ -15,11 +15,11 @@ The [event-sourced model](2026-06-11-event-sourced-sessions.md) makes the append
|
||||
Persistence is a **capability seam** with an abstract Service Definition ([capability seams](2026-06-13-capability-seams.md), the `dsh-shell` template), not loop or core logic:
|
||||
|
||||
1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `create`/`open`/`stat`/`list`/`flush`, with `create`/`open` returning per-session `SessionHandle`s that carry `read`/`append`/`flush`/`close` ([handle-based seam](2026-08-27-handle-based-session-persistence.md)). Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type.
|
||||
2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only logical JSONL log per session: a `SessionHeader` line followed by storage records that losslessly represent the contiguous `SessionEvent` stream. Current v2 writes one event per row; frozen v0 and v1 readers retain their historical packed-delta representation. [Checksummed Zstandard frames](2026-07-19-zstandard-jsonl-session-logs.md) are the default physical encoding, with raw lines configurable.
|
||||
2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only logical JSONL log per session: a `SessionHeader` line followed by storage records that losslessly represent the contiguous `SessionEvent` stream. The current format writes one event per row; frozen v0 and v1 readers retain their historical packed-delta representation. [Checksummed Zstandard frames](2026-07-19-zstandard-jsonl-session-logs.md) are the default physical encoding, with raw lines configurable.
|
||||
|
||||
Key durable, contested choices:
|
||||
|
||||
- **The canonical durable log persists every current `SessionEvent` losslessly.** In v2, one `assistant/message` or `assistant/attempt` embeds the exact timed provider stream for an attempt; `deriveMessages()` projects only the surface message. Dropping embedded stream members is tempting, but it loses replay, timing, usage, partial-failure, and diagnostic facts. Removing a complete event likewise requires dense renumbering because `seq = log.length` and `events[i].seq === i`; the [v1-to-v2 migration](2026-09-01-v2-embedded-assistant-streams.md) performs that rewrite explicitly rather than filtering the canonical log.
|
||||
- **The canonical durable log persists every current `SessionEvent` losslessly.** One `assistant/message` or `assistant/attempt` embeds the exact timed provider stream for an attempt; `deriveMessages()` projects only the surface message. Dropping embedded stream members is tempting, but it loses replay, timing, usage, partial-failure, and diagnostic facts. Removing a complete event likewise requires dense renumbering because `seq = log.length` and `events[i].seq === i`; the [v1-to-v2 migration](2026-09-01-v2-embedded-assistant-streams.md) performs that rewrite explicitly rather than filtering the canonical log.
|
||||
- **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../../../../packages/session/session-checkpoint-policy/README.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, persistence returns its contiguous, parseable events unmodified; the reader owns balancing — resume computes risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }` (`interruptedTurnClosers`) and appends them through its write handle, while read-only observers add the same closers in memory. The synthetic results keep resumed provider transcripts valid. Only the incomplete fragment of a torn final append is discarded — complete records recovered from it are durably rewritten by the write path before its first new append; a parse error or sequence gap in the committed prefix is corruption and makes the session unloadable.
|
||||
- **The file backend is canonical while the service remains extensible.** `dsh-session-persistence-jsonl` is the sole first-party provider and passes `runPersistenceContract`; the abstract service remains available to out-of-tree providers. The [JSONL-only persistence decision](../simplification/2026-08-30-jsonl-only-session-persistence.md) owns removal of the first-party database provider and its deliberate compatibility cut.
|
||||
- **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. `createdAt` is non-negative safe-integer Unix epoch milliseconds: live creation and persistence registration reject fractional values, and JSONL validates the decoded header. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header boundary is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../../archived/simplification/2026-06-19-drop-mutable-session-summary.md).)
|
||||
@@ -29,7 +29,7 @@ Key durable, contested choices:
|
||||
|
||||
Each key choice above records its rejected alternative where the choice is stated: a **stream-filtered canonical log** — loses attempt evidence, while removing events without an explicit migration breaks contiguous sequence numbers; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as log line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever.
|
||||
|
||||
Format versioning: the header carries a `version`; handles expose only `SESSION_FORMAT_VERSION = 2`. JSONL event-body reads compose the static v0-to-v1 and v1-to-v2 adjacent migration chain before returning a handle; the first edge owns bounded legacy normalization, while the second owns Assistant stream embedding and dense reference remapping. V0 remains at suffixless `session.jsonl[.zstd]`, while positive versions use immutable lowercase `session.vN.jsonl[.zstd]` names ([released Session migration](2026-08-31-released-session-format-migrations.md)). Current-generation append and flush are robust to partial trailing writes; a future provider or write-ahead log needs its own power-loss and recovery contract.
|
||||
Format versioning: the header carries a `version`; handles expose only the logical format selected by `SESSION_FORMAT_VERSION` ([version authority](../../../../docs/session-format-status.md)). JSONL event-body reads compose the complete static adjacent migration chain before returning a handle; each edge owns its historical transformations. V0 remains at suffixless `session.jsonl[.zstd]`, while positive versions use immutable lowercase `session.vN.jsonl[.zstd]` names ([released Session migration](2026-08-31-released-session-format-migrations.md)). Current-generation append and flush are robust to partial trailing writes; a future provider or write-ahead log needs its own power-loss and recovery contract.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -15,11 +15,11 @@ Status: implemented
|
||||
持久化是一个具有抽象 Service Definition 的**能力 seam**([能力 seam](2026-06-13-capability-seams.zh.md),`dsh-shell` 模板),而非循环或核心逻辑:
|
||||
|
||||
1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `create`/`open`/`stat`/`list`/`flush`,其中 `create`/`open` 返回逐会话的 `SessionHandle`,句柄承载 `read`/`append`/`flush`/`close`([基于句柄的 seam](2026-08-27-handle-based-session-persistence.zh.md))。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。
|
||||
2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。当前 v2 每个事件写一行;冻结的 v0 与 v1 reader 保留其历史 packed-delta 表示。[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.zh.md)是默认物理编码,也可通过配置使用原始行。
|
||||
2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。当前格式每个事件写一行;冻结的 v0 与 v1 reader 保留其历史 packed-delta 表示。[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.zh.md)是默认物理编码,也可通过配置使用原始行。
|
||||
|
||||
长期有效、存在争议的关键选择:
|
||||
|
||||
- **规范持久日志无损保留每个当前 `SessionEvent`。** 在 v2 中,一个 `assistant/message` 或 `assistant/attempt` 会嵌入该 attempt 的精确带时间 provider stream;`deriveMessages()` 只投影 surface message。删除嵌入 stream 成员看似诱人,但会丢失 replay、timing、usage、部分失败与诊断事实。移除完整事件同样需要密集重新编号,因为 `seq = log.length` 且 `events[i].seq === i`;[v1 到 v2 迁移](2026-09-01-v2-embedded-assistant-streams.zh.md)会显式执行该改写,而不是过滤规范日志。
|
||||
- **规范持久日志无损保留每个当前 `SessionEvent`。** 一个 `assistant/message` 或 `assistant/attempt` 会嵌入该 attempt 的精确带时间 provider stream;`deriveMessages()` 只投影 surface message。删除嵌入 stream 成员看似诱人,但会丢失 replay、timing、usage、部分失败与诊断事实。移除完整事件同样需要密集重新编号,因为 `seq = log.length` 且 `events[i].seq === i`;[v1 到 v2 迁移](2026-09-01-v2-embedded-assistant-streams.zh.md)会显式执行该改写,而不是过滤规范日志。
|
||||
- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../../../../packages/session/session-checkpoint-policy/README.zh.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,持久化会原样返回其连续、可解析的事件;配平是读方的职责——resume 会为未应答的 assistant 调用计算按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`(`interruptedTurnClosers`),并通过其写句柄追加它们,而只读观察方仅在内存中添加同样的收尾事件。合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有撕裂的最终 append 中不完整的碎片会被丢弃——从中恢复的完整记录由写路径在第一次新 append 之前持久重写;已提交前缀中的解析错误或序号间隙,属于数据损坏,会使该会话不可加载。
|
||||
- **文件后端为规范实现,服务保持可扩展。** `dsh-session-persistence-jsonl` 是唯一 first-party provider,并通过 `runPersistenceContract`;抽象服务继续供仓库外 provider 使用。[JSONL-only 持久化决策](../simplification/2026-08-30-jsonl-only-session-persistence.zh.md)负责 first-party 数据库 provider 的删除及其明确 compatibility cut。
|
||||
- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header 边界是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../../archived/simplification/2026-06-19-drop-mutable-session-summary.md)。)
|
||||
@@ -29,7 +29,7 @@ Status: implemented
|
||||
|
||||
上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤 stream 的规范日志**会丢失 attempt 证据,而未通过显式迁移移除事件会破坏连续序号;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储不一致;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。
|
||||
|
||||
格式版本控制:header 携带 `version`;句柄只暴露 `SESSION_FORMAT_VERSION = 2`。JSONL 的事件正文读取会在返回句柄前组合静态 v0-to-v1 与 v1-to-v2 相邻迁移链;第一条边负责有界 legacy normalization,第二条边负责 Assistant stream 嵌入与密集引用重映射。V0 保留无后缀的 `session.jsonl[.zstd]`,正版本则使用不可变的小写 `session.vN.jsonl[.zstd]` 名称([已发布 Session 迁移](2026-08-31-released-session-format-migrations.zh.md))。当前 generation 的 append 与 flush 能稳健处理不完整尾部写入;未来 provider 或 WAL 必须定义自己的断电与恢复约定。
|
||||
格式版本控制:header 携带 `version`;句柄只暴露由 `SESSION_FORMAT_VERSION` 选定的逻辑格式([版本真源](../../../../docs/session-format-status.zh.md))。JSONL 的事件正文读取会在返回句柄前组合完整的静态相邻迁移链;每条迁移边拥有自身的历史转换。V0 保留无后缀的 `session.jsonl[.zstd]`,正版本则使用不可变的小写 `session.vN.jsonl[.zstd]` 名称([已发布 Session 迁移](2026-08-31-released-session-format-migrations.zh.md))。当前 generation 的 append 与 flush 能稳健处理不完整尾部写入;未来 provider 或 WAL 必须定义自己的断电与恢复约定。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-18-session-surface.md
|
||||
2026-06-18-session-surface.md: 0139cc4beba766e4e8b936594899649304234eaa
|
||||
2026-06-18-session-surface.zh.md: 0596d2a0425890924276265dd9cc6c32fcffb974
|
||||
2026-06-18-session-surface.md: 93ea55883dedd943fe1ffac67a9842c962ca6dac
|
||||
2026-06-18-session-surface.zh.md: 54ecb1162bc46007dfcbb7d8cb39075d52171567
|
||||
@@ -12,36 +12,32 @@ The event log is authoritative, but history manipulation had no durable shared m
|
||||
|
||||
Add a **surface** — a derived, cached order of event sequences (the subset of events that produce LLM messages) — maintained by `surfaceOp` markers in the event log.
|
||||
|
||||
### Two new top-level fields on `SessionEvent`
|
||||
### Top-level surface metadata on `SessionEvent`
|
||||
|
||||
Every `SessionEvent` gains two optional fields (structural metadata, like `seq`/`time`):
|
||||
Surface metadata belongs only to the four surface event types (`system/message`, `user/message`, `assistant/message`, `tool/result`):
|
||||
|
||||
- **`sourceEventSeqs?: number[]`** — seq numbers of earlier events cited as sources, such as a `tool/call` cited by its result or surface nodes shadowed by a compaction marker. A present list is non-empty, unique, earlier, and known. V2 `assistant/message` embeds its provider stream and cannot carry this field. Without cited seqs, replay cannot validate that a replace-range operation names every event it removed.
|
||||
- **`surfaceOp?: SurfaceOp`** — how this event entered the surface. Absent for non-surface events.
|
||||
- **`sourceEventSeqs?: SessionSeq[]`** — seq numbers of earlier events cited as sources, such as a `tool/call` cited by its result or surface nodes shadowed by a compaction marker. A present list is non-empty, unique, earlier, and known. `assistant/message` embeds its provider stream and cannot carry this field. Without cited seqs, replay cannot validate that a replace-range operation names every event it removed.
|
||||
- **`surfaceOp: SurfaceOp`** — required placement for every surface event. Known log-only events forbid both metadata fields; native unknown or obsolete ignorable envelopes remain opaque.
|
||||
|
||||
### SurfaceOp: two operations
|
||||
|
||||
```ts
|
||||
export type SurfaceOp =
|
||||
| 'append' // normal tail append
|
||||
| { op: 'replace'; start: number; end: number } // shadow [start, end] inclusive
|
||||
```
|
||||
The [source-backed `SurfaceOp` reference](../../../../docs/subsystems/session.md#surface-types) defines the exact union. Replacement objects contain only `op`, `startSeq`, and `endSeq`; endpoints use the `SessionSeq` brand.
|
||||
|
||||
1. **Append** — add the new event seq to the tail. Used by `user/message`, `assistant/message`, `tool/result`, `context/message`. The loop passes `surfaceOp: 'append'` on all such appends and records `sourceEventSeqs` where applicable: `tool/result` records its `tool/call` source, while `assistant/message` owns its embedded stream directly.
|
||||
1. **Append** — add the new event seq to the tail. Used by `system/message`, `user/message`, `assistant/message`, `tool/result`. The loop passes `surfaceOp: 'append'` on all such appends and records `sourceEventSeqs` where applicable: `tool/result` records its `tool/call` source, while `assistant/message` owns its embedded stream directly.
|
||||
|
||||
2. **Replace** — remove entries from `start` through `end` (both inclusive) and insert the new event seq in their place. Both `start` and `end` must be present in the current surface; `start === end` replaces one entry. The event's `sourceEventSeqs` must contain every shadowed surface seq. The shadowed events remain in the log but are no longer on the surface.
|
||||
2. **Replace** — remove entries from `startSeq` through `endSeq` (both inclusive) and insert the new event seq in their place. Both `startSeq` and `endSeq` must be present in the current surface; `startSeq === endSeq` replaces one entry. The event's `sourceEventSeqs` must contain every shadowed surface seq. The shadowed events remain in the log but are no longer on the surface.
|
||||
|
||||
### SurfaceManager: delta-based, not full rebuild
|
||||
|
||||
A `Session` owns one `SurfaceManager` that maintains an ordered `number[]` of event seqs. The manager validates each seed or append candidate without applying it before commit, then processes only committed events since its previous synchronization rather than rescanning the entire log. `Session.surface` exposes the same manager through the readonly `SessionSurface` contract, so acceptance, derived history, compaction, and workspace context share one incremental state. Replace locates its inclusive endpoints by array position and splices the replacement seq into that range; no second manager, link objects, or seq-to-node map duplicates the order.
|
||||
A `Session` owns one `SurfaceManager` that maintains an ordered `SessionSeq[]` of event seqs. The manager validates each seed or append candidate without applying it before commit, then processes only committed events since its previous synchronization rather than rescanning the entire log. `Session.surface` exposes the same manager through the readonly `SessionSurface` contract, so acceptance, derived history, compaction, and workspace context share one incremental state. Replace locates its inclusive endpoints by array position and splices the replacement seq into that range; no second manager, link objects, or seq-to-node map duplicates the order.
|
||||
|
||||
Delta processing is O(1) when no new events and O(new events) when new events arrive.
|
||||
|
||||
`deriveMessages()` uses the surface when surface markers exist, falling back to the existing linear scan for sessions without markers (backward compatibility).
|
||||
`deriveMessages()` walks the surface as its sole derivation path. A surface event without its required marker is invalid, not an implicit append.
|
||||
|
||||
### Persistence
|
||||
|
||||
The new fields are serialized as top-level JSON properties. JSONL storage requires no separate column mapping: its lossless JSON boundary preserves both values. Released v0 and v1 share this surface representation, and the identity v0-to-v1 edge preserves it exactly; a future structural representation change increments `SESSION_FORMAT_VERSION` and owns an adjacent migration.
|
||||
The fields are serialized as top-level JSON properties. JSONL preserves placement and provenance without a separate column mapping. The [V3 canonical-envelope decision](2026-09-06-v3-canonical-session-envelopes.md) owns exact replacement keys and strict-acceptance rationale; the [V2-to-V3 specification](../../../../packages/session/session-format-v2-to-v3/README.md#canonical-envelopes) owns historical conversion. This note retains ordered-projection ownership and replacement rationale.
|
||||
|
||||
### Crash recovery
|
||||
|
||||
@@ -51,22 +47,22 @@ The `repair.ts` module synthesizes `tool/result` closers for orphaned tool calls
|
||||
|
||||
`Session` validates `sourceEventSeqs` and `surfaceOp` at the always-on seed/append boundary: source lists are non-empty, unique, earlier, and known; `assistant/message` carries no source list; replacement endpoints exist in surface order; and `sourceEventSeqs` covers every shadowed node. These are single-record acceptance and storage-projection rules, not optional invariant-service contributions.
|
||||
|
||||
Every surface-eligible event must carry `surfaceOp` or it would disappear from derived history. Typed `append` overloads enforce this for literal event types; runtime checks in `append` and the seed constructor cover widened unions and current loaded logs. Historical v0 validation and normalization belong to the v0-to-v1 edge rather than generic Session code.
|
||||
Every surface-eligible event must carry `surfaceOp` or it would disappear from derived history. Typed `append` overloads enforce this for literal event types; runtime checks in `append` and the seed constructor cover widened unions and current loaded logs. Released validation and conversion belong to their versioned migration edges rather than generic Session code; see the [V2-to-V3 placement rules](../../../../packages/session/session-format-v2-to-v3/README.md#canonical-envelopes).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Per-plugin `agent/request` wrapping** (the pre-surface pattern for history manipulation) — listener-ordering fragility, no durable record of what was changed, and every new manipulation forces another change to core `deriveMessages()`.
|
||||
- **Half-open `[start, endExclusive)` replace ranges** — rejected: endpoints are named by surface event seqs, and single-entry replacement (`start === end`) reads naturally with inclusive semantics.
|
||||
- **Half-open `[start, endExclusive)` replace ranges** — rejected: endpoints are named by surface event seqs, and single-entry replacement (`startSeq === endSeq`) reads naturally with inclusive semantics.
|
||||
- **Linked node objects plus a seq map** — rejected: production did not read predecessor links, the only successor use was the next array position, and replacement already required linear `indexOf` lookup. A single seq array preserves the same asymptotic behavior with one representation to validate.
|
||||
- **Full rebuild behind a dirty flag** instead of delta processing — O(N²) over a session's lifetime: every single-event append would rescan all prior events.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **`packages/core/session`**: `surface.ts` (`SurfaceManager`) maintains one ordered seq array for candidate acceptance and live projection; `SessionSurface` is its readonly public view. `SurfaceOp`/`SurfaceIntent` and the top-level session-event fields record how entries join it. `append()` requires a `SurfaceIntent` for surface events, `deriveMessages()` walks the surface as the sole derivation path, and `repair.ts` emits surface-aware closers. The seed constructor rejects a surface-eligible seed event missing its `surfaceOp` marker (see § Invariants).
|
||||
- **`packages/core/agent-loop`**: All surface-capable appends pass surface opts. Each `assistant/message` cites its chunk seqs; each `tool/result` cites its `tool/call` seq.
|
||||
- **`packages/session/session-persistence-jsonl`**: No changes required.
|
||||
- **`packages/session/session-persistence`**: Abstract interface unchanged.
|
||||
- **`packages/core/agent-loop`**: All surface-capable appends pass surface opts. Each `assistant/message` embeds its exact provider stream and forbids `sourceEventSeqs`; each `tool/result` cites its `tool/call` seq.
|
||||
- **`packages/session/session-persistence-jsonl`**: Persists canonical surface metadata and restores current events through validated format preparation.
|
||||
- **`packages/session/session-persistence`**: Keeps storage ownership separate from the in-memory surface projection.
|
||||
|
||||
The surface is the foundation history manipulation ships on — dsh-compaction's compaction rides it. A compaction or tool-result-pruner plugin appends one of the existing message-producing event types (a `user/message` carrying the summary, say) with `surfaceOp: { op: 'replace', start, end }` and `sourceEventSeqs` covering the shadowed entries — the new event takes the range's place on the surface while the plugin's own trace events (e.g. `compaction/start`, `compaction/end`) stay off it. Replay preserves the decision deterministically.
|
||||
The surface is the foundation history manipulation ships on — dsh-compaction's compaction rides it. A compaction or tool-result-pruner plugin appends one of the existing message-producing event types (a `user/message` carrying the summary, say) with `surfaceOp: { op: 'replace', startSeq, endSeq }` and `sourceEventSeqs` covering the shadowed entries — the new event takes the range's place on the surface while the plugin's own trace events (e.g. `compaction/start`, `compaction/end`) stay off it. Replay preserves the decision deterministically.
|
||||
|
||||
A `tool/result` replacement may rewrite exactly one current `tool/result` and must preserve every data field except `content`. Session acceptance enforces this rule together with positional range and cited source-event validation, independent of optional diagnostic plugins.
|
||||
@@ -12,36 +12,32 @@ Status: implemented
|
||||
|
||||
新增一个 **surface**:事件 seq 的派生并缓存的有序投影(即产出 LLM(大语言模型)消息的事件子集),通过事件日志中的 `surfaceOp` 标记维护。
|
||||
|
||||
### `SessionEvent` 新增两个顶层字段
|
||||
### `SessionEvent` 的顶层 surface 元数据
|
||||
|
||||
每个 `SessionEvent` 获得两个可选字段(结构性元数据,与 `seq`/`time` 同级):
|
||||
surface 元数据仅属于四种 surface 事件类型(`system/message`、`user/message`、`assistant/message`、`tool/result`):
|
||||
|
||||
- **`sourceEventSeqs?: number[]`**:被引用为数据来源的早期事件 seq 编号,例如 result 引用的 `tool/call`,或被 compaction marker 遮蔽的 surface 节点。出现的列表必须非空、唯一、更早且已知。V2 `assistant/message` 嵌入其 provider stream,不能携带该字段。如果没有这些引用的 seq,回放就无法验证 replace-range 操作是否列出了它移除的每个事件。
|
||||
- **`surfaceOp?: SurfaceOp`**:该事件如何进入 surface。非 surface 事件不携带此字段。
|
||||
- **`sourceEventSeqs?: SessionSeq[]`**:被引用为数据来源的早期事件 seq 编号,例如 result 引用的 `tool/call`,或被 compaction marker 遮蔽的 surface 节点。出现的列表必须非空、唯一、更早且已知。`assistant/message` 嵌入其 provider stream,不能携带该字段。如果没有这些引用的 seq,回放就无法验证 replace-range 操作是否列出了它移除的每个事件。
|
||||
- **`surfaceOp: SurfaceOp`**:每个 surface 事件必填的位置声明。已知仅日志事件禁止两个元数据字段;原生未知或已退役的可忽略信封保持不透明。
|
||||
|
||||
### SurfaceOp:两种操作
|
||||
|
||||
```ts
|
||||
export type SurfaceOp =
|
||||
| 'append' // normal tail append
|
||||
| { op: 'replace'; start: number; end: number } // shadow [start, end] inclusive
|
||||
```
|
||||
[与源码同步的 `SurfaceOp` 参考](../../../../docs/subsystems/session.zh.md#surface-types)定义了精确联合类型。替换对象仅包含 `op`、`startSeq` 和 `endSeq`;端点使用 `SessionSeq` 品牌。
|
||||
|
||||
1. **Append**:在尾部追加新事件的 seq。`user/message`、`assistant/message`、`tool/result`、`context/message` 使用此操作。agent loop(智能体循环)在所有此类追加上传入 `surfaceOp: 'append'`,并在适用时记录 `sourceEventSeqs`:`tool/result` 记录其 `tool/call` 来源,`assistant/message` 则直接拥有其嵌入式 stream。
|
||||
1. **Append**:在尾部追加新事件的 seq。`system/message`、`user/message`、`assistant/message`、`tool/result` 使用此操作。agent loop(智能体循环)在所有此类追加上传入 `surfaceOp: 'append'`,并在适用时记录 `sourceEventSeqs`:`tool/result` 记录其 `tool/call` 来源,`assistant/message` 则直接拥有其嵌入式 stream。
|
||||
|
||||
2. **Replace**:移除从 `start` 到 `end`(两端包含)的条目,并在其位置插入新事件的 seq。`start` 和 `end` 都必须存在于当前 surface;`start === end` 表示替换单个条目。该事件的 `sourceEventSeqs` 必须包含所有被遮蔽的 surface seq。被遮蔽的事件仍留在日志中,但不再出现在 surface 上。
|
||||
2. **Replace**:移除从 `startSeq` 到 `endSeq`(两端包含)的条目,并在其位置插入新事件的 seq。`startSeq` 和 `endSeq` 都必须存在于当前 surface;`startSeq === endSeq` 表示替换单个条目。该事件的 `sourceEventSeqs` 必须包含所有被遮蔽的 surface seq。被遮蔽的事件仍留在日志中,但不再出现在 surface 上。
|
||||
|
||||
### SurfaceManager:基于增量,而非全量重建
|
||||
|
||||
一个 `Session` 拥有一个 `SurfaceManager`,后者维护事件 seq 的有序 `number[]`。管理器会在提交前校验每个种子或追加候选项而不应用它,然后只处理上次同步之后已经提交的事件,而不重新扫描整个日志。`Session.surface` 通过只读的 `SessionSurface` 约定暴露同一个管理器,因此接纳、派生历史、压缩与工作区上下文共享同一份增量状态。Replace 按数组位置定位两个端点(均包含在范围内),并把替换 seq splice 到该范围;不会用第二个管理器、链接对象或 seq 到节点的 map 来重复表达顺序。
|
||||
一个 `Session` 拥有一个 `SurfaceManager`,后者维护事件 seq 的有序 `SessionSeq[]`。管理器会在提交前校验每个种子或追加候选项而不应用它,然后只处理上次同步之后已经提交的事件,而不重新扫描整个日志。`Session.surface` 通过只读的 `SessionSurface` 约定暴露同一个管理器,因此接纳、派生历史、压缩与工作区上下文共享同一份增量状态。Replace 按数组位置定位两个端点(均包含在范围内),并把替换 seq splice 到该范围;不会用第二个管理器、链接对象或 seq 到节点的 map 来重复表达顺序。
|
||||
|
||||
无新事件时增量处理为 O(1),有新事件到达时为 O(新事件数)。
|
||||
|
||||
`deriveMessages()` 在存在 surface 标记时使用 surface,对没有标记的会话回退到既有的线性扫描(向后兼容)。
|
||||
`deriveMessages()` 以遍历 surface 作为唯一派生路径。缺少必填标记的 surface 事件无效,不会被视为隐式追加。
|
||||
|
||||
### 持久化
|
||||
|
||||
新字段作为顶层 JSON 属性序列化。JSONL 存储无需单独列映射:其无损 JSON 边界会保留两个值。已发布 v0 与 v1 共享该 surface 表示,恒等的 v0-to-v1 边会精确保留它;未来结构性表示变更会递增 `SESSION_FORMAT_VERSION` 并拥有一项相邻迁移。
|
||||
这些字段作为顶层 JSON 属性序列化。JSONL 无需单独列映射即可保留位置与来源。[V3 规范信封决策](2026-09-06-v3-canonical-session-envelopes.zh.md)负责精确替换键与严格准入依据;[V2 到 V3 规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#canonical-envelopes)负责历史转换。本文继续负责有序投影的所有权与替换依据。
|
||||
|
||||
### 崩溃恢复
|
||||
|
||||
@@ -51,22 +47,22 @@ export type SurfaceOp =
|
||||
|
||||
`Session` 在始终启用的 seed/append 边界校验 `sourceEventSeqs` 与 `surfaceOp`:source list 必须非空、唯一、更早且已知;`assistant/message` 不携带 source list;replacement endpoint 必须存在于 surface 顺序中;`sourceEventSeqs` 必须覆盖每个被遮蔽的节点。这些是单记录接纳与存储投影规则,不是由可选 invariant service 提供的规则。
|
||||
|
||||
每个可进入 surface 的事件都必须携带 `surfaceOp`,否则它将从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此规则;`append` 和种子构造函数中的运行时检查覆盖宽化联合类型和当前已加载日志。历史 v0 的校验与规范化属于 v0-to-v1 边,而不属于通用 Session 代码。
|
||||
每个可进入 surface 的事件都必须携带 `surfaceOp`,否则它将从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此规则;`append` 和种子构造函数中的运行时检查覆盖宽化联合类型和当前已加载日志。已发布格式的校验与转换属于各自版本化迁移边,而不属于通用 Session 代码;参见 [V2 到 V3 位置规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#canonical-envelopes)。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **逐插件的 `agent/request` 包装**(surface 之前的历史操纵模式):监听器排序脆弱、无法持久记录改动内容,且每种新操纵都迫使核心 `deriveMessages()` 再次修改。
|
||||
- **半开区间 `[start, endExclusive)` 的 replace 范围**:否决。端点由 surface 事件 seq 命名,单条目替换(`start === end`)在闭区间语义下读起来更自然。
|
||||
- **半开区间 `[start, endExclusive)` 的 replace 范围**:否决。端点由 surface 事件 seq 命名,单条目替换(`startSeq === endSeq`)在闭区间语义下读起来更自然。
|
||||
- **链接节点对象加 seq map**:否决。生产代码不读取前驱链接,唯一的后继用途就是数组中的下一个位置,而替换本来就需要线性 `indexOf` 查找。单个 seq 数组在保留相同渐进复杂度的同时,只留下一个需要校验的表示。
|
||||
- **脏标记后全量重建**替代增量处理:在会话生命周期内为 O(N²),每次单事件追加都要重新扫描所有先前事件。
|
||||
|
||||
## 后果
|
||||
|
||||
- **`packages/core/session`**:`surface.ts`(`SurfaceManager`)维护一个用于候选接纳和实时投影的有序 seq 数组;`SessionSurface` 是其只读公共视图。`SurfaceOp`/`SurfaceIntent` 与顶层会话事件字段记录条目如何加入它。`append()` 要求 surface 事件携带 `SurfaceIntent`,`deriveMessages()` 以遍历 surface 作为唯一派生路径,`repair.ts` 则发出 surface 感知的闭合事件。种子构造函数拒绝缺少 `surfaceOp` 标记的可进入 surface 的种子事件(见「不变式」一节)。
|
||||
- **`packages/core/agent-loop`**:所有涉及 surface 事件的追加操作都传入 surface 选项。每个 `assistant/message` 都引用产生它的分片 seq;每个 `tool/result` 都引用它的 `tool/call` seq。
|
||||
- **`packages/session/session-persistence-jsonl`**:无需改动。
|
||||
- **`packages/session/session-persistence`**:抽象接口不变。
|
||||
- **`packages/core/agent-loop`**:所有涉及 surface 事件的追加操作都传入 surface 选项。每个 `assistant/message` 都嵌入精确提供方 stream,并禁止 `sourceEventSeqs`;每个 `tool/result` 都引用其 `tool/call` seq。
|
||||
- **`packages/session/session-persistence-jsonl`**:持久化规范 surface 元数据,并通过经过校验的格式准备恢复当前事件。
|
||||
- **`packages/session/session-persistence`**:存储所有权与内存 surface 投影保持分离。
|
||||
|
||||
surface 是历史操纵赖以落地的基础——dsh-compaction 的压缩就搭载于其上。压缩或 tool-result-pruner 插件追加一个既有的消息产出事件类型(例如一条携带摘要的 `user/message`),附带 `surfaceOp: { op: 'replace', start, end }` 和覆盖被遮蔽条目的 `sourceEventSeqs`——新事件在 surface 上取代该范围的位置,而插件自身的 trace 事件(如 `compaction/start`、`compaction/end`)不进入 surface。回放以确定性方式保留该决策。
|
||||
surface 是历史操纵赖以落地的基础——dsh-compaction 的压缩就搭载于其上。压缩或 tool-result-pruner 插件追加一个既有的消息产出事件类型(例如一条携带摘要的 `user/message`),附带 `surfaceOp: { op: 'replace', startSeq, endSeq }` 和覆盖被遮蔽条目的 `sourceEventSeqs`——新事件在 surface 上取代该范围的位置,而插件自身的 trace 事件(如 `compaction/start`、`compaction/end`)不进入 surface。回放以确定性方式保留该决策。
|
||||
|
||||
一次 `tool/result` 替换只能改写当前的一个 `tool/result`,并且必须保留除 `content` 以外的每个数据字段。Session 接纳会与位置范围和引用的源事件校验一起强制这条规则,不依赖可选的诊断插件。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md
|
||||
2026-07-05-reconstructable-requests.md: bca93a60bf07484d73f1faf50359b72a0d00b9a3
|
||||
2026-07-05-reconstructable-requests.zh.md: c9d2a4a5d05456df8b0bd065bade8a41dd7e4e84
|
||||
2026-07-05-reconstructable-requests.md: 2f88675a75a72e7fbf105dfbf4f337a4dd80948a
|
||||
2026-07-05-reconstructable-requests.zh.md: 90008502a6651e38c142b7fb88052c05d46dea76
|
||||
@@ -22,9 +22,9 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro
|
||||
|
||||
**Messages.** `Session.deriveMessages()` is cached: each surface entry is projected exactly once, when first seen, through the public per-event function `deriveEventMessage(event)`; a surface rewrite (a compaction `replace` — `SurfaceManager.replaceGeneration`) rebuilds. Callers get a fresh array per call over shared, deep-frozen messages: mutating logged history through a projection is unrepresentable (it throws), replacing the old clone-per-call isolation. External reconstructors fold the same public function over a log prefix, so no two paths can disagree.
|
||||
|
||||
`EpochHeader` records the request's non-history state: call config, rendered system prompt, and tool schemas, with empty values canonicalized to absence. Adapter-supplied effort and token defaults retain their `adapterDefaults` provenance; a Web model selection restored from the log omits an adapter-owned effort so the next resolution cannot reclassify the same effective config as an explicit selection and a false change. `request/header` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, an in-instance change uses `change`, and an unchanged envelope beginning an explicitly declared message series or following a surface replacement uses `series`. A `change` snapshot carries `startsSeries: true` when the changed request also starts a series, preserving the two independent facts without a duplicate header. Ordinary append-only later Turns, further same-series Steps, and retries inherit the latest snapshot. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded.
|
||||
`EpochHeader` records the request's non-history state: call config and tool schemas. Writers omit `tools: []` and `adapterDefaults: {}`; current acceptance rejects those fields and any `header.system`, rather than repairing them. Whitespace-only system-message content, `config.stop: []`, and nested extensions remain intact. The [V3 canonical-envelope decision](2026-09-06-v3-canonical-session-envelopes.md) owns historical conversion. The rendered system prompt is derived history — the `system/message` event at surface node 0, per the [surface-node Agent Note](2026-09-02-system-prompt-as-surface-node.md) — so a prompt change is a surface replacement rather than a header change. Adapter-supplied effort and token defaults retain their `adapterDefaults` provenance; a Web model selection restored from the log omits an adapter-owned effort so the next resolution cannot reclassify the same effective config as an explicit selection and a false change. `request/header` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, an in-instance change uses `change`, and an unchanged envelope beginning an explicitly declared message series or following a surface replacement uses `series`. A `change` snapshot carries `startsSeries: true` when the changed request also starts a series, preserving the two independent facts without a duplicate header. Ordinary append-only later Turns, further same-series Steps, and retries inherit the latest snapshot. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded.
|
||||
|
||||
Each proposed step first claims its inbox batch and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start`, records the final message batch as `user/message` events, and may use `startsRequestSeries: true` to declare a distinct series. The step then assembles the system prompt and tools, while `agent/request` may replace only the frozen call-config seed. The loop records the owed initial, resume, change, or series full snapshot, builds `GenerateOptions` from derived messages and that header, and freezes it while leaving `AbortSignal` live. The [request-freeze provenance decision](../simplification/2026-09-06-agent-request-freeze-provenance.md) owns reuse of completed message freezes and per-request local header freezing. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header.
|
||||
Each proposed step first claims its inbox batch, assembles the system prompt and tools, projects the rendered prompt against the surviving `system/message` node, and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start`, commits a changed prompt as the `system/message` append or node-0 replacement, records the final message batch as `user/message` events, and may use `startsRequestSeries: true` to declare a distinct series. `agent/request` may replace only the frozen call-config seed. The loop records the owed initial, resume, change, or series full snapshot, builds `GenerateOptions` from derived messages (system message first) and that header with no `system` field, and freezes it while leaving `AbortSignal` live. The [request-freeze provenance decision](../simplification/2026-09-06-agent-request-freeze-provenance.md) owns reuse of completed message freezes and per-request local header freezing. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header.
|
||||
|
||||
**The open step is the reconstruction boundary.** Its entered `user/message` batch and any newly written `request/header` precede request dispatch. Injection after the atomic claim joins a later request, while a listener that must affect this request returns messages through `agent/pre-step`. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written.
|
||||
|
||||
@@ -49,9 +49,9 @@ Like MiniCode, the conversation advances append-only and resets only when model-
|
||||
|
||||
- A request that is not explained by the log cannot be constructed by accident — not by the loop, not by a listener; mutating a built request throws; every header change is a durable, diffable log event.
|
||||
- Model-visible context uses logged message channels. `agent.inject()` and tool `additionalContexts` enter the inbox for a later claim, while `agent/pre-step` returns context that must settle with the current claimed batch. Each entered value is a durable sourced `user/message`, paid once and prefix-cached thereafter at the price of accumulating in history until compaction.
|
||||
- What still costs full price at the provider is inherent and logged: compaction (its `compaction/*` events and replacement entry), a real prompt, tool, or config change (`request/header` with reason `change`), or a process boundary with drift (a differing `resume` snapshot). The provider's own reasoning-content exclusion is managed server-side.
|
||||
- What still costs full price at the provider is inherent and logged: compaction (its `compaction/*` events and replacement entry), a real prompt change (a `system/message` replacement of surface node 0), a real tool or config change (`request/header` with reason `change`), or a process boundary with drift (a differing `resume` snapshot). The provider's own reasoning-content exclusion is managed server-side.
|
||||
- `agent/pre-step` is the current-request message channel; direct inbox mutation is the eventual later-request channel.
|
||||
- Tool-result trimming needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic.
|
||||
- Tool-result trimming needs no new mechanism: a logged single-entry surface replace (`startSeq === endSeq`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic.
|
||||
- Unreadable referenced attachment objects still fail model requests; [automatic attachment quarantine](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md) records the proposed recovery without weakening byte-exact reconstruction.
|
||||
- Session logs grow one `request/header` snapshot per loop instance, real change, and later model-message series. Repeating the full system prompt and tool catalog is larger than a delta codec but small beside chunk-heavy logs and retains one self-contained replay representation. Current v1 retains this single representation; the frozen v0-to-v1 edge explicitly refuses legacy delta events before current Session construction.
|
||||
- Session logs grow one `request/header` snapshot per loop instance, real change, and later model-message series. Repeating the full tool catalog is larger than a delta codec but small beside chunk-heavy logs and retains one self-contained replay representation. Current logs retain this single representation; the frozen historical edges explicitly refuse legacy delta events before current Session construction.
|
||||
- Snapshot fixtures include each repeated series header. Keyless refresh owns those deterministic log changes, while the snapshot harness pins prompt and tool sidecars only for the initial and actual change revisions and reuses the current revision for `series` snapshots. Filesystem-writing fixtures remain in normalized authored form with cwd-relative tool arguments because replay only round-trips cwd-independent argument paths.
|
||||
@@ -22,9 +22,9 @@ Status: implemented
|
||||
|
||||
**消息。** `Session.deriveMessages()` 带缓存:每个 surface 条目在首次出现时通过公开的逐事件函数 `deriveEventMessage(event)` 精确投影一次;surface 重写(压缩的 `replace`,即 `SurfaceManager.replaceGeneration`)触发重建。调用方每次获得一个新数组,底层是共享的深度冻结消息:通过投影变异已记录的历史是不可表达的(会抛异常),取代了旧的逐次调用克隆隔离。外部重建器对日志前缀折叠同一个公开函数,因此不可能有两条路径产生分歧。
|
||||
|
||||
`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词和工具 schema,空值规范化为缺失。适配器提供的推理强度与 token 默认值会保留其 `adapterDefaults` 来源信息;Web 从日志恢复模型选择时会省略适配器持有的推理强度,因此下一次解析不会把相同的有效配置重新归类为显式选择并产生虚假变更。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`,内容未变的封装显式开启消息序列或跟随表层替换时使用 `series`。如果发生变化的请求同时开启序列,`change` 快照会携带 `startsSeries: true`,无需重复 header 即可保留这两个独立事实。普通的仅追加后续 Turn、同一序列内后续的 Step 与重试沿用最新快照。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。
|
||||
`EpochHeader` 记录请求的非历史状态:调用配置和工具 schema。写入方省略 `tools: []` 与 `adapterDefaults: {}`;当前接纳拒绝这些字段以及任何 `header.system`,而不修复它们。仅含空白的系统消息内容、`config.stop: []` 与嵌套扩展保持原样。[V3 规范信封决策](2026-09-06-v3-canonical-session-envelopes.zh.md)负责历史转换。渲染后的系统提示词是派生历史——surface 第 0 号节点上的 `system/message` 事件,见[surface 节点 Agent Note](2026-09-02-system-prompt-as-surface-node.zh.md)——因此提示词变更是 surface 替换而不是 header 变更。适配器提供的推理强度与 token 默认值会保留其 `adapterDefaults` 来源信息;Web 从日志恢复模型选择时会省略适配器持有的推理强度,因此下一次解析不会把相同的有效配置重新归类为显式选择并产生虚假变更。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`,内容未变的封装显式开启消息序列或跟随表层替换时使用 `series`。如果发生变化的请求同时开启序列,`change` 快照会携带 `startsSeries: true`,无需重复 header 即可保留这两个独立事实。普通的仅追加后续 Turn、同一序列内后续的 Step 与重试沿用最新快照。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。
|
||||
|
||||
每个拟议步骤先领取其 inbox 批次,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,把最终消息批次记录为 `user/message` 事件,并可使用 `startsRequestSeries: true` 声明独立序列。随后步骤组装系统提示词与工具,`agent/request` 只能替换冻结的调用配置种子。循环记录所需的 initial、resume、change 或 series 完整快照,从派生消息与该 header 构建 `GenerateOptions`,冻结请求但保持 `AbortSignal` 活跃。[请求冻结来源证明决策](../simplification/2026-09-06-agent-request-freeze-provenance.zh.md)拥有消息完整冻结的复用规则和每次请求的本地 header 冻结规则。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。
|
||||
每个拟议步骤先领取其 inbox 批次,组装系统提示词与工具,把渲染后的提示词与存活的 `system/message` 节点比对投影,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,把变化的提示词作为 `system/message` 追加或第 0 号节点替换提交,把最终消息批次记录为 `user/message` 事件,并可使用 `startsRequestSeries: true` 声明独立序列。`agent/request` 只能替换冻结的调用配置种子。循环记录所需的 initial、resume、change 或 series 完整快照,从派生消息(系统消息在先)与该不含 `system` 字段的 header 构建 `GenerateOptions`,冻结请求但保持 `AbortSignal` 活跃。[请求冻结来源证明决策](../simplification/2026-09-06-agent-request-freeze-provenance.zh.md)拥有消息完整冻结的复用规则和每次请求的本地 header 冻结规则。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。
|
||||
|
||||
**已打开步骤是重建边界。** 进入步骤的 `user/message` 批次与任何新写入的 `request/header` 都位于请求分派之前。原子领取后发生的注入加入后续请求;必须影响本次请求的监听器则通过 `agent/pre-step` 返回消息。header 重建选择该步骤的 `request/header`,或在无新 header 写入时沿用前一个快照。
|
||||
|
||||
@@ -49,9 +49,9 @@ Status: implemented
|
||||
|
||||
- 一个日志无法解释的请求不可能被意外构造——无论是循环还是监听器;变异已构建的请求会抛异常;每个 header 变更都是持久的、可 diff 的日志事件。
|
||||
- 模型可见上下文使用已记录消息通道。`agent.inject()` 与工具 `additionalContexts` 进入 inbox,等待后续领取;必须与当前已领取批次一起结算的上下文由 `agent/pre-step` 返回。每个进入步骤的值都是带来源的持久 `user/message`,只付出一次代价并在后续成为可缓存前缀,代价是会在历史中累积直至压缩。
|
||||
- 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compaction/*` 事件和替换条目)、真正的提示词、工具或配置变更(reason 为 `change` 的 `request/header`),或带漂移的进程边界(不同的 `resume` 快照)。提供方自身的 reasoning-content 排除由服务端管理。
|
||||
- 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compaction/*` 事件和替换条目)、真正的提示词变更(对 surface 第 0 号节点的 `system/message` 替换)、真正的工具或配置变更(reason 为 `change` 的 `request/header`),或带漂移的进程边界(不同的 `resume` 快照)。提供方自身的 reasoning-content 排除由服务端管理。
|
||||
- `agent/pre-step` 是当前请求的消息通道;直接修改 inbox 则是最终进入后续请求的通道。
|
||||
- 工具结果裁剪无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存失效由相同的压力逻辑批量处理。
|
||||
- 工具结果裁剪无需新机制:一个已记录的单条目 surface replace(`startSeq === endSeq`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存失效由相同的压力逻辑批量处理。
|
||||
- 无法读取的被引用附件对象仍会让模型请求失败;[附件自动隔离](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md)记录了不削弱字节精确重建的拟议恢复方案。
|
||||
- 会话日志会为每个循环实例、真实变更和后续模型消息序列增加一个 `request/header` 快照。重复完整系统提示词与工具目录比 delta 编解码器更大,但相对分片密集型日志仍然很小,并保留一种自包含的回放表示。当前 v1 保留这一种表示;冻结的 v0-to-v1 迁移边会在构造当前 Session 前显式拒绝旧版 delta 事件。
|
||||
- 会话日志会为每个循环实例、真实变更和后续模型消息序列增加一个 `request/header` 快照。重复完整工具目录比 delta 编解码器更大,但相对分片密集型日志仍然很小,并保留一种自包含的回放表示。当前日志保留这一种表示;冻结的历史迁移边会在构造当前 Session 前显式拒绝旧版 delta 事件。
|
||||
- 快照 fixture 包含每个重复的 series header。无密钥 refresh 负责这些确定性日志变化;快照 harness 只为 initial 与真实 change 修订固定提示词和工具 sidecar,并让 `series` 快照复用当前修订。写入文件系统的 fixture 继续以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md
|
||||
2026-07-08-agent-scope-contexts.md: 6a1fd4aed49cb8edef061c8fb6f0edcd0a09c30f
|
||||
2026-07-08-agent-scope-contexts.zh.md: 8408c4afff6075c129c6a96c47393c9c812b04b7
|
||||
2026-07-08-agent-scope-contexts.md: 45e635b7bc3138d4e90a25a06ff23b3b57a9415e
|
||||
2026-07-08-agent-scope-contexts.zh.md: aac860734e744843c3b9e7d55e5bc7a150763090
|
||||
@@ -16,6 +16,8 @@ The mechanism also needs a publication boundary. An agent must not become visibl
|
||||
|
||||
Every live agent owns one flat registration layer exposed as `agent.ctx`. Code registers through the context that owns a contribution; scope-aware services combine deployment-global registrations with exactly one matching agent layer; operations choose that layer from their real agent; and the layer exists for the agent's complete published lifetime.
|
||||
|
||||
`agent.ctx` carries registration ownership and the scope key; it does not expose a reverse `agent` property. Code that needs the domain subject receives it explicitly: `AgentSetup` receives `(agentCtx, agent)`, and scoped events carry their subject in the payload.
|
||||
|
||||
Cordis is the plugin framework underneath the SDK. A Cordis **context** is the object plugins use to access services and register effects whose cleanup follows that context. The [Cordis primer](../../../../docs/cordis-primer.md) explains the framework in more detail.
|
||||
|
||||
For most contributors, the complete contract is four rules:
|
||||
@@ -45,7 +47,7 @@ flowchart LR
|
||||
|
||||
The missing cross-edges are the isolation rule: Agent A's local registrations do not enter Agent B's view, and a parent's registrations do not enter a child merely because the parent owns the child's lifetime.
|
||||
|
||||
The companion [runtime-design Agent Note](2026-07-12-agent-scope-runtime-design.md) explains the implementation and correctness reasoning. The [subagent composition-controls Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns the separate `persona`, `toolFilter`, and `maxDepth` feature.
|
||||
The companion [runtime-design Agent Note](2026-07-12-agent-scope-runtime-design.md) explains the implementation and correctness reasoning. The [explicit runtime-identity Agent Note](2026-08-31-explicit-agent-runtime-identity.md) owns why lifecycle, event, and transport interfaces pass Agent identity instead of exposing it through Context. The [subagent composition-controls Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns the separate `persona`, `toolFilter`, and `maxDepth` feature.
|
||||
|
||||
### Registration origin chooses visibility and cleanup
|
||||
|
||||
@@ -88,7 +90,7 @@ await handle.dispose()
|
||||
ctx.tools.get('review_summary', handle.agent) // undefined: scope is gone
|
||||
```
|
||||
|
||||
Setup receives a full trusted Cordis context so it can compose ordinary plugins and services. Its contract is composition-only: driving or publishing the in-flight agent through casts or internal registry calls is unsupported.
|
||||
Setup receives the full trusted Cordis context and unpublished Agent so it can compose ordinary plugins and services while reading the exact child Session when needed. Its contract is composition-only: driving or publishing the in-flight agent through casts or internal registry calls is unsupported.
|
||||
|
||||
### The operation chooses the view
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ Status: implemented
|
||||
|
||||
每个存活的 agent 拥有一个扁平的注册层,通过 `agent.ctx` 暴露。代码通过拥有某项贡献的上下文进行注册;具备作用域感知的服务将部署全局注册与恰好一个匹配的 agent 层合并;操作从其真实 agent 选择该层;该层在 agent 的完整发布生命周期内存在。
|
||||
|
||||
`agent.ctx` 携带注册所有权和作用域键,不暴露反向的 `agent` 属性。需要领域主体的代码会显式接收它:`AgentSetup` 接收 `(agentCtx, agent)`,作用域事件则在 payload 中携带主体。
|
||||
|
||||
Cordis 是 SDK 底层的插件框架。Cordis **上下文**是插件用来访问服务和注册效果的对象,效果的清理跟随该上下文。[Cordis 入门](../../../../docs/cordis-primer.zh.md)对该框架有更详细的说明。
|
||||
|
||||
对大多数贡献者而言,完整约定是四条规则:
|
||||
@@ -45,7 +47,7 @@ flowchart LR
|
||||
|
||||
缺失的交叉边即隔离规则:Agent A 的本地注册不会进入 Agent B 的视图,父级的注册也不会仅因父级拥有子级的生命周期就进入子级。
|
||||
|
||||
配套的[运行时设计 Agent Note](2026-07-12-agent-scope-runtime-design.zh.md) 阐述了实现与正确性推理。[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md) 负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。
|
||||
配套的[运行时设计 Agent Note](2026-07-12-agent-scope-runtime-design.zh.md)阐述实现与正确性推理。[显式运行时身份 Agent Note](2026-08-31-explicit-agent-runtime-identity.zh.md)说明生命周期、事件和传输接口为何显式传递 Agent 身份,而不通过 Context 暴露该身份。[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md)负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。
|
||||
|
||||
### 注册来源决定可见性与清理
|
||||
|
||||
@@ -88,7 +90,7 @@ await handle.dispose()
|
||||
ctx.tools.get('review_summary', handle.agent) // undefined: scope is gone
|
||||
```
|
||||
|
||||
setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插件和服务。其约定仅限组合:不支持通过 cast 或内部注册表调用来驱动或发布正在构建中的 agent。
|
||||
setup 接收完整的受信 Cordis 上下文和未发布的 Agent,因此既可以组合普通插件和服务,也能在需要时读取确切的子 Session。其约定仅限组合:不支持通过 cast 或内部注册表调用来驱动或发布正在构建中的 agent。
|
||||
|
||||
### 操作选择视图
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 5533c38d635d04c8799658fc1f6bcec8543d1dab
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 05800082034f0d1bd5034fc5bb17f09356d84dd9
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 7792e24a5869be6b7bae7787a6a481f3075ba740
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 857bfaec80da962fac0403f2239e0a5e71954194
|
||||
+1
-1
@@ -66,7 +66,7 @@ The Python client launches the packaged `dsh` command with the selected profile
|
||||
|
||||
## Testing
|
||||
|
||||
The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build installs both wheels into a clean venv outside the checkout, proves matching versions and installed module/executable locations, then completes turns against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The spawn-node scenario drives the platform shell tool through a command starting with `node` and requires the machine's own Node version in the tool result with no `PKG_EXECPATH` in the child environment, pinning the packaged runtime against a pkg upgrade that re-records the child-process patch. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. The same installed run compares a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. Trusted pull requests add a real-provider two-turn file write/read whose external bytes, tool calls, completed reasons, and persisted log must agree. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ.
|
||||
The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build installs both wheels into a clean venv outside the checkout, proves matching versions and installed module/executable locations, then completes turns against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and single shell-tool catalog and retains shell state across calls. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The spawn-node scenario drives the platform shell tool through a command starting with `node` and requires the machine's own Node version in the tool result with no `PKG_EXECPATH` in the child environment, pinning the packaged runtime against a pkg upgrade that re-records the child-process patch. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. The same installed run compares a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. Trusted pull requests add a real-provider two-turn file write/read whose external bytes, tool calls, completed reasons, and persisted log must agree. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ.
|
||||
|
||||
Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends.
|
||||
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ exe 内支持 `dsh-workflow-worker-thread` 与 `dsh-code-runtime-worker-thread`
|
||||
|
||||
## 测试
|
||||
|
||||
验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都会把两个 wheel 包安装进 checkout 外的干净 venv,证明版本相同以及已安装模块/可执行文件的位置,再通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob` 与 `grep`。spawn-node 场景驱动平台 shell 工具执行以 `node` 开头的命令,要求工具结果给出机器自身的 Node 版本且子进程环境中无 `PKG_EXECPATH`,把打包运行时钉死在「pkg 升级重录 child_process 补丁也不得回归」的行为上。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一项安装后运行还会经 Python SDK 比较一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。可信拉取请求会增加真实提供方双轮文件写入/读取,并要求外部字节、工具调用、已完成原因与持久化日志一致。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。
|
||||
验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都会把两个 wheel 包安装进 checkout 外的干净 venv,证明版本相同以及已安装模块/可执行文件的位置,再通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与单个 shell 工具目录,并跨调用保留 shell 状态。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob` 与 `grep`。spawn-node 场景驱动平台 shell 工具执行以 `node` 开头的命令,要求工具结果给出机器自身的 Node 版本且子进程环境中无 `PKG_EXECPATH`,把打包运行时钉死在「pkg 升级重录 child_process 补丁也不得回归」的行为上。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一项安装后运行还会经 Python SDK 比较一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。可信拉取请求会增加真实提供方双轮文件写入/读取,并要求外部字节、工具调用、已完成原因与持久化日志一致。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。
|
||||
|
||||
|
||||
手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。
|
||||
|
||||
+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-12-agent-scope-runtime-design.md
|
||||
2026-07-12-agent-scope-runtime-design.md: b6001a5ef9f2dc69ec21908f8350b765dd00acf1
|
||||
2026-07-12-agent-scope-runtime-design.zh.md: be12c53ffa9b89e007888935002a5c484c038fd7
|
||||
2026-07-12-agent-scope-runtime-design.md: ca300d4eeeab878a4e41b8e68a669be418617181
|
||||
2026-07-12-agent-scope-runtime-design.zh.md: 870690d6ace9fefd859557a2e73e88b9b1da6206
|
||||
@@ -42,6 +42,8 @@ All agents share one Cordis service graph. A derived context does not clone `Too
|
||||
|
||||
`agent.ctx` is such a derived context. Service calls still reach the shared instances, while a registration can inspect its calling context and store a contribution under the nearest scope key. Ordinary plugin contexts carry no scope key and therefore register globally.
|
||||
|
||||
The Agent context is exactly the context returned by `createScope`; it carries no second reverse association to the Agent. Subject-bearing APIs pass the Agent explicitly, leaving one formal scope mechanism for registration ownership and routing.
|
||||
|
||||
### Fibers and effects make cleanup structural
|
||||
|
||||
A Cordis fiber is the live instance created when a plugin or child context is activated. Its state records whether that lifecycle is active, unloading, failed, or disposed. `ctx.effect()` and `ctx.on()` return disposers and also attach those disposers to the registering fiber, so unloading a plugin or agent scope removes everything registered through that context without a separate inventory.
|
||||
@@ -68,7 +70,7 @@ A `ScopeKey` is an opaque object compared by identity. The harness uses the live
|
||||
|
||||
`createScope(parent, key)` returns a scope whose `ctx` shares the parent's services and whose effects are tagged with that key. `scopeOf(ctx)` reads the nearest registration key. `scopeTarget(base, key)` creates the event receiver whose filter preserves the base receiver's Cordis service filter, then admits unscoped listeners and listeners with that exact key.
|
||||
|
||||
The receiver is a small carrier rather than a transparent proxy for the domain object. Code that needs the agent receives the explicit event argument; code that needs registration ownership receives `agent.ctx`.
|
||||
The receiver is a small carrier rather than a transparent proxy for the domain object. Code that needs the agent receives an explicit setup parameter or event argument; code that needs registration ownership receives `agent.ctx`.
|
||||
|
||||
### Registry reads overlay one exact layer
|
||||
|
||||
@@ -100,11 +102,11 @@ The transaction is installed under both the calling Cordis context and the concr
|
||||
|
||||
Create prepares a new Session. Resume loads and validates the persisted Session before preparing the same live session identity. Both paths then build the scope, agent, and driver and invoke the same setup/publication algorithm.
|
||||
|
||||
The factory stores concrete trace targets but invokes them through a caller-bound Cordis trace. This preserves dependency origin and caller ownership without stacking trace proxies.
|
||||
The factory stores concrete trace targets but invokes them through a caller-bound Cordis trace. A runtime child creator sets `parentAgent` in the create or resume options, and AgentRegistry forwards those options without deriving a parent from the caller Context. This preserves dependency origin and both ownership facts without stacking trace proxies or attaching a domain object to the Context. Scoped Remote event adapters likewise receive the Agent in the request, verify that it is the carrier key, and project its Context and wire identity directly. No scope index reconstructs an Agent from a Context. The [explicit runtime-identity decision](2026-08-31-explicit-agent-runtime-identity.md) owns this separation and the continuable-child ownership rule that follows from it.
|
||||
|
||||
### Setup is trusted composition inside a private world
|
||||
|
||||
Setup receives the full child context and may await plugin activation. It can register tools, prompt sections, restrictions, listeners, and other effects, but the public contract does not support driving or publishing the in-flight agent through casts or internal registry calls.
|
||||
Setup receives the full child context and the exact unpublished Agent, and may await plugin activation. It can register tools, prompt sections, restrictions, listeners, and other effects, and consumers that need the child's Session read it from the Agent parameter. The public contract does not support driving or publishing the in-flight agent through casts or internal registry calls.
|
||||
|
||||
The transaction races asynchronous load and setup against deactivation rather than waiting forever for a promise owned by external code. If cancellation or owner unload wins, public creation rejects after transaction-owned cleanup even when the external promise never settles.
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ Status: implemented
|
||||
|
||||
`agent.ctx` 就是这样一个派生上下文。服务调用仍然到达共享实例,而注册操作可以检查其调用上下文并将贡献存储在最近的作用域键下。普通的插件上下文不携带作用域键,因此注册到全局。
|
||||
|
||||
Agent 上下文就是 `createScope` 返回的上下文,不携带第二份指回 Agent 的关联。需要主体的 API 显式传递 Agent,因此注册所有权与路由只依赖一种正式的作用域机制。
|
||||
|
||||
### Fiber 与 effect 使清理成为结构性的
|
||||
|
||||
Cordis fiber 是插件或子上下文被激活时创建的活跃实例。其状态记录该生命周期是 active、unloading、failed 还是 disposed。`ctx.effect()` 和 `ctx.on()` 返回 disposer,同时将这些 disposer 附加到注册所在的 fiber,因此卸载一个插件或 agent 作用域会移除通过该上下文注册的一切,无需单独的清单。
|
||||
@@ -70,7 +72,7 @@ scope 包实现了 Cordis 路由所需的最小对象。其载体仅持有一个
|
||||
|
||||
`createScope(parent, key)` 返回一个作用域,其 `ctx` 共享父级的服务,其 effect 被标记为该键。`scopeOf(ctx)` 读取最近的注册键。`scopeTarget(base, key)` 创建事件接收器,其过滤器保留 base receiver 的 Cordis 服务过滤器,然后接纳无作用域的监听器和具有该确切键的监听器。
|
||||
|
||||
Receiver 是一个小型载体而非领域对象的透明代理。需要 agent 的代码接收显式的事件参数;需要注册所有权的代码接收 `agent.ctx`。
|
||||
Receiver 是一个小型载体而非领域对象的透明代理。需要 agent 的代码接收显式的 setup 参数或事件参数;需要注册所有权的代码接收 `agent.ctx`。
|
||||
|
||||
### 注册表读取叠加一个精确 layer
|
||||
|
||||
@@ -102,11 +104,11 @@ detach 闭包捕获其确切注册表条目。它仅在映射仍指向该注册
|
||||
|
||||
创建准备一个新 Session。恢复加载并验证持久化的 Session,然后准备相同的活跃会话标识。两条路径随后构建作用域、agent 和 driver,并调用相同的 setup/发布算法。
|
||||
|
||||
工厂存储具体的 trace 目标,但通过调用方绑定的 Cordis trace 调用它们。这保留了依赖来源和调用方所有权,而不堆叠 trace 代理。
|
||||
工厂存储具体的 trace 目标,但通过调用方绑定的 Cordis trace 调用它们。运行时子 Agent 的创建方在 create 或 resume options 中设置 `parentAgent`,AgentRegistry 转交这些 options,不从调用方 Context 推导父级。这既保留了依赖来源和两种所有权事实,又不堆叠 trace 代理,也不把领域对象附着到 Context。作用域 Remote 事件适配器同样从 request 接收 Agent,校验它就是 carrier key,再直接投影其 Context 与 wire identity。系统不会通过作用域索引从 Context 重建 Agent。[显式运行时身份决策](2026-08-31-explicit-agent-runtime-identity.zh.md)拥有这项分离原则及由此确定的可续跑子级归属规则。
|
||||
|
||||
### Setup 是私有世界内的可信组合
|
||||
|
||||
Setup 接收完整的子上下文,可以等待插件激活。它可以注册工具、提示词段、限制、监听器和其他 effect,但公开约定不支持通过强制转换或内部注册表调用来驱动或发布正在创建中的 agent。
|
||||
Setup 接收完整的子上下文和确切的未发布 Agent,可以等待插件激活。它可以注册工具、提示词段、限制、监听器和其他 effect;需要子 Session 的消费者从 Agent 参数读取它。公开约定不支持通过强制转换或内部注册表调用来驱动或发布正在创建中的 agent。
|
||||
|
||||
事务将异步加载和 setup 与停用进行竞争,而非无限等待外部代码拥有的 promise。如果取消或所有者卸载获胜,即使外部 promise 永不结算,公开创建也会在事务拥有的清理之后拒绝。
|
||||
|
||||
|
||||
+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-14-provider-routed-llm-adapters.md
|
||||
2026-07-14-provider-routed-llm-adapters.md: 4cc3cc3cdceeefdfea864ccf3465e528e32159c7
|
||||
2026-07-14-provider-routed-llm-adapters.zh.md: 098a6af7c951c6dee8a1b4f948bc69725e8d189c
|
||||
2026-07-14-provider-routed-llm-adapters.md: 1001b10e18837399b41578658ffe62065e294ba8
|
||||
2026-07-14-provider-routed-llm-adapters.zh.md: c5bff0ef407c8a2c22e8cad7e7d32c58c8b0e2da
|
||||
@@ -30,7 +30,7 @@ A provider has exactly one adapter owner in a Cordis context. `dsh-llm-deepseek`
|
||||
|
||||
`dsh-llm-pi-ai` takes one non-empty list of provider profiles. Provider names must be unique within the list and present in pi-ai's `getProviders()` result. Each profile contains the provider name plus optional `apiKey`, `baseURL`, headers, reasoning level and budgets, cache retention, transport, SDK timeouts, a Harness stream-idle timeout, and a provider-owned `retryPolicy`. The adapter forces pi-ai's `maxRetries` to zero so one `stream()` call makes one visible provider attempt, while `dsh-llm-retry` executes the resolved policy at the agent failed-step extension point. Credentials are never global: an explicit key applies only to its profile, while an absent key lets pi-ai resolve its standard environment variable, OAuth token, AWS credential chain, Google ADC, or other provider-native ambient authentication. An explicitly empty key is invalid configuration rather than an environment fallback.
|
||||
|
||||
The plugin registers all configured provider names against one `PiAiAdapter` in one all-or-nothing call. A request uses its provider to select the matching profile and finds its model in `getModels(provider)` to obtain the catalog descriptor. An unknown provider fails at plugin load; an unknown model fails before network I/O with `UNKNOWN_MODEL`. The catalog object is never mutated. When a profile supplies `baseURL`, the adapter clones the selected descriptor and overrides only `baseUrl`, so a private endpoint can retain pi-ai's API, capabilities, compatibility flags, context limits, and reasoning map. The private endpoint must implement the selected provider's protocol, and the model id must still exist in the installed pi-ai catalog.
|
||||
The plugin registers configured provider names against one `PiAiAdapter` in one atomic call. Each immutable request snapshot combines the effective profiles and their serviceable model descriptors. Catalog-external models require an explicit or inferable protocol and endpoint. Stored catalog errors remain visible and repairable under the [settings catalog recovery decision](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md), while writes validate changed providers and requests reject the selected failed model before network I/O.
|
||||
|
||||
The adapter calls pi-ai's `streamSimple()` so each catalog model chooses its registered API implementation, including OpenAI Responses instead of Chat Completions where the descriptor says `openai-responses`. Harness temperature, maximum tokens, signal, session id, and the profile's common stream options flow through directly. Profile headers merge with the mandatory Harness attribution headers, with Harness attribution winning its reserved names. The adapter no longer maintains DeepSeek-specific payload rewrites or a provider-protocol matrix.
|
||||
|
||||
@@ -54,7 +54,7 @@ Compaction configuration gains `summarizationProvider` beside `summarizationMode
|
||||
|
||||
The JSON-RPC runtime receives provider and model explicitly. Its convenience fallback mounts `dsh-llm-deepseek` only for provider `deepseek` when that provider has no registered owner; other missing providers fail without guessing an adapter.
|
||||
|
||||
Current v1 seed/load validation rejects request headers and assistant messages that omit required provider/model fields. The frozen v0-to-v1 edge requires the same reconstructable routing identity before migration; it never guesses a missing provider or model, and malformed shapes refuse before publication.
|
||||
Current seed/load validation rejects request headers and assistant messages that omit required provider/model fields. The frozen v0-to-v1 edge requires the same reconstructable routing identity before migration; it never guesses a missing provider or model, and malformed shapes refuse before publication.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -78,7 +78,7 @@ Current v1 seed/load validation rejects request headers and assistant messages t
|
||||
- pi-ai credentials, transport knobs, SDK timeouts, and the five-minute-default `streamIdleTimeoutMs` watchdog are scoped per provider profile. Hidden provider retries are disabled; bounded retries belong to the separately composed agent recovery policy.
|
||||
- `dsh-llm-pi-ai` rejects stop sequences because pi-ai's common stream API cannot express them; the native DeepSeek adapter retains its stop support.
|
||||
- Replay state is portable only within the adapter instance that owns both the historical and target providers. Cross-provider and cross-model restoration is an adapter responsibility, and another adapter receives provider-neutral history without the opaque state.
|
||||
- Current v1 Session JSONL requires provider/model on request headers and assistant messages. The v0 edge migrates only frozen shapes that already carry reconstructable request identity.
|
||||
- Current Session JSONL requires provider/model on request headers and assistant messages. The v0 edge migrates only frozen shapes that already carry reconstructable request identity.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
+3
-3
@@ -30,7 +30,7 @@ Status: implemented
|
||||
|
||||
`dsh-llm-pi-ai` 接受一个非空的提供方配置列表。列表内的提供方名称必须唯一,并且存在于 pi-ai 的 `getProviders()` 结果中。每项配置包含提供方名称,以及可选的 `apiKey`、`baseURL`、headers、推理级别和预算、缓存保留设置、传输方式、SDK 超时、Harness 流空闲超时,以及由提供方拥有的 `retryPolicy`。适配器强制将 pi-ai 的 `maxRetries` 设为零,使一次 `stream()` 调用只发起一次可见的提供方请求;`dsh-llm-retry` 则在 agent 失败步骤扩展点上执行解析后的策略。凭据不设全局值:显式密钥仅对所属配置生效;未提供密钥时,pi-ai 使用标准环境变量、OAuth token、AWS 凭据链、Google ADC 或其他提供方原生环境认证。显式空密钥属于无效配置,不会回退到环境认证。
|
||||
|
||||
插件通过一次全有或全无调用,将所有已配置的提供方名称注册到同一个 `PiAiAdapter`。请求按 provider 选择对应配置,并在 `getModels(provider)` 中查找模型以取得目录描述符。未知提供方会在插件加载时失败;未知模型会在网络 I/O 前以 `UNKNOWN_MODEL` 失败。适配器不会修改目录对象。当配置提供 `baseURL` 时,适配器复制选中的描述符,仅覆盖 `baseUrl`,使私有端点保留 pi-ai 的 API、能力、兼容标志、上下文限制与推理映射。私有端点必须实现所选提供方的协议,模型 ID 也仍须存在于已安装的 pi-ai 目录中。
|
||||
插件通过一次原子调用,将已配置的提供方名称注册到同一个 `PiAiAdapter`。每个不可变请求快照组合有效 profile 与可服务模型的描述符。目录外模型需要显式指定或可推断的协议与端点。根据[设置目录恢复决策](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md),已存储的目录错误保持可见、可修复,写入仍会校验已修改提供方,请求则在网络 I/O 前拒绝所选错误模型。
|
||||
|
||||
适配器调用 pi-ai 的 `streamSimple()`,因此每个目录模型会选择其注册的 API 实现;描述符为 `openai-responses` 时使用 OpenAI Responses,而非 Chat Completions。Harness 的 temperature、最大 token 数、signal、session ID,以及提供方配置中的通用流选项均直接传递。配置 headers 与 Harness 强制归因 headers 合并;发生保留名称冲突时,以 Harness 归因为准。适配器不再维护 DeepSeek 专用 payload 重写或提供方协议矩阵。
|
||||
|
||||
@@ -54,7 +54,7 @@ pi-ai 回放状态用其成功 `AssistantMessage` 的带版本最小投影填充
|
||||
|
||||
JSON-RPC 运行时显式接收提供方与模型。仅当 `deepseek` 提供方没有注册所有者时,其便利回退才会挂载 `dsh-llm-deepseek`;其他缺失的提供方会直接失败,不会猜测适配器。
|
||||
|
||||
当前 v1 的 seed/load 验证会拒绝省略必需提供方/模型字段的请求头和助手消息。冻结的 v0-to-v1 迁移边要求迁移前已具备同一套可重建路由身份;它绝不会猜测缺失的提供方或模型,畸形结构会在发布前被拒绝。
|
||||
当前的 seed/load 验证会拒绝省略必需提供方/模型字段的请求头和助手消息。冻结的 v0-to-v1 迁移边要求迁移前已具备同一套可重建路由身份;它绝不会猜测缺失的提供方或模型,畸形结构会在发布前被拒绝。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -78,7 +78,7 @@ JSON-RPC 运行时显式接收提供方与模型。仅当 `deepseek` 提供方
|
||||
- pi-ai 凭据、传输选项、SDK 超时,以及默认五分钟的 `streamIdleTimeoutMs` 空闲超时机制均按提供方配置隔离。系统禁用隐藏的提供方重试;有界重试由单独组合的 agent 恢复策略负责。
|
||||
- pi-ai 的通用流 API 无法表达停止序列,因此 `dsh-llm-pi-ai` 会拒绝停止序列;原生 DeepSeek 适配器仍支持停止序列。
|
||||
- 仅当历史提供方与目标提供方归同一个适配器实例所有时,回放状态才可移植。适配器负责跨提供方和跨模型恢复;其他适配器只接收不含不透明状态的提供方无关历史。
|
||||
- 当前 v1 Session JSONL 要求请求头和助手消息都包含提供方/模型。v0 边只迁移已经携带可重建请求身份的冻结结构。
|
||||
- 当前 Session JSONL 要求请求头和助手消息都包含提供方/模型。v0 边只迁移已经携带可重建请求身份的冻结结构。
|
||||
|
||||
## 测试
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md
|
||||
2026-07-15-agent-initiator-scope.md: 63540c0ec6b29a10613e01f1ed9ced24e8f2d277
|
||||
2026-07-15-agent-initiator-scope.zh.md: 3ea893aa5f6992bf09965436c1db3144d2fae5ac
|
||||
2026-07-15-agent-initiator-scope.md: ab11da116a463cd706418e797eb58f1bc4ab9b1c
|
||||
2026-07-15-agent-initiator-scope.zh.md: 343acba5f99379b6d2c3af41368e8fbe90b20611
|
||||
@@ -6,7 +6,7 @@ English | [中文](2026-07-15-agent-initiator-scope.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The harness has two useful but different notions of context. A Cordis `Context` selects services, registration ownership, and lifetime; `agent.ctx` is the flat registration scope owned by one live Agent. Agent and Session identity instead describe the subject of an asynchronous operation. Changing a root `ctx.agent` to mean “whichever Agent is running” would conflate those meanings and fail when one process drives Agents concurrently.
|
||||
The harness has two useful but different notions of context. A Cordis `Context` selects services, registration ownership, and lifetime; `agent.ctx` is the flat registration scope owned by one live Agent. Agent and Session identity instead describe the subject of an asynchronous operation. A dynamic `ctx.agent` meaning “whichever Agent is running” would conflate those meanings and fail when one process drives Agents concurrently.
|
||||
|
||||
Deep process-local infrastructure sometimes needs a trusted initiating Agent below explicit loop, tool, and request parameters—for example, a host-aware transport, tracing helper, logger, or gateway client. Requiring every private helper to forward `agent` adds repetition, while a process-global mutable slot is incorrect across `await`. Model-visible arguments are unsuitable because a model must not choose a trusted Session or routing header. The carrier belongs to the Agent service rather than optional model-visible context.
|
||||
|
||||
@@ -18,9 +18,9 @@ The mandatory `ctx.agents` service uses Node `AsyncLocalStorage` to carry the in
|
||||
|
||||
`AgentLoop` already injects `ctx.agents` and wraps each concrete driver's complete `runLoop` lifetime in `agents.withInitiator(agent, ...)`. Its package-private loop, turn, step, and tool-call orchestration entries recover the exact Agent from `ctx.agents`, derive `agent.session` once, and let operation-local helpers capture it instead of forwarding the concrete driver or `Session` through shallow interfaces. A leaf helper keeps a narrow `Session` parameter when that is its actual interface rather than accepting a broader `Context` only for an ambient lookup.
|
||||
|
||||
Concurrent drivers receive independent stores. A child driver's continuations carry the child, while the caller resumes in its prior store as soon as `withInitiator()` returns; active-run tracking keeps the returned Promise in the teardown drain until it settles. Creation, persistence load, and unpublished `setup(agentCtx)` remain outside the child's driver boundary: creation initiated by a parent runs under the parent identity, while `agentCtx.agent` explicitly identifies the child.
|
||||
Concurrent drivers receive independent stores. A child driver's continuations carry the child, while the caller resumes in its prior store as soon as `withInitiator()` returns; active-run tracking keeps the returned Promise in the teardown drain until it settles. Creation, persistence load, and unpublished `setup(agentCtx, childAgent)` remain outside the child's driver boundary: creation initiated by a parent runs under the parent identity, while the explicit `childAgent` parameter identifies the child.
|
||||
|
||||
Ambient identity does not replace explicit contracts. `ToolExecution.agent`, `AssembleContext.agent`, `GenerateOptions.sessionId`, job ownership, parent/child requests, `ctx.agent`, `agentCtx.agent`, approval and hook subjects, `cwd` selection, cancellation, worker/process messages, persistence records, and wire identity remain explicit. A remote boundary materializes the identity it needs into its typed request because ALS is process-local.
|
||||
Ambient identity does not replace explicit contracts. `ToolExecution.agent`, `AssembleContext.agent`, the Agent parameter of `AgentSetup`, `GenerateOptions.sessionId`, job ownership, parent/child requests, approval and hook subjects, `cwd` selection, cancellation, worker/process messages, persistence records, and wire identity remain explicit. A remote boundary materializes the identity it needs into its typed request because ALS is process-local.
|
||||
|
||||
`AgentRegistry` owns an ordered initiator lifecycle. Teardown first rejects new boundaries; removing `ctx.agents` then drains injected dependents such as AgentLoop, and the registry waits for active returned-Promise boundaries before calling `AsyncLocalStorage.disable()`. If a boundary's inherited async chain starts an owning Cordis fiber's unload, the private run-token lineage releases that nested boundary chain from the drain, which prevents teardown from waiting on itself while unrelated boundaries still drain. `currentInitiator()` and `requireInitiator()` remain usable through a retained in-flight service reference while the ordinary drain runs; after disposal, initiator methods throw `agent initiator scope is disposed`. Root Context disposal may start sibling fiber teardown concurrently, so active-boundary counting remains necessary in addition to Cordis dependency ordering.
|
||||
|
||||
@@ -28,7 +28,7 @@ Initiator scope does not own detached work: registry drain tracks only the Promi
|
||||
|
||||
A host-aware transport may derive a deployment-owned header such as `X-Harness-Session-Id` from `ctx.agents.requireInitiator().session.id`; the header is absent from model-visible schema and arguments. No production MCP or Web transport adopts such a header in this decision. A test-double transport proves the trusted boundary without assigning host routing policy to an existing provider-neutral seam.
|
||||
|
||||
This decision extends the [Agent registration-scope contract](2026-07-08-agent-scope-contexts.md) and its [runtime design](2026-07-12-agent-scope-runtime-design.md); it does not change their static `agent.ctx` meaning.
|
||||
This decision extends the [Agent registration-scope contract](2026-07-08-agent-scope-contexts.md) and its [runtime design](2026-07-12-agent-scope-runtime-design.md); it does not change their static `agent.ctx` meaning. The [explicit runtime-identity decision](2026-08-31-explicit-agent-runtime-identity.md) keeps initiator scope limited to private asynchronous chains while lifecycle, ownership, event, and wire interfaces carry their subjects directly.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -40,7 +40,7 @@ A test-double host-aware transport derives `X-Harness-Session-Id` internally and
|
||||
|
||||
**Pass Agent through every function.** Public, worker, process, persistence, and wire boundaries continue to do this, but requiring every process-local private helper to carry Agent adds repetitive forwarding without improving trust. ALS is confined to the asynchronous chain inside those explicit boundaries.
|
||||
|
||||
**Make `ctx.agent` dynamic.** `ctx.agent` already means the static Agent associated with an Agent-scoped Cordis context. Changing the root meaning would mix registration and execution scopes and make concurrent behavior surprising.
|
||||
**Expose a dynamic `ctx.agent`.** Context carries registration ownership, not a domain subject. Adding an accessor for the executing Agent would mix registration and execution scopes and make concurrent behavior surprising.
|
||||
|
||||
**Add a separate `ctx.agentExecution` service.** The carrier has no independent backend, configuration, or identity type: it stores the same `Agent` that `ctx.agents` already owns, and AgentLoop already depends on that service. A second mandatory provider would add package, composition, lifecycle, generated-catalog, and test-harness wiring without separating a real capability.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负责选择服务、注册归属和生命周期;`agent.ctx` 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若把根 `ctx.agent` 改成「当前正在运行的 Agent」,就会混淆这两种含义,并在单进程并发驱动多个 Agent 时失效。
|
||||
harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负责选择服务、注册归属和生命周期;`agent.ctx` 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若提供表示「当前正在运行的 Agent」的动态 `ctx.agent`,就会混淆这两种含义,并在单进程并发驱动多个 Agent 时失效。
|
||||
|
||||
进程内深层基础设施有时需要在显式传递的循环、工具及请求参数之下获取可信的发起 Agent,例如宿主感知传输层、追踪辅助函数、日志器或网关客户端。要求每个私有辅助函数都转发 `agent` 会造成重复,而进程级可变槽会在跨 `await` 时发生并发错误。模型可见参数也不适用,因为模型不得选择可信的会话或路由请求头。该载体归 Agent 服务所有,而非模型可见的可选上下文。
|
||||
|
||||
@@ -18,9 +18,9 @@ harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负
|
||||
|
||||
`AgentLoop` 已经注入 `ctx.agents`,并用 `agents.withInitiator(agent, ...)` 包裹每个具体驱动的完整 `runLoop` 生命周期。循环、轮次、步骤和工具调用的包内私有入口从 `ctx.agents` 恢复同一个 Agent,一次推导 `agent.session`,再由操作内辅助函数捕获该值,避免在浅层接口中转发具体驱动或 `Session`。若 `Session` 本身就是底层辅助函数的实际接口,该函数会保留狭窄的 `Session` 参数,而不会只为隐式查找而接收更宽泛的 `Context`。
|
||||
|
||||
因此,并发驱动使用彼此独立的存储。子驱动的异步延续携带子 Agent;`withInitiator()` 返回后,调用方立即恢复之前的存储,而活动运行计数仍持续跟踪返回的 Promise,直到其结束。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。
|
||||
因此,并发驱动使用彼此独立的存储。子驱动的异步延续携带子 Agent;`withInitiator()` 返回后,调用方立即恢复之前的存储,而活动运行计数仍持续跟踪返回的 Promise,直到其结束。创建、持久化加载和尚未发布的 `setup(agentCtx, childAgent)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而显式的 `childAgent` 参数标识子 Agent。
|
||||
|
||||
隐式身份不会取代显式约定。`ToolExecution.agent`、`AssembleContext.agent`、`GenerateOptions.sessionId`、任务归属、父子请求、`ctx.agent`、`agentCtx.agent`、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。
|
||||
隐式身份不会取代显式约定。`ToolExecution.agent`、`AssembleContext.agent`、`AgentSetup` 的 Agent 参数、`GenerateOptions.sessionId`、任务归属、父子请求、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。
|
||||
|
||||
`AgentRegistry` 管理一个有序的发起方生命周期。teardown 会先拒绝新边界;移除 `ctx.agents` 后,AgentLoop 等注入方开始排空,注册表随后等待活动的返回 Promise 边界,最后调用 `AsyncLocalStorage.disable()`。如果某个边界继承的异步调用链启动所属 Cordis fiber 的卸载,私有运行标记谱系会从排空范围中释放该嵌套边界链,从而避免 teardown 等待自身完成,同时继续排空无关边界。在普通排空期间,进行中代码可通过保留的服务引用继续调用 `currentInitiator()` 和 `requireInitiator()`;dispose(资源释放)后,发起方方法会抛出 `agent initiator scope is disposed`。根 Context dispose 可能并发启动同级 fiber 的 teardown,因此除 Cordis 依赖顺序外仍必须统计活动边界。
|
||||
|
||||
@@ -28,7 +28,7 @@ harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负
|
||||
|
||||
宿主感知的传输层可以从 `ctx.agents.requireInitiator().session.id` 推导由部署方拥有的 `X-Harness-Session-Id` 等请求头;模型可见 schema 和参数中不包含该请求头。本决策不让现有生产 MCP 或 Web 传输层采用此请求头。测试替身传输层用于证明可信边界,而不会把宿主路由策略分配给现有的提供方无关 seam。
|
||||
|
||||
本决策扩展 [Agent 注册作用域约定](2026-07-08-agent-scope-contexts.zh.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md),不会改变其中 `agent.ctx` 的静态含义。
|
||||
本决策扩展 [Agent 注册作用域约定](2026-07-08-agent-scope-contexts.zh.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md),不会改变其中 `agent.ctx` 的静态含义。[显式运行时身份决策](2026-08-31-explicit-agent-runtime-identity.zh.md)把发起方作用域限制在私有异步调用链内,同时让生命周期、归属、事件和协议接口直接携带各自的主体。
|
||||
|
||||
## 验证
|
||||
|
||||
@@ -40,7 +40,7 @@ Agent 服务测试锁定可选与必需读取、同步值及跨 realm Promise
|
||||
|
||||
**在每个函数中传递 Agent。** 公开、worker、进程、持久化和协议边界继续显式传递,但要求每个进程内私有辅助函数都携带 Agent 只会造成重复转发,不会提高可信度。ALS 仅限于这些显式边界内部的异步调用链。
|
||||
|
||||
**让 `ctx.agent` 变成动态值。** `ctx.agent` 已经表示与 Agent 作用域 Cordis 上下文静态关联的 Agent。改变根上下文的含义会混合注册作用域与执行作用域,并让并发行为变得意外。
|
||||
**暴露动态的 `ctx.agent`。** Context 携带注册所有权,而非领域主体。为正在执行的 Agent 新增 accessor 会混合注册作用域与执行作用域,并让并发行为变得意外。
|
||||
|
||||
**新增独立的 `ctx.agentExecution` 服务。** 该载体没有独立后端、配置或身份类型:它存储的是 `ctx.agents` 已经管理的同一个 `Agent`,而 AgentLoop 本就依赖该服务。第二个必需提供方会增加包、组合、生命周期、生成目录及测试 harness 接线,却没有拆出真实能力。
|
||||
|
||||
|
||||
+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-19-gui-web-client-architecture.md
|
||||
2026-07-19-gui-web-client-architecture.md: 409c4347bca42dd96fd134e0133a1721fdaebd5d
|
||||
2026-07-19-gui-web-client-architecture.zh.md: 58235981471eeb365f7416fcd2e5530468e1e3ff
|
||||
2026-07-19-gui-web-client-architecture.md: 55421d1ad6df192d08c431af3633675036a4a857
|
||||
2026-07-19-gui-web-client-architecture.zh.md: 6fb3f9a512389710f6708b7f36f42e90eef11b28
|
||||
@@ -44,7 +44,7 @@ Implementation homes: registry core and the props-share types live in `packages/
|
||||
|
||||
A service is a plugin's only API toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (RPC transport + generation state), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer installation contract), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md).
|
||||
|
||||
There is no component registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Final Chat business Nodes dispatch through the keyed/session `'conversation.chat.node'` slot; ui-tool owns its `tool-call` entry, recursively renders the supplied `subCalls`, and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and roots and descendants dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '<tool>' }, Row))`; the declaration is the load and reload dependency ([decision](../../archived/architecture/2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components. The target-neutral event and view registries are data assembly seams rather than parallel component registries ([decision](2026-08-09-client-conversation-node-assembly.md)).
|
||||
There is no component registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Final Chat business Nodes dispatch through the keyed/session `'conversation.chat.node'` slot; ui-tool owns its `tool-call` entry, recursively renders the supplied `subCalls`, and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and roots and descendants dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '<tool>' }, Row))`; the declaration is the load and reload dependency ([decision](../../archived/architecture/2026-08-05-slot-declaration-injection.md)). The right column is the `rightbar` seat ui-sidebar-right fills with one docking surface per session; the former details column and its `'conversation.details.tool'` seat are gone ([decision](../feature/2026-09-04-right-sidebar-docking-infrastructure.md)). The target-neutral event and view registries are data assembly seams rather than parallel component registries ([decision](2026-08-09-client-conversation-node-assembly.md)).
|
||||
|
||||
**Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport).
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ slot 体系有自己的笔记——[slot 体系标准](2026-07-22-slot-type-chai
|
||||
|
||||
服务是插件对其他插件的唯一 API(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图 slot 注册)。名册:`ctx.connection`(RPC 传输 + generation 状态)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装约定)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.zh.md) 住 entry 声明的 store。
|
||||
|
||||
slot 之外不存在第二种组件注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list slot entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。最终 Chat 业务 Node 通过 keyed/session `'conversation.chat.node'` slot 分发;ui-tool 拥有其中的 `tool-call` entry,递归渲染传入的 `subCalls`,并声明 keyed/session `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与任意深度的后代都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '<tool>' }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](../../archived/architecture/2026-08-05-slot-declaration-injection.md))。ui-conversation 还通过 `'conversation.details.tool'` 委托 selected call 的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。与 target 无关的事件注册表和视图注册表是数据组装 seam,不是平行组件注册表([决策](2026-08-09-client-conversation-node-assembly.zh.md))。
|
||||
slot 之外不存在第二种组件注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list slot entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。最终 Chat 业务 Node 通过 keyed/session `'conversation.chat.node'` slot 分发;ui-tool 拥有其中的 `tool-call` entry,递归渲染传入的 `subCalls`,并声明 keyed/session `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与任意深度的后代都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '<tool>' }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](../../archived/architecture/2026-08-05-slot-declaration-injection.md))。右列是 ui-sidebar-right 以每会话一个停靠面填充的 `rightbar` 坑位;原来的详情列及其 `'conversation.details.tool'` 坑位已删除([决策](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md))。与 target 无关的事件注册表和视图注册表是数据组装 seam,不是平行组件注册表([决策](2026-08-09-client-conversation-node-assembly.zh.md))。
|
||||
|
||||
**scope 寻址**与 host 侧 agent(智能体)scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。
|
||||
|
||||
|
||||
+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-20-canonical-tool-output-contract.md
|
||||
2026-07-20-canonical-tool-output-contract.md: f2c17325f77b93675086c39dd5a7693854b8521a
|
||||
2026-07-20-canonical-tool-output-contract.zh.md: d81c1730aab66df2dcf4eea4515f260df917ffb4
|
||||
2026-07-20-canonical-tool-output-contract.md: 4dbcac3da8381e69809b15a653cdb4987af4e0e7
|
||||
2026-07-20-canonical-tool-output-contract.zh.md: c2f4ebdfc7266495570766c69b3aa264f91172cc
|
||||
@@ -34,7 +34,7 @@ type ToolExecutionResult =
|
||||
|
||||
`tools/post-execute` has two mutually exclusive successful projections. Replacing `content` changes only Native/model presentation and preserves the canonical value and metadata. Replacing `value` revalidates the replacement and recomputes both presentation projections. A block removes the value and becomes a failure. Content replacement is therefore not a confidentiality mechanism: policy that must prevent programmatic access blocks the call or replaces the value.
|
||||
|
||||
Canonical values are execution-local. The agent loop persists `tool/result` with only `content`, `error`, and optional `meta`; PTC mode's `tool/code-dispatch` persists the sub-call's rendered `content` and `isError`. Neither event stores the canonical intermediate value, so replay reproduces presentation but cannot reconstruct the programmatic result. When a tool declares `presentationMeta`, it is computed only for a direct surface call; a nested Code dispatch gets no metadata. The Client can derive [nested terminal cards](../bug-fix/2026-09-05-nested-terminal-cards.md) from raw arguments and rendered content without that metadata. The outer `run_code` card instead reads final post-policy content and declares no presentation metadata. Generic and tool-owned spill projections similarly skip nested dispatches, whose canonical value never enters model context.
|
||||
Canonical values are execution-local. The agent loop persists `tool/result` with only `content`, `error`, and optional `meta`; PTC mode's `tool/ptc-dispatch` persists the sub-call's rendered `content` and `isError`. Neither event stores the canonical intermediate value, so replay reproduces presentation but cannot reconstruct the programmatic result. When a tool declares `presentationMeta`, it is computed only for a direct surface call; a nested Code dispatch gets no metadata. The Client can derive [nested terminal cards](../bug-fix/2026-09-05-nested-terminal-cards.md) from raw arguments and rendered content without that metadata. The outer `run_code` card instead reads final post-policy content and declares no presentation metadata. Generic and tool-owned spill projections similarly skip nested dispatches, whose canonical value never enters model context.
|
||||
|
||||
The first-party tools preserve their existing Native text while returning domain DTOs:
|
||||
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ type ToolExecutionResult =
|
||||
|
||||
`tools/post-execute` 为成功结果提供两种互斥的投影方式。替换 `content` 只改变 Native/模型展示,并保留规范值和元数据。替换 `value` 会重新校验替代值,并重新计算两份展示投影。阻止操作会移除值并转为失败。因此,替换内容并不是保密机制:必须阻止程序化访问的策略,应当阻止调用或替换值。
|
||||
|
||||
规范值仅存在于执行期间。agent loop(智能体循环)持久化的 `tool/result` 只包含 `content`、`error` 和可选的 `meta`;PTC mode 的 `tool/code-dispatch` 持久化子调用渲染后的 `content` 与 `isError`。两个事件都不存储规范中间值,因此回放可以重现展示,却无法重建程序化结果。当工具声明 `presentationMeta` 时,系统只会为直接的外层调用计算它;嵌套 Code 分发没有元数据。Client 可以从原始参数与渲染后的内容派生[嵌套 terminal 卡片](../bug-fix/2026-09-05-nested-terminal-cards.zh.md),无需这些元数据。外层 `run_code` 卡片则读取最终的 post-policy 内容,并且不声明展示元数据。通用以及工具自有的 spill 投影同样跳过嵌套分发,因为它们的规范值永远不会进入模型上下文。
|
||||
规范值仅存在于执行期间。agent loop(智能体循环)持久化的 `tool/result` 只包含 `content`、`error` 和可选的 `meta`;PTC mode 的 `tool/ptc-dispatch` 持久化子调用渲染后的 `content` 与 `isError`。两个事件都不存储规范中间值,因此回放可以重现展示,却无法重建程序化结果。当工具声明 `presentationMeta` 时,系统只会为直接的外层调用计算它;嵌套 Code 分发没有元数据。Client 可以从原始参数与渲染后的内容派生[嵌套 terminal 卡片](../bug-fix/2026-09-05-nested-terminal-cards.zh.md),无需这些元数据。外层 `run_code` 卡片则读取最终的 post-policy 内容,并且不声明展示元数据。通用以及工具自有的 spill 投影同样跳过嵌套分发,因为它们的规范值永远不会进入模型上下文。
|
||||
|
||||
第一方工具在保持现有 Native 文本不变的同时返回领域 DTO:
|
||||
|
||||
|
||||
+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-22-slot-type-chain-implementation.md
|
||||
2026-07-22-slot-type-chain-implementation.md: 0e493a19e232acf6f289714e53e0cf64b3ffc4b0
|
||||
2026-07-22-slot-type-chain-implementation.zh.md: 5e5c2ec808cec6949396a333c6a86e267bb4268b
|
||||
2026-07-22-slot-type-chain-implementation.md: 98585a42f71844594da4ada19d7438a7d8321702
|
||||
2026-07-22-slot-type-chain-implementation.zh.md: d1093537ccbc5ee5ff2dc18c234233ffca39879d
|
||||
@@ -12,6 +12,8 @@ The page is composed at runtime from independently loaded plugins, so the UI nee
|
||||
|
||||
## Decision
|
||||
|
||||
Global main-panel selection and its root lifetime are defined by the [global main-panels decision](2026-09-08-global-main-panels.md).
|
||||
|
||||
One sentence: **the ui-renderer renders only `'root'`; a plugin composes UI through a single `register` call that simultaneously occupies a slot, declares+authorizes its child slots, declares its store, and injects its business face; components are pure functions whose props arrive in four shares, each auto-derived from its single source of truth.**
|
||||
|
||||
### 'root' is the only a-priori slot
|
||||
@@ -25,7 +27,7 @@ ctx.slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'sidebar': { kind: 'single', scope: 'root' },
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
'main': { kind: 'keyed', scope: 'root' },
|
||||
},
|
||||
store: createLayoutStore, // StoreHandle or factory (below)
|
||||
inject: injectFrame, // business face (below)
|
||||
|
||||
+3
-1
@@ -12,6 +12,8 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
全局主面板选择及其 root 生命周期由[全局主面板决策](2026-09-08-global-main-panels.zh.md)定义。
|
||||
|
||||
一句话:**ui-renderer 只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——这一次调用同时占用 slot、声明并授权子 slot、声明 store、注入业务面;组件是纯函数,props 分四份额到达,每一份额都从各自唯一的真源自动推导。**
|
||||
|
||||
### 'root' 是唯一的先验 slot
|
||||
@@ -25,7 +27,7 @@ ctx.slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'sidebar': { kind: 'single', scope: 'root' },
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
'main': { kind: 'keyed', scope: 'root' },
|
||||
},
|
||||
store: createLayoutStore, // StoreHandle or factory (below)
|
||||
inject: injectFrame, // business face (below)
|
||||
|
||||
+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-projected-token-usage-and-request-context.md
|
||||
2026-07-29-projected-token-usage-and-request-context.md: d62f7dccd544a342da64fc35c24d32d53bf56231
|
||||
2026-07-29-projected-token-usage-and-request-context.zh.md: 6d2bb624ac11dbcdac30695913d3c16513bfdf9e
|
||||
2026-07-29-projected-token-usage-and-request-context.md: f96257243bef91ff6a73418231de5e777d8edb2e
|
||||
2026-07-29-projected-token-usage-and-request-context.zh.md: 7365d5d816f9f9b324f3e3d3b4db0d3346851bdf
|
||||
+2
-2
@@ -26,7 +26,7 @@ Capacity deliberately stays out of `EpochHeader`. That type is the reconstructio
|
||||
|
||||
Both units ride the standard projection lifecycle: history tail baselines, `session/projection` live frames, higher-seq-wins client storage, JSON checkpoints, cache recovery, and unit unload. There is no token-specific history field, mux frame, projector, revision counter, or client fence.
|
||||
|
||||
The Web `StatsLine` reads both through the standard `useProjection` seat. Window nodes still supply turn and step counts plus LLM and tool wall times — those answer "what is on screen" and are correctly window-scoped. Durable token and context groups remain when compaction leaves no visible assistant step. Cache writes count in billed input and in the cache-hit denominator. A deployment without token-meter drops the token groups; occupancy stays hidden until both pressure and capacity are known. The exact-overflow tooltip mounts its measuring child only for a non-empty line and retains one `ResizeObserver` while values change; text changes perform one direct measurement without replacing the observer.
|
||||
The Web [`StatsPills`](../feature/2026-09-07-composer-session-stats-pills.md) reads both through the standard `useProjection` seat. Window nodes still supply turn and step counts plus LLM and tool wall times as the no-projection fallback — those answer "what is on screen" and are correctly window-scoped. The durable usage pill remains when compaction leaves no visible assistant step. Cache writes count in billed input and in the cache-hit denominator. A deployment without token-meter drops the usage pill; context occupancy lives on the composer's ContextMeter ring. Exact token figures show in the usage pill's click-open dialog rather than a hover tooltip.
|
||||
|
||||
## Context occupancy is approximate, and that is the decision
|
||||
|
||||
@@ -48,7 +48,7 @@ That cost bought a worse display: occupancy went blank after every reconnect and
|
||||
|
||||
**Resolve capacity inside token-meter.** The package documents itself as independent of model routing and is otherwise a pure reader that never appends to the log. AgentLoop already holds the resolved metadata where the header is written.
|
||||
|
||||
**Extend the `session.models` RPC with capacity.** The handler already resolves and discards it, so the field is nearly free — but `StatsLine` lives in `ui-conversation` while the model directory lives in `ui-model-selection`, and `ui-conversation` cannot depend on `ui-model-selection`. Delivering it would have required either a second dock entry splitting one text row across two plugins, or a cross-plugin store write.
|
||||
**Extend the `session.models` RPC with capacity.** The handler already resolves and discards it, so the field is nearly free — but the stats display (now `StatsPills`, ui-chat) and the model directory live in separate plugins with no dependency between them. Delivering it would have required either a second dock entry splitting one surface across two plugins, or a cross-plugin store write.
|
||||
|
||||
**Add a context circle beside the model selector.** That placement suggests selected-model state. The stats line carries the figure without a duplicate UI or data path.
|
||||
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ token-meter 还拥有在持久事件上运行的共享纯 attempt/Turn fold。
|
||||
|
||||
两个单元都沿用标准投影生命周期:历史尾页基线、`session/projection` 实时帧、seq 高者胜的客户端存储、JSON 检查点、缓存恢复和单元卸载。系统没有任何 token 专用的历史字段、mux 帧、投影器、修订计数器或客户端栅栏。
|
||||
|
||||
Web `StatsLine` 通过标准 `useProjection` 席位读取两者。窗口内节点仍提供轮次和步骤计数,以及 LLM(大语言模型)与工具的墙钟时间:它们回答的是「屏幕上有什么」,按窗口作用域正是正确的。压缩使可见 assistant 步骤归零后,持久 token 与上下文分组仍会保留。缓存写入会计入计费输入和缓存命中率分母。未部署 token-meter 时会去掉 token 分组;只有压力与容量都已知时才显示占用率。精确 overflow tooltip 只在统计行非空时挂载测量子组件,并在值变化期间保留同一个 `ResizeObserver`;文本变化只直接测量一次,不替换 observer。
|
||||
Web [`StatsPills`](../feature/2026-09-07-composer-session-stats-pills.zh.md) 通过标准 `useProjection` 席位读取两者。窗口内节点仍作为无投影回退提供轮次和步骤计数,以及 LLM(大语言模型)与工具的墙钟时间:它们回答的是「屏幕上有什么」,按窗口作用域正是正确的。压缩使可见 assistant 步骤归零后,持久用量 pill 仍会保留。缓存写入会计入计费输入和缓存命中率分母。未部署 token-meter 时会去掉用量 pill;上下文占用率由输入框旁的 ContextMeter 圆环承载。精确 token 数字显示在用量 pill 点击展开的弹层里,而非悬停提示。
|
||||
|
||||
## 上下文占用率是近似值,而这正是决策本身
|
||||
|
||||
@@ -48,7 +48,7 @@ Web `StatsLine` 通过标准 `useProjection` 席位读取两者。窗口内节
|
||||
|
||||
**在 token-meter 内部解析容量。** 该包自述与模型路由无关,且在其他方面是一个从不向日志追加内容的纯读取方。AgentLoop 在写入请求头的位置已经持有已解析的元数据。
|
||||
|
||||
**为 `session.models` RPC 增加容量字段。** 其处理器已经解析出容量又将其丢弃,因此这个字段几乎是免费的;但 `StatsLine` 位于 `ui-conversation`,模型目录位于 `ui-model-selection`,而 `ui-conversation` 不能依赖 `ui-model-selection`。要送达它,就得增加第二个 dock 条目、把一行文本拆到两个插件里,或者做一次跨插件的 store 写入。
|
||||
**为 `session.models` RPC 增加容量字段。** 其处理器已经解析出容量又将其丢弃,因此这个字段几乎是免费的;但统计展示(现为 `StatsPills`,ui-chat)与模型目录位于两个互不依赖的插件。要送达它,就得增加第二个 dock 条目把一个表面拆到两个插件里,或者做一次跨插件的 store 写入。
|
||||
|
||||
**在模型选择器旁增加上下文圆环。** 该位置会让人以为这是所选模型的状态。统计行可以承载该数字,无需引入重复的 UI 或数据路径。
|
||||
|
||||
|
||||
+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: aeec2a36d0b1e498591ef509e2e9164f586ed60c
|
||||
2026-07-30-session-end-seed-log-boundary.zh.md: ceba46a474c402230dbf215a2d53a41d3c027fc2
|
||||
2026-07-30-session-end-seed-log-boundary.md: 76f8904f75f6c4b5a1a6acab8d69ef2290be718e
|
||||
2026-07-30-session-end-seed-log-boundary.zh.md: 4310654c841305a7f0de2f46434d0f839085c482
|
||||
@@ -50,6 +50,6 @@ Bought: one boundary, written in one place, correct for all six seeded-start pat
|
||||
|
||||
Cost: a seeded session's log is one event longer, including an empty resumed log. Seq expectations move with that boundary. Two updates are load-bearing rather than mechanical: telemetry's adoption tests assert that capture begins with the current lifecycle's newly appended boundary and excludes the constructor seed, and the property suite's replay invariant is "seed reproduced verbatim, plus one log-only boundary" with idempotence as its own property.
|
||||
|
||||
`session/end-seed` joins the on-disk vocabulary. Current v1 requires the validated marker semantics owned by Session; the frozen v0 codec and migration edge own which historical v0 seed layouts remain admissible. The exact inherited cut stays separate from the logical header and is available after a body read.
|
||||
`session/end-seed` joins the on-disk vocabulary. The current format requires the validated marker semantics owned by Session; the frozen v0 codec and migration edge own which historical v0 seed layouts remain admissible. The exact inherited cut stays separate from the logical header and is available after a body read.
|
||||
|
||||
The [queued manual compaction decision](../feature/2026-07-30-queued-manual-compaction.md) now supplies the first consumer. Its tail scan independently finds the unmatched `compaction/start` and newest end-seed, treats only a start after that boundary as live, and clears the invariant trace on the same replay transition. The predicate remains in the compaction package rather than becoming a generic core helper.
|
||||
+1
-1
@@ -50,6 +50,6 @@ Status: implemented
|
||||
|
||||
代价:带种子会话的日志长了一个事件,空日志恢复也包括在内。seq 期望会随这条边界移动。两处更新是承重的而非机械的:telemetry 的接管测试断言捕获从当前生命周期新追加的边界开始,并排除 constructor seed;属性测试套件的回放不变式则是「种子逐字节复现,外加一个仅日志边界」,并把幂等性作为独立属性。
|
||||
|
||||
`session/end-seed` 加入了落盘词汇表。当前 v1 要求由 Session 拥有的已校验 marker 语义;冻结的 v0 codec 与迁移边负责哪些历史 v0 seed 布局仍可接受。精确继承 cut 与逻辑 header 分离,并在读取正文后可用。
|
||||
`session/end-seed` 加入了落盘词汇表。当前格式要求由 Session 拥有的已校验 marker 语义;冻结的 v0 codec 与迁移边负责哪些历史 v0 seed 布局仍可接受。精确继承 cut 与逻辑 header 分离,并在读取正文后可用。
|
||||
|
||||
[排队手动压缩决策](../feature/2026-07-30-queued-manual-compaction.zh.md)如今提供了第一个消费方。其尾部扫描会分别查找未匹配的 `compaction/start` 与最新 end-seed,只把位于该边界之后的 start 视为存活,并在同一个回放转换上清除不变量追踪状态。该谓词仍位于压缩功能所在的包中,不会成为通用核心辅助函数。
|
||||
+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-31-claimed-pre-step-inbox-lifecycle.md
|
||||
2026-07-31-claimed-pre-step-inbox-lifecycle.md: 73768e1eee8957f8976d40812b0a31a2961f0825
|
||||
2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 343816abaf394b8f64924cf36b753c6b1b2e34ca
|
||||
2026-07-31-claimed-pre-step-inbox-lifecycle.md: 737e3835263a3215a0fd2e52dad4ee05402bd888
|
||||
2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: ecb731df663e0d48b374a3118d7db7f6a34bfc18
|
||||
+7
-5
@@ -12,17 +12,19 @@ Occurrence-local inbox wrappers also duplicated the identity already carried by
|
||||
|
||||
## Decision
|
||||
|
||||
Before every proposed step, `Inbox.claim(target)` atomically removes the complete batch: all `next-step` messages and, at a turn boundary, one `next-turn` message. At the initial boundary the loop first commits `turn/start`, so the claim and its single `agent/pre-step` decision have durable turn ownership. Claiming records normalized `agent/inbox/spliced` pure deletions with no outcome. The loop then emits `agent/inbox/claimed { message, turn }` once per claimed message and awaits the waterfall with that exclusive batch and `{ turn, step, signal }`.
|
||||
Before every proposed step, the loop's package-internal `ReactLoopInbox` atomically claims the complete batch: all `next-step` messages and, at a turn boundary, one `next-turn` message. At the initial boundary the loop first commits `turn/start`, so the claim and its single `agent/pre-step` decision have durable turn ownership. Claiming records normalized `agent/inbox/spliced` pure deletions with no outcome, emits `agent/inbox/claimed { message, turn }` once per claimed message, and returns the exclusive batch for the loop's waterfall with `{ turn, step, signal }`.
|
||||
|
||||
`PreStepDecision` is `{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }`. Reject opens no step, leaves the claimed batch removed, and closes the turn as blocked without any step events. Empty entry, cancellation, and failure before `step/start` likewise close a balanced no-step turn. Enter supplies the complete batch appended as `user/message` events after `step/start`. A listener wrapping `next()` preserves downstream changes unless it intentionally replaces them, so all message rewrites settle once in the final return value. There is no `agent/prompt-prepare`, `agent/prompt-submit`, or `agent/step` extension point.
|
||||
|
||||
The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming is the loop's internal step-boundary operation on the inbox and records pure deletions without notifications or an outcome, so the loop can publish claimed events itself. These live events add no placement, outcome, or batch fields.
|
||||
The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming records pure deletions without an outcome and emits claimed events from `ReactLoopInbox`. These live events add no placement, outcome, or batch fields.
|
||||
|
||||
The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. Whole-queue consumers, including the Web queue projection and reconnect baseline, use the durable `agent/inbox/spliced` stream; UI edits and removals route through `Inbox.splice()` or another Inbox mutation method so the same projection records every change.
|
||||
`Agent.inbox` exposes only the structural `Inbox` interface for reading and mutating pending work; loop-only `hasPending` and claim operations are absent from that public face. dsh-agent-loop constructs one `ReactLoopInbox` and uses it for both structural commands and driver operations. The concrete constructor receives `SessionProjectionRegistry` directly instead of the wider Cordis `Context` and registers the standard definition on the agent scope before its first read. `AgentLoop` requires the registry service at activation, and the registry reference-counts the definition across live agent scopes.
|
||||
|
||||
The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. Each `ReactLoopInbox` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream from its agent scope; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. Whole-queue control consumers use the projection change feed: the Session controller publishes the projection frame, then derives the queue replacement from the same post-fold inbox value.
|
||||
|
||||
Plugins that need current-step atomic rewriting return messages from `agent/pre-step`. Plugins that only need later context may mutate `agent.inbox` directly. Workspace context uses both paths: asynchronous filesystem projections stage one replaceable `next-step` item, while the next entering pre-step folds that item or a newly composed baseline into its final batch and removes the pending copy. Rejection keeps the item queued.
|
||||
|
||||
The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` now owns addressability, while the retained Host queue mirror derives its snapshots from the durable splice projection.
|
||||
The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` owns addressability, while `ReactLoopInbox` contributes `inbox` as the standard session projection over durable splices. The generic projection carrier serves that fold for live updates, history-tail reconnect baselines, and cold process-restart recovery without a live Agent mirror.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -34,7 +36,7 @@ The archived [addressable queue occurrence decision](../../archived/feature/2026
|
||||
|
||||
## Verification
|
||||
|
||||
Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, and cancellation. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, and resumed durable projection. Generated event and type catalogs expose only the new waterfall and payloads.
|
||||
Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, cancellation, and agent-scope projection removal after the last owner unloads. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, resumed durable projection, rejection of invalid persisted coordinates or cross-list identities, and post-fold queue replacement when the controller registers before the projection registry. Consumer-domain tests use a process-local Inbox stub only when durability is outside the test subject; claiming, durable projection, recovery, validation, and live-notification tests create Agents through the production AgentLoop test harness, so test support never reimplements the projection. Generated event and type catalogs expose only the new waterfall and payloads.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
+7
-5
@@ -12,17 +12,19 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
每个拟议步骤之前,`Inbox.claim(target)` 会原子移除完整批次:全部 `next-step` 消息,以及轮次边界上的一条 `next-turn` 消息。在首次边界,循环会先提交 `turn/start`,使领取及其唯一一次 `agent/pre-step` 决策拥有持久轮次归属。领取会记录规范化、不带 outcome 的纯删除 `agent/inbox/spliced`。随后,循环针对每条已领取消息发出一次 `agent/inbox/claimed { message, turn }`,并用该独占批次与 `{ turn, step, signal }` 等待 waterfall(瀑布式事件)。
|
||||
每个拟议步骤之前,循环包内部的 `ReactLoopInbox` 会原子领取完整批次:全部 `next-step` 消息,以及轮次边界上的一条 `next-turn` 消息。在首次边界,循环会先提交 `turn/start`,使领取及其唯一一次 `agent/pre-step` 决策拥有持久轮次归属。领取会记录规范化、不带 outcome 的纯删除 `agent/inbox/spliced`,针对每条已领取消息发出一次 `agent/inbox/claimed { message, turn }`,并把独占批次返回给循环,由后者用 `{ turn, step, signal }` 等待 waterfall(瀑布式事件)。
|
||||
|
||||
`PreStepDecision` 为 `{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }`。reject 不会打开步骤,会让已领取批次保持已删除,并将轮次关闭为 blocked,且不产生任何步骤事件。空的 enter、取消以及 `step/start` 前的失败同样会关闭一个边界平衡的无步骤轮次。enter 提供在 `step/start` 后以 `user/message` 追加的完整批次。包装 `next()` 的监听器会保留下游变更,除非有意替换,因此全部消息改写只在最终返回值中一次性结算。系统不再存在 `agent/prompt-prepare`、`agent/prompt-submit` 或 `agent/step` 扩展点。
|
||||
|
||||
持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取是循环在 inbox 上的内部步骤边界操作,记录不带通知或 outcome 的纯删除,因此循环可以自行发布 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。
|
||||
持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取记录不带 outcome 的纯删除,并由 `ReactLoopInbox` 发出 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。
|
||||
|
||||
两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。包括 Web 队列投影和重连基线在内的整体队列消费方使用持久 `agent/inbox/spliced` 流;UI 编辑与移除通过 `Inbox.splice()` 或其他 Inbox 变更方法处理,从而让同一投影记录所有变化。
|
||||
`Agent.inbox` 只暴露用于读取和变更待处理工作的结构化 `Inbox` 接口;仅供循环使用的 `hasPending` 与领取操作不在该公开接口上。dsh-agent-loop 只构造一个 `ReactLoopInbox`,同时用于结构化命令与驱动器操作。具体构造函数直接接收 `SessionProjectionRegistry`,而不是更宽泛的 Cordis `Context`,并在首次读取前从 agent 作用域注册标准定义。`AgentLoop` 激活时要求该注册表服务存在,注册表则对多个 live agent 作用域贡献的定义进行引用计数。
|
||||
|
||||
两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。每个 `ReactLoopInbox` 都从其 agent 作用域在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。整体队列的 control 消费方使用投影变更流:Session controller 先发布 projection frame,再从同一份折叠后的 inbox 值派生 queue replacement。
|
||||
|
||||
必须对当前步骤进行原子改写的插件从 `agent/pre-step` 返回消息。只需要稍后上下文的插件可以直接修改 `agent.inbox`。Workspace context 同时使用两条路径:异步文件系统投影会暂存一条可替换的 `next-step` 消息,而下一次进入步骤的 pre-step 会把该消息或新组合的基线折入最终批次,并移除仍待处理的副本。reject 会让该条目继续排队。
|
||||
|
||||
已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。现在由 `MessageId` 负责寻址,而保留的 Host 队列镜像根据持久 splice 投影派生快照。
|
||||
已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。`MessageId` 负责寻址,而 `ReactLoopInbox` 把 `inbox` 作为持久 splice 上的标准会话投影贡献给投影注册表。通用投影传输层会将该折叠结果用于实时更新、历史尾页的重连基线和冷进程重启恢复,无需 live Agent 镜像。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
@@ -34,7 +36,7 @@ Status: implemented
|
||||
|
||||
## 验证
|
||||
|
||||
agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败与取消。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点以及恢复后的持久投影。生成的事件与类型目录只公开新的 waterfall 与载荷。
|
||||
agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败、取消,以及最后一个所有者卸载后移除 agent 作用域投影。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点、恢复后的持久投影、对非法持久坐标或跨列表重复标识的拒绝,以及 controller 早于投影注册表注册时仍使用折叠后队列值。只有当持久性不属于测试对象时,消费方领域测试才使用进程内 Inbox 桩;领取、持久投影、恢复、校验与实时通知测试通过生产 AgentLoop 测试 harness 创建 Agent,因此测试支持代码不会重新实现该投影。生成的事件与类型目录只公开新的 waterfall 与载荷。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md
|
||||
2026-08-04-draft-provider-endpoint-interrogation.md: 462b8ba3e771018126aa6483a8cd72999361144d
|
||||
2026-08-04-draft-provider-endpoint-interrogation.zh.md: c7612513b95bc3dc9736b9bfaf70cca26a776181
|
||||
2026-08-04-draft-provider-endpoint-interrogation.md: 469ab38b0f5609808a9df03237d4d21c0080d843
|
||||
2026-08-04-draft-provider-endpoint-interrogation.zh.md: 74c6bc7ed73572d5e99ea973c9ea94d22319cbdf
|
||||
+3
-3
@@ -17,7 +17,7 @@ The awkward part is that the question is about something that does not exist yet
|
||||
Interrogation is keyed by **settings namespace**, not by provider route:
|
||||
|
||||
- `ctx.llm.registerModelDiscovery(settingsNs, discover)` lets an adapter plugin offer to interrogate endpoints for the namespace it owns, and `ctx.llm.discoverModels(settingsNs, request)` asks. There is no way to enumerate which namespaces registered: a surface that cannot interrogate learns it from the refusal, and a list nothing consumed would be a required wire field doing nothing. The namespace is the right key because a configuration surface already holds it from the configurable-provider directory, and because a provider being added has no route to name.
|
||||
- `LlmModelDiscoveryRequest` carries the draft — an optional `provider`, an optional `baseURL`, an optional `api`, an optional `apiKey`, and a signal — and needs at least one of `provider` or `baseURL` to have anything to answer about. `provider` exists because a route the adapter already describes is answered from its own registry with no network call at all; only a route it does not describe reaches an endpoint. Nothing in this path writes settings or credentials. A named configured route reads its stored credential and deployment-owned profile `headers` inside the Host: the credential is write-only and the curated Models page does not edit headers, so neither can be reconstructed from that page's draft. The typed key wins over the stored credential, while the profile headers still accompany the request.
|
||||
- `LlmModelDiscoveryRequest` carries the draft — an optional `provider`, an optional `baseURL`, an optional `api`, an optional `apiKey`, and a signal — and needs at least one of `provider` or `baseURL` to have anything to answer about. `provider` exists because a route the adapter already describes is answered from its own registry with no network call at all; only a route it does not describe reaches an endpoint. The custom-provider card parses a non-empty `baseURL` and permits only HTTP or HTTPS before it calls this operation or writes the profile. Nothing in this path writes settings or credentials. A named configured route reads its stored credential and deployment-owned profile `headers` inside the Host: the credential is write-only and the curated Models page does not edit headers, so neither can be reconstructed from that page's draft. The typed key wins over the stored credential, while the profile headers still accompany the request.
|
||||
- `LlmDiscoveredModel` makes every field but `id` optional, because most listings disclose an id and nothing else. The reply is candidates, not a catalog: a surface adopting one still owes the capacities the adapter requires.
|
||||
- `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored or echoed back. It does ride the client's outgoing envelope like every other secret-bearing payload, where a `subscribeEnvelopes()` observer can see it; redacting that tap is a configuration-plane-wide change, not this method's to make alone. Connection authenticates the method with the complete Host API: it makes the host issue a GET to a caller-chosen URL and reports the outcome, which an anonymous caller must not receive. Every refusal folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered.
|
||||
|
||||
@@ -41,10 +41,10 @@ pi-ai supplies `createProvider({ fetchModels })` plus `Models.refresh()` and a `
|
||||
|
||||
## Consequences
|
||||
|
||||
A person adding a gateway can ask it what it serves instead of hunting through its documentation, and the answer arrives as candidates they choose from rather than as configuration written behind their back. When an endpoint discloses richer metadata, adopting a candidate fills its id, name, context window, and output-token cap into the editable Web row. Search preserves hidden selections, selecting all adds the visible results, and deselecting all clears every result so a filtered picker cannot submit hidden models accidentally. An already-configured enterprise gateway uses the same deployment headers and Harness `User-Agent` for interrogation and model requests without adding a header injection field to the browser protocol. The seam gained a registry that is deliberately small: one offer per namespace, no storage, no lifecycle beyond the fiber.
|
||||
A person adding a gateway can ask it what it serves instead of hunting through its documentation, and the answer arrives as candidates they choose from rather than as configuration written behind their back. A missing scheme, unsupported scheme, or malformed URL fails on the field without a Remote call; localhost, IPv4 and IPv6 literals, and custom ports remain valid. Provider and network failures remain distinct interrogation results. When an endpoint discloses richer metadata, adopting a candidate fills its id, name, context window, and output-token cap into the editable Web row. Search preserves hidden selections, selecting all adds the visible results, and deselecting all clears every result so a filtered picker cannot submit hidden models accidentally. An already-configured enterprise gateway uses the same deployment headers and Harness `User-Agent` for interrogation and model requests without adding a header injection field to the browser protocol. The seam gained a registry that is deliberately small: one offer per namespace, no storage, no lifecycle beyond the fiber.
|
||||
|
||||
What it costs: the wire gained a third secret-carrying payload, so the configuration plane's write-only surface is now three methods rather than two. Discovery coverage remains protocol-shaped rather than provider-shaped, and an endpoint using an unsupported request contract must be filled in by hand. Because nothing re-runs the question, a model list is still only as current as its last edit; that is the same trade the layer below made deliberately.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals, and the `model-discovery-failed` Remote mapping. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — standard arrays and enriched objects with every accepted metadata spelling, Anthropic's native path, headers, and capacity fields, route keys that differ from nested canonical ids, name fallback, a preserved deployment path, an absent credential, a configured route supplying its stored credential and headers while a typed key wins without resolving the stored one, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/llm/llm-pi-ai/tests/loader-composition.spec.ts` boots settings and credentials through the Loader and proves settings-only headers reach `GET /models` with request-owned headers winning collisions. `packages/llm/llm-pi-ai/tests/adapter.spec.ts` rejects profile headers Fetch cannot represent, and `packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts` proves a settings write reports that configuration error while its last good routes keep serving. `packages/client/connection/tests/node-half.host.spec.ts` pins the `llm/discoverModels` `/api` carrier registration, while the component and built-Web settings tests verify that the complete draft reaches the Remote, absent fields stay absent, selected metadata fills all four editable model fields, tuned rows win over rediscovery, filtered deselection clears hidden candidates, and no settings namespace or credential is written before selection.
|
||||
`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals, and the `model-discovery-failed` Remote mapping. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — standard arrays and enriched objects with every accepted metadata spelling, Anthropic's native path, headers, and capacity fields, route keys that differ from nested canonical ids, name fallback, a preserved deployment path, an absent credential, a configured route supplying its stored credential and headers while a typed key wins without resolving the stored one, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/llm/llm-pi-ai/tests/loader-composition.spec.ts` boots settings and credentials through the Loader and proves settings-only headers reach `GET /models` with request-owned headers winning collisions. `packages/llm/llm-pi-ai/tests/adapter.spec.ts` rejects profile headers Fetch cannot represent, and `packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts` proves a settings write reports that configuration error while its last good routes keep serving. `packages/client/connection/tests/node-half.host.spec.ts` pins the `llm/discoverModels` `/api` carrier registration, while the component and built-Web settings tests verify that the complete draft reaches the Remote, malformed and non-HTTP(S) custom-provider URLs make no request, localhost and IP literals with custom ports remain valid, provider failures stay distinct from URL errors, absent fields stay absent, selected metadata fills all four editable model fields, tuned rows win over rediscovery, filtered deselection clears hidden candidates, and no settings namespace or credential is written before selection.
|
||||
+3
-3
@@ -17,7 +17,7 @@ Status: implemented
|
||||
询问以 **settings namespace** 为键,而不是提供方路由:
|
||||
|
||||
- `ctx.llm.registerModelDiscovery(settingsNs, discover)` 让适配器插件为自己拥有的 namespace 提供「询问端点」的能力,`ctx.llm.discoverModels(settingsNs, request)` 发起询问。没有任何办法枚举哪些 namespace 注册过:询问不了的界面会从那句拒绝里知道,而一份无人消费的列表只会变成一个什么都不做的必填协议字段。以 namespace 为键是对的,因为配置界面已经从可配置提供方目录里拿到了它,也因为正在新增的提供方没有路由可点名。
|
||||
- `LlmModelDiscoveryRequest` 携带草稿——可选的 `provider`、可选的 `baseURL`、可选的 `api`、可选的 `apiKey`,以及一个 signal——且 `provider` 与 `baseURL` 至少要有一个,才有东西可答。`provider` 之所以存在,是因为适配器已经描述过的路由直接由它自己的注册表作答、完全不联网;只有它未描述的路由才会抵达某个端点。这条路径不写 settings 与 credentials。已配置且具名的路由会在 Host 内读取已存凭据和部署方持有的 profile `headers`:凭据只写,而精选的 Models 页面不编辑 headers,因此页面草稿无法重建两者。键入的密钥优先于已存凭据,profile headers 则仍随请求发送。
|
||||
- `LlmModelDiscoveryRequest` 携带草稿——可选的 `provider`、可选的 `baseURL`、可选的 `api`、可选的 `apiKey`,以及一个 signal——且 `provider` 与 `baseURL` 至少要有一个,才有东西可答。`provider` 之所以存在,是因为适配器已经描述过的路由直接由它自己的注册表作答、完全不联网;只有它未描述的路由才会抵达某个端点。自定义提供方卡片会解析非空的 `baseURL`,且只允许 HTTP 或 HTTPS,之后才会调用该操作或写入 profile。这条路径不写 settings 与 credentials。已配置且具名的路由会在 Host 内读取已存凭据和部署方持有的 profile `headers`:凭据只写,而精选的 Models 页面不编辑 headers,因此页面草稿无法重建两者。键入的密钥优先于已存凭据,profile headers 则仍随请求发送。
|
||||
- `LlmDiscoveredModel` 除 `id` 外每个字段都可选,因为大多数列表只公布 id。回复是候选而非 catalog:采纳其中一条的界面仍要补上适配器所需的容量。
|
||||
- `llm.discoverModels` 把同一份草稿送过协议层。它的 `apiKey` 是可承载机密的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。它确实会像其他承载机密的载荷一样随客户端外发信封同行,`subscribeEnvelopes()` 观察者看得到;把那个抽头脱敏是整个配置面的改动,不该由这一个方法独自决定。Connection 用与完整 Host API 相同的会话认证该方法:它让宿主向调用方选定的 URL 发起 GET 并回报结果,匿名调用者绝不能获得这类探测能力。每一种拒绝都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。
|
||||
|
||||
@@ -41,10 +41,10 @@ pi-ai 提供了 `createProvider({ fetchModels })` 加上 `Models.refresh()` 与
|
||||
|
||||
## Consequences
|
||||
|
||||
接入网关的人可以直接问它服务什么,而不必去翻它的文档;答案以候选形式抵达,由用户自己挑选,而不是被背着写进配置。当端点公布了更丰富的元数据时,采纳候选会把 id、名称、上下文窗口与最大输出 token 数填进 Web 的可编辑行。搜索会保留隐藏项的勾选状态,全选会加入可见结果,而取消全选会清空全部结果,因此筛选后的选择器不会意外提交隐藏模型。已配置的企业网关会为询问与模型请求使用同一组部署 headers 和 Harness `User-Agent`,而无需给浏览器协议增加 header 注入字段。seam 因此多了一个刻意保持很小的注册表:每个 namespace 一份、不存储、生命周期不超出 fiber。
|
||||
接入网关的人可以直接问它服务什么,而不必去翻它的文档;答案以候选形式抵达,由用户自己挑选,而不是被背着写进配置。缺少 scheme、不支持的 scheme 或 URL 格式错误都会直接在字段处失败,不会发起 Remote 调用;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。提供方与网络失败仍是独立的询问结果。当端点公布了更丰富的元数据时,采纳候选会把 id、名称、上下文窗口与最大输出 token 数填进 Web 的可编辑行。搜索会保留隐藏项的勾选状态,全选会加入可见结果,而取消全选会清空全部结果,因此筛选后的选择器不会意外提交隐藏模型。已配置的企业网关会为询问与模型请求使用同一组部署 headers 和 Harness `User-Agent`,而无需给浏览器协议增加 header 注入字段。seam 因此多了一个刻意保持很小的注册表:每个 namespace 一份、不存储、生命周期不超出 fiber。
|
||||
|
||||
代价是:协议层多了第三个承载机密的载荷,配置面的只写接口从两个方法变成三个。发现覆盖范围仍按协议而非按提供方划分,使用不受支持请求约定的端点仍须手工填写。由于没有任何环节会重跑该询问,模型列表的新鲜度依旧只到最近一次编辑为止;这与下层刻意做出的取舍是同一个。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose(资源释放)、丢弃重复与不可用 id 且不凭空补容量的归一化、`NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝,以及 `model-discovery-failed` Remote 映射。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——包括采用每种受支持元数据拼写的标准数组与富信息对象、Anthropic 原生路径、headers 与容量字段、不同于嵌套规范 id 的路由键、名称回退、被保留的部署路径、无凭据、已配置路由提供自己的已存凭据与 headers 且键入的密钥无需解析已存凭据便可压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/llm/llm-pi-ai/tests/loader-composition.spec.ts` 通过 Loader 启动 settings 与 credentials,并证明仅配置在 settings 中的 headers 会抵达 `GET /models`,且请求所持有的 headers 赢得冲突。`packages/llm/llm-pi-ai/tests/adapter.spec.ts` 拒绝 Fetch 无法表示的 profile headers,`packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts` 证明 settings 写入会报告该配置错误,同时上一组可用路由仍继续服务。`packages/client/connection/tests/node-half.host.spec.ts` 固定 `llm/discoverModels` 的 `/api` 承载注册,而设置页的组件测试和构建后 Web 测试则验证完整草稿抵达 Remote、缺席字段保持缺席、所选元数据填满四个可编辑模型字段、用户调整过的行优先于重新发现结果、筛选后的取消选择会清除隐藏候选项,以及选择前没有 settings namespace 或凭据被写入。
|
||||
`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose(资源释放)、丢弃重复与不可用 id 且不凭空补容量的归一化、`NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝,以及 `model-discovery-failed` Remote 映射。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——包括采用每种受支持元数据拼写的标准数组与富信息对象、Anthropic 原生路径、headers 与容量字段、不同于嵌套规范 id 的路由键、名称回退、被保留的部署路径、无凭据、已配置路由提供自己的已存凭据与 headers 且键入的密钥无需解析已存凭据便可压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/llm/llm-pi-ai/tests/loader-composition.spec.ts` 通过 Loader 启动 settings 与 credentials,并证明仅配置在 settings 中的 headers 会抵达 `GET /models`,且请求所持有的 headers 赢得冲突。`packages/llm/llm-pi-ai/tests/adapter.spec.ts` 拒绝 Fetch 无法表示的 profile headers,`packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts` 证明 settings 写入会报告该配置错误,同时上一组可用路由仍继续服务。`packages/client/connection/tests/node-half.host.spec.ts` 固定 `llm/discoverModels` 的 `/api` 承载注册,而设置页的组件测试和构建后 Web 测试则验证完整草稿抵达 Remote、格式错误和非 HTTP(S) 的自定义提供方 URL 不会发起请求、带自定义端口的 localhost 与 IP 字面地址仍然有效、提供方失败与 URL 错误保持区别、缺席字段保持缺席、所选元数据填满四个可编辑模型字段、用户调整过的行优先于重新发现结果、筛选后的取消选择会清除隐藏候选项,以及选择前没有 settings namespace 或凭据被写入。
|
||||
@@ -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-05-profile-plugin-bundles.md
|
||||
2026-08-05-profile-plugin-bundles.md: ccfa3306fd88b4f291085cae2bd02305b2c11fc6
|
||||
2026-08-05-profile-plugin-bundles.zh.md: e15ad15978ab57dcada8ecc877e0036cfde6b21e
|
||||
2026-08-05-profile-plugin-bundles.md: 7e51345e7eba8a58db63807e31d4a11481e3ffea
|
||||
2026-08-05-profile-plugin-bundles.zh.md: b2631603737ea9412eb97029ff01d751d8084cec
|
||||
@@ -12,7 +12,7 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y
|
||||
|
||||
Everything becomes a **profile**: a directory `$DSH_HOME/profiles/<name>` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer and `--patch` overlays — one `applyEntryPatches` call shared by boot and `--dump-config`. App invocation values later moved from launcher-derived patches to startup services in the [app-owned command-line decision](../../archived/architecture/2026-08-06-app-owned-command-line.md).
|
||||
|
||||
The default Profile templates use `@deepseek-ai/dsh-base` as the shared core for `web`, `headless`, `sdk`, and `acp`, with one mode bundle above it. The [standalone `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.md) instead lists one bundle that owns its complete explicit tree. Generic `dsh --profile <name>` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, headless owns its task positional, and the protocol profiles accept no app options. Patch overlays use launcher-owned `--patch`. `dsh plugin --profile <name> <args...>` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract.
|
||||
The default Profile templates use `@deepseek-ai/dsh-base` as the shared core for `web`, `headless`, `sdk`, and `acp`, with one mode bundle above it. The [standalone `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.md) instead lists one bundle that owns its complete explicit tree. Generic `dsh --profile <name>` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, headless owns its task positional, and the protocol profiles accept no app options. Patch overlays use launcher-owned `--patch`. A new, non-shipped target can use `--from-default-profile <template>` to copy one default template's bundle list and patch-reload policy before boot or config dump. This creates an independent profile with empty dependencies and an empty user patch: it neither reads a local profile named by the template nor records an inheritance relationship. The launcher claims the complete target directory exclusively, so existing state and concurrent creators fail without modification. `dsh plugin --profile <name> <args...>` is a thin pnpm forwarder that initializes a base-backed profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract.
|
||||
|
||||
Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch).
|
||||
|
||||
@@ -24,10 +24,12 @@ Two supporting refactors: the webserver's built-in static dist serving became th
|
||||
- **`link:` entries for in-box bundles**: pnpm cannot version, install, or update a `link:` into the installation, it embeds a machine path in a user file, and it breaks when the installation moves. The two-anchor resolution plus healed symlink fallback gives the same guarantee ("bundles come from the installation") without ceremony.
|
||||
- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows and app-owned startup services, so the composition stays fully dumpable and the manifest stays data-only. The launcher-provided host slots (`ctx.cmdlineArgs`, `ctx.appExit`, and the environment snapshot) are provided in `boot()`'s `prepare` hook, before any config-tree entry mounts.
|
||||
- **Transitive bundle auto-application**: only direct `dsh.profile.bundles` entries contribute layers; a meta-bundle wanting to re-export another bundle's patch must do so explicitly in its own patch file.
|
||||
- **Dynamic template inheritance or cloning a local profile**: recording a parent would require merge and upgrade rules for bundle membership, dependencies, and user patches, while copying local state would duplicate machine-specific choices. Template-based creation copies only installation-owned defaults once.
|
||||
|
||||
## Consequences
|
||||
|
||||
- New composition surfaces (a TUI, provider packs) ship as ordinary npm packages installable per profile, without a repository row for every deployment shape.
|
||||
- Users can start an independent custom profile from any shipped application template without copying machine-local profile state.
|
||||
- `apps/cli` shrank to argv parsing, profile machinery consumption, and the pnpm forwarder; `AppCLIEntry` and the per-surface boot paths are gone.
|
||||
- The keyless web e2e scaffold boots the same bundle layers over the same empty-root shape as production, including the profiles module fallback, so composition drift between test and product fails loudly.
|
||||
- Under the pre-release stance, backends carry no compatibility behavior for old on-disk configuration; `$DSH_HOME/config.yaml` is ignored.
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
一切都变成 **profile**:即目录 `$DSH_HOME/profiles/<name>`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层与 `--patch` overlay——启动与 `--dump-config` 共享同一条 `applyEntryPatches` 路径。随后,[应用持有命令行的决策](../../archived/architecture/2026-08-06-app-owned-command-line.md)又把调用期取值从启动器派生的 patch 迁移到了启动服务。
|
||||
|
||||
默认 Profile 模板为 `web`、`headless`、`sdk` 与 `acp` 使用 `@deepseek-ai/dsh-base` 作为共享核心,并在其上叠加一个模式组合包。[独立 `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.zh.md)则只列出一个拥有完整显式配置树的组合包。通用的 `dsh --profile <name>` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 持有任务位置参数,协议 profile 不接受应用选项。patch overlay 使用启动器持有的 `--patch`。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。
|
||||
默认 Profile 模板为 `web`、`headless`、`sdk` 与 `acp` 使用 `@deepseek-ai/dsh-base` 作为共享核心,并在其上叠加一个模式组合包。[独立 `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.zh.md)则只列出一个拥有完整显式配置树的组合包。通用的 `dsh --profile <name>` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 持有任务位置参数,协议 profile 不接受应用选项。patch overlay 使用启动器持有的 `--patch`。新的非内置目标可以使用 `--from-default-profile <template>`,在启动或配置 dump 之前复制一个默认模板的 bundle 列表与 patch 重载策略。这会创建依赖为空、用户 patch 为空的独立 profile:它既不读取与模板同名的本地 profile,也不记录继承关系。launcher 会以独占方式领取完整的目标目录,因此既有状态和并发创建者都会在不作修改的情况下失败。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器,负责初始化一个以 base 为基础的 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。
|
||||
|
||||
解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。
|
||||
|
||||
@@ -24,10 +24,12 @@ Status: implemented
|
||||
- **内置组合包使用 `link:` 条目**:pnpm 无法对指向安装目录的 `link:` 做版本管理、安装或更新,它会把机器路径嵌进用户文件,并且在安装目录移动后失效。双锚点解析加上每次启动修复的符号链接回退提供了同样的保证(「组合包来自安装目录」),且没有这些繁文缛节。
|
||||
- **在组合包 manifest 中放一个启动前 `context` 模块**承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是普通配置行和由应用持有的启动服务,因此组合始终可完整 dump,manifest 保持纯数据。启动器提供的宿主 slot(`ctx.cmdlineArgs`、`ctx.appExit` 与环境快照)在任何配置树条目挂载之前,于 `boot()` 的 `prepare` 钩子中提供。
|
||||
- **组合包的传递式自动应用**:只有直接列在 `dsh.profile.bundles` 中的条目才贡献层;想重新导出另一个组合包 patch 的元组合包,必须在自己的 patch 文件中显式完成。
|
||||
- **动态模板继承或克隆本地 profile**:记录父级会要求为 bundle 成员关系、依赖和用户 patch 制定合并与升级规则,而复制本地状态会重复机器特定选择。基于模板的创建只会一次性复制安装自有的默认值。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 新的组合表层(TUI、提供方扩展包)以普通 npm 包形式交付,可按 profile 安装,无需在仓库中为每种部署形态各留一行。
|
||||
- 用户可以从任意随附应用模板启动一个独立的自定义 profile,而不会复制机器本地的 profile 状态。
|
||||
- `apps/cli` 收缩为 argv 解析、profile 机制的消费方和 pnpm 转发器;`AppCLIEntry` 与各表层专属的启动路径全部移除。
|
||||
- 无密钥 web e2e 脚手架以与生产相同的空根形态启动相同的组合包层,包括 profiles 模块回退,因此测试与产品之间的组合漂移会响亮失败。
|
||||
- 按发布前姿态,后端不携带旧磁盘配置的兼容行为;`$DSH_HOME/config.yaml` 会被忽略。
|
||||
+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-09-client-conversation-node-assembly.md
|
||||
2026-08-09-client-conversation-node-assembly.md: 7f5b948f9083b20edd037fe32c55df7ba3378692
|
||||
2026-08-09-client-conversation-node-assembly.zh.md: 8716b25584a8cefd8d2239f242d430775ed2a8ef
|
||||
2026-08-09-client-conversation-node-assembly.md: 842dfb0218478591f975c97064f101a35ea2f211
|
||||
2026-08-09-client-conversation-node-assembly.zh.md: 6ab64c25957d33487461fe56e122faedb19f9421
|
||||
+2
-2
@@ -315,7 +315,7 @@ The shell synchronously resolves the persisted selection when a Session binding
|
||||
|
||||
Ordinary prepend and append flushes call `apply({ upserts, timeline })` only for active targets. Complete window replacement and Registry rebuild call `replace()` only for active targets. Unsubscription does not remove a target, so returning to an opened View does not rebuild it.
|
||||
|
||||
[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store with identity-stable Node and Turn-process sources, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields.
|
||||
[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store with identity-stable Node and Turn-process sources, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsPills and mirrored into top-level public compatibility fields.
|
||||
|
||||
Only a new key or a change to `anchorSeq`, visibility, or Location identity makes a Chat update structural. An ordinary content change does not rebuild `order`; the keyed Node store replaces that key's value and publishes only its source. The Turn-process projector recalculates cross-Node presentation only for a Turn whose structure, specification, or status changed, then publishes only that Turn's process sources.
|
||||
|
||||
@@ -424,4 +424,4 @@ Inbox Context retention grows with splice count and claimed message count rather
|
||||
|
||||
The cost is new Runtime contracts for Registry, Assembler, Location data, dependency replay, and per-target Builders, plus parent-owned common inject and per-occurrence `hookContext` in UI Slots. Definitions that consume Assistant deltas also maintain equivalent scalar and packed update branches. Definition authors must understand stable IDs, unique scalar starts, forward replay, Step→Turn publication order, read-only Reader access, and the prohibition on Node withdrawal.
|
||||
|
||||
`useTurnData()` does not revoke the standard `useSession` capability from session-scoped renderers, so this boundary relies on API guidance and tests rather than capability isolation. Registry changes remain low-frequency full rebuilds; the Chat Builder still maintains a legacy slice for StatsLine and the top-level public fields, while Trajectory owns target-specific Definitions and a Builder over the shared Session window. Built-in Definitions remain in their respective UI packages, and these compatibility boundaries do not return business interpretation to Session.
|
||||
`useTurnData()` does not revoke the standard `useSession` capability from session-scoped renderers, so this boundary relies on API guidance and tests rather than capability isolation. Registry changes remain low-frequency full rebuilds; the Chat Builder still maintains a legacy slice for StatsPills and the top-level public fields, while Trajectory owns target-specific Definitions and a Builder over the shared Session window. Built-in Definitions remain in their respective UI packages, and these compatibility boundaries do not return business interpretation to Session.
|
||||
+2
-2
@@ -315,7 +315,7 @@ Session binding 可用、缓存的 binding 成为 current 或 View roster 变化
|
||||
|
||||
普通 prepend 与 append flush 只对 active target 调用 `apply({ upserts, timeline })`。完整 window replace 与 Registry rebuild 只对 active target 调用 `replace()`。取消订阅不会移除 target,因此返回已打开的 View 不会重建。
|
||||
|
||||
[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、带身份稳定 Node 与 Turn-process source 的 keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。
|
||||
[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、带身份稳定 Node 与 Turn-process source 的 keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsPills 使用并镜像到顶层公共兼容字段的 `legacy` slice。
|
||||
|
||||
Chat 结构变化只由新 key、`anchorSeq`、visibility 或 Location identity 变化触发。普通内容变化不重建 `order`;keyed Node store 只替换该 key 的 value 并发布其 source。Turn-process projector 仅为结构、规格或状态发生变化的 Turn 重算跨 Node 呈现,再只发布该 Turn 的 process source。
|
||||
|
||||
@@ -424,4 +424,4 @@ Inbox Context 的保留量随 splice 数和已 claim 消息数增长,不再随
|
||||
|
||||
代价是 Runtime 新增 Registry、Assembler、Location data、依赖重放和 per-target Builder 契约,UI Slots 也新增 parent-owned common inject 与 per-occurrence `hookContext`。消费 Assistant delta 的 Definition 还需要维护等价的 scalar 与 packed update 分支。Definition 作者必须理解稳定 ID、唯一 scalar start、正序 replay、Step→Turn 发布顺序、只读 Reader 和 Node 不撤回规则。
|
||||
|
||||
`useTurnData()` 不撤销 session-scoped renderer 的标准 `useSession`,因此该边界依靠 API 引导和测试,而不是能力隔离。Registry 变化仍是低频完整 rebuild;Chat Builder 继续为 StatsLine 和顶层公共字段维护 legacy slice,Trajectory 则在共享 Session 窗口上拥有 target 专属 Definition 与 Builder。内建 Definition 分别留在所属 UI package;这些兼容边界不把业务解释权交还给 Session。
|
||||
`useTurnData()` 不撤销 session-scoped renderer 的标准 `useSession`,因此该边界依靠 API 引导和测试,而不是能力隔离。Registry 变化仍是低频完整 rebuild;Chat Builder 继续为 StatsPills 和顶层公共字段维护 legacy slice,Trajectory 则在共享 Session 窗口上拥有 target 专属 Definition 与 Builder。内建 Definition 分别留在所属 UI package;这些兼容边界不把业务解释权交还给 Session。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
|
||||
2026-08-10-session-log-version-mechanism.md: 0f7f70b5ad6ecb2445729b1aa61fb3b295fc4ddb
|
||||
2026-08-10-session-log-version-mechanism.zh.md: a6d58505ad9fdb6068a1afe48f7250f020d20a9e
|
||||
2026-08-10-session-log-version-mechanism.md: 513b126d3b00841f71893715cc296cb67619543e
|
||||
2026-08-10-session-log-version-mechanism.zh.md: 919bfb077c7037da2ebf7eb81b75745d6b4fe4d0
|
||||
@@ -16,7 +16,13 @@ Session logs must be upgradable after release, and the runtime that ships first
|
||||
|
||||
**Read rules by direction.** Equal version: read normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: every event-body operation first runs the complete adjacent chain in memory and leaves the source path, bytes, and inode unchanged. Read handles may consume that current logical result directly; a write open exclusively publishes the final current generation under its canonical versioned filename before append. Header-only listing remains non-mutating and reports the numerically highest canonical generation. Catalog generation and module initialization reject a missing adjacent step, so a published first-party build never exposes a partial historical chain. Retained lower generations are not automatic fallback or a downgrade compatibility promise.
|
||||
|
||||
**A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the three `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example).
|
||||
**A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the four `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example).
|
||||
|
||||
### Writer and publication authority
|
||||
|
||||
`SESSION_FORMAT_VERSION` owns the checkout writer number; the [release-status reference](../../../../docs/session-format-status.md) owns one bilingual `latestReleasedVersion` and `evidenceTag` record. Publication changes independently of source development, so status is derived by comparing those facts rather than maintaining a second `released` boolean. General documentation links to these authorities; fixed-version contracts and historical evidence keep their explicit numbers.
|
||||
|
||||
The [documentation-standard check](../../../../scripts/doc-standard.spec.ts) validates record structure, bilingual equality, evidence-link consistency, and the local release/writer ordering without network access. It proves internal consistency, not publication or freshness. The release operator verifies publication and updates the record after a higher format ships, as required by the [release process](../process/2026-08-10-npm-release-sequences.md). This keeps compatibility review independent of credentials and GitHub availability while making the manual freshness obligation explicit.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -28,3 +34,5 @@ What shipped in v0 (release 0812): direction-aware refusal with the raw-log path
|
||||
- **Default-ignorable unknown events** — inverts the failure mode of a forgotten marker from visible over-refusal into silent corruption.
|
||||
- **Migrating during header-only listing** — makes cheap inventory mutate storage and requires event bodies to compute facts that a header cannot prove. Listing returns descriptors; event-body reads own publication.
|
||||
- **Per-plugin runtime registration of known event types** — rejected because it would make the known set composition-dependent and register event names without classifying whether omission is safe. The persisted `ignorable` marker keeps that classification with each record; the [external-plugin retention decision](2026-08-30-retain-ignorable-external-session-events.md) owns the current consumer constraint.
|
||||
- **Duplicate release flags or runtime status services** — introduce another mutable authority for a maintainer fact that does not control Session execution. The writer constant and publication record suffice.
|
||||
- **Network-dependent documentation gates or publication automation** — network queries would couple local documentation checks to credentials and GitHub availability; a runtime service or publication workflow change is unnecessary for record consistency. Publication verification remains an explicit release-operator obligation.
|
||||
+9
-1
@@ -16,7 +16,13 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决
|
||||
|
||||
**读取规则按方向区分。**版本相等:正常读。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:每个事件正文操作先在内存中运行完整相邻链,并保持源路径、字节与 inode 不变。读句柄可以直接使用该 current 逻辑结果;写 open 则在 append 前把最终 current generation 排他发布到其规范版本文件名。仅 header 的列表保持不变更,并报告数值最高的规范 generation。catalog 生成与模块初始化会拒绝缺失的相邻步骤,因此已发布第一方 build 绝不会暴露不完整历史链。保留的低 generation 不是自动 fallback,也不构成 downgrade compatibility 承诺。
|
||||
|
||||
**逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经三种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。
|
||||
**逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经四种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。
|
||||
|
||||
### 写入器与发布真源
|
||||
|
||||
`SESSION_FORMAT_VERSION` 拥有工作区写入器版本号;[发布状态参考](../../../../docs/session-format-status.zh.md)拥有唯一的双语 `latestReleasedVersion` 与 `evidenceTag` 记录。发布状态独立于源码开发而变化,因此通过比较这两个事实推导状态,而不另行维护 `released` 布尔值。一般文档链接到这些真源;固定版本约定与历史证据保留明确版本号。
|
||||
|
||||
[文档标准检查](../../../../scripts/doc-standard.spec.ts)在不访问网络的情况下,校验记录结构、双语一致性、证据链接一致性及本地发布版本与写入器版本的大小关系。它证明内部一致性,而非发布事实或记录新鲜度。[发布流程](../process/2026-08-10-npm-release-sequences.zh.md)要求发布操作者在更高格式交付后核实发布并更新记录。这让兼容性评审不依赖凭据与 GitHub 可用性,同时明确人工维护新鲜度的义务。
|
||||
|
||||
## 影响
|
||||
|
||||
@@ -28,3 +34,5 @@ v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径
|
||||
- **未知事件默认可忽略**:把忘写标记的后果从可见的过度拒绝反转成静默损坏。
|
||||
- **在仅 header 列表期间迁移**:让便宜清单改变存储,而且需要读取事件正文才能计算 header 无法证明的事实。列表返回 descriptor,事件正文读取负责发布。
|
||||
- **插件运行时注册已知事件类型**:不予采用,因为该方案会让已知集依赖插件组合,而且只注册事件名称,无法判定省略事件是否安全。持久化的 `ignorable` 标记把该分类保留在每条记录中;[外部插件保留决策](2026-08-30-retain-ignorable-external-session-events.zh.md)定义当前消费方约束。
|
||||
- **重复发布标记或运行时状态服务**:为不控制 Session 执行的维护信息增加另一个可变真源。写入器常量与发布记录已经足够。
|
||||
- **依赖网络的文档门禁或发布自动化**:网络查询会把本地文档检查耦合到凭据与 GitHub 可用性;记录一致性不需要运行时服务或发布工作流变更。核实发布仍是发布操作者的明确义务。
|
||||
+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-18-experimental-agent-teams-packages.md
|
||||
2026-08-18-experimental-agent-teams-packages.md: bff91f9c735f541eb6ef055a5bf70c31b5d88912
|
||||
2026-08-18-experimental-agent-teams-packages.zh.md: f3753a3be9679962c30b566a5a411ca568e1c982
|
||||
2026-08-18-experimental-agent-teams-packages.md: 8ccbd690882cac0a4dc844d253656681e600fb74
|
||||
2026-08-18-experimental-agent-teams-packages.zh.md: dd79d8f2171b545977b7b7e776463da0087724bc
|
||||
+14
-12
@@ -1,4 +1,4 @@
|
||||
# Agent Note: Incubate Agent Teams as private experimental packages
|
||||
# Agent Note: Publish Agent Teams under experimental package names
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,34 +6,36 @@ English | [中文](2026-08-18-experimental-agent-teams-packages.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Agent Teams needs the real Session log, subagent lifecycle, tools, examples, snapshots, and repository checks while its service and tool contracts continue to change. Placing those packages in a product-role group makes them members of the dsh release family and gives them the same publication expectation as stable packages.
|
||||
Agent Teams needs the real Session log, subagent lifecycle, tools, examples, snapshots, and repository checks while its service and tool contracts continue to change. Users also need to install the complete Team composition from npm without building a source checkout.
|
||||
|
||||
An experimental directory without a current package previously imposed placement, dependency, promotion, and release rules on no consumer. Agent Teams supplies the concrete consumer, but the directory needs mechanical release exclusion and dependency isolation rather than a documentation-only status.
|
||||
Moving the packages into product-role groups would remove their experimental names and imply stable-package ownership. Publishing every package under `packages/experimental/` would instead expose unrelated internal prototypes. The release policy needs an explicit Agent Teams exception while preserving the private default.
|
||||
|
||||
## Decision
|
||||
|
||||
`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, `packages/experimental/agent-team-profile`, `packages/experimental/client-ui-agent-team`, and `packages/experimental/agent-team-web-profile` are private workspace packages. The [experimental package rules](../../../../packages/experimental/AGENTS.md) own their npm names and promotion rename; this note owns their placement, release exclusion, and dependency isolation.
|
||||
`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, `packages/experimental/agent-team-profile`, `packages/experimental/client-ui-agent-team`, and `packages/experimental/agent-team-web-profile` are public workspace packages. They retain their existing `@deepseek-ai/dsh-experimental-*` names and join the dsh release family. The [experimental package rules](../../../../packages/experimental/AGENTS.md) own the private default, this exception, and later promotion.
|
||||
|
||||
The dsh pack and publish set and the local baseline publisher exclude every manifest below `packages/experimental/`. `release:dsh` still advances their manifest versions with the shared dsh version without creating release tags. Workspace constraints require each experimental package to set `private: true` and omit `publishConfig`. The same top-level check rejects `dependencies`, `optionalDependencies`, and `peerDependencies` from release packages, release apps, or the Python runtime to an experimental package. Experimental packages may depend on release packages and each other; tests may use them through `devDependencies`, and examples may load them explicitly.
|
||||
The dsh pack and publish set and the local baseline publisher include exactly these five experimental package directories. Workspace constraints require them to omit `private`, set `publishConfig.access` to `public`, and keep the experimental npm prefix. Every other experimental package remains private and excluded from publication by default. Release packages and apps outside the experimental group, plus the Python runtime, cannot name experimental packages in `dependencies`, `optionalDependencies`, or `peerDependencies`; experimental packages may depend on release packages and each other.
|
||||
|
||||
The generic caller-reserved continuable child identity and selective direct-child drain remain in the stable Subagent service. They own Subagent identity and Activation lifecycle without importing or naming Agent Teams; the experimental Team service consumes them in the permitted direction.
|
||||
|
||||
The private Host-side Agent Teams profile bundle depends on the Team packages and applies after `dsh-base`. It inserts the Team rows and disables the global continuable-child controls whose model-visible names overlap the Team tools. The separate private Web profile applies after `dsh-web-app` and the Host profile; it inserts the Team UI, which mounts the Remote contribution generated by the Team package. Both layers leave the shipped base, CLI, Web, and Python runtime dependency graphs unchanged.
|
||||
The published Host-side Agent Teams profile bundle depends on the Team packages and applies after `dsh-base`. It inserts the Team rows and disables the global continuable-child controls whose model-visible names overlap the Team tools. The separate published Web profile applies after `dsh-web-app` and the Host profile; it inserts the Team UI, which mounts the Remote contribution generated by the Team package. Both layers remain opt-in and leave the shipped base, CLI, Web, and Python runtime dependency graphs unchanged.
|
||||
|
||||
Profile startup resolves selected bundles before healing module fallbacks. The shared fallback retains the dsh installation's carrier-specific entries: symlinks under plain Node and ESM proxies in a packaged executable. Missing packages from selected bundle closures are linked under the current profile's own `node_modules`, while pnpm-managed profile entries remain authoritative. Closure discovery starts from each explicit external bundle's real package directory and traverses every listed root even when an earlier dependency has the same package name. It excludes dsh-owned profile projections from later discovery, so a projected dependency cannot feed back into its own closure. Link ownership compares canonical parent paths so junction-normalized targets remain removable. A private profile layer can therefore carry experimental plugin rows without adding those plugins to a release app, requiring profile users to install transitive packages directly, weakening packaged-runtime module identity, or changing another profile's resolution.
|
||||
Profile installation resolves each published bundle and its dependencies through the profile's package manager. The generic profile launcher then applies the selected layers without adding them to any shipped profile or changing another profile's resolution.
|
||||
|
||||
Experimental status changes publication and compatibility expectations only. The packages retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion requires review of the public contracts, limitations, test evidence, release payload, runtime dependents, and a named owner accepting stable-package obligations.
|
||||
Experimental status changes compatibility and support expectations, not publication for these five packages. They retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion still requires review of the public contracts, limitations, test evidence, runtime dependents, and a named owner accepting stable-package obligations.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep Agent Teams in a product-role group and describe it as opt-in.** Opt-in composition controls model behavior but does not exclude packages from publication or prevent stable packages from taking runtime dependencies on them.
|
||||
**Move Agent Teams into product-role groups.** This would remove the requested experimental npm names and imply stable-package ownership before the contracts have stabilized.
|
||||
|
||||
**Reserve an empty experimental group.** A directory without a current package has no owner or release mechanism to test. The group exists only while concrete packages need its enforced treatment.
|
||||
**Keep Agent Teams private and source-checkout only.** This preserves the simplest experimental policy but prevents users from installing the complete opt-in composition from npm.
|
||||
|
||||
**Publish every experimental package.** Unrelated prototypes remain internal-only and have not accepted a public package contract.
|
||||
|
||||
**Move the Subagent prerequisites into the experimental directory.** Child identity allocation and Activation teardown belong to the Subagent owner and contain no Team-specific contract. Moving or duplicating them would invert the dependency or split one lifecycle across packages.
|
||||
|
||||
## Consequences
|
||||
|
||||
Agent Teams can use the full repository graph and quality checks without entering official tarballs or becoming a supported runtime dependency. A release package cannot expose Team until the Team packages are promoted, so the CLI experiment installs an explicit private profile layer instead of changing shipped bundles. The generic profile launcher accepts that layer without making its plugin dependencies part of the dsh release closure.
|
||||
Agent Teams publishes as five installable tarballs in the dsh release family without changing package names or enabling Team in a shipped profile. Public availability does not make the packages stable or supported by default, and stable release packages cannot take runtime dependencies on them.
|
||||
|
||||
The product-role grouping is less direct while the packages incubate. Promotion creates path and npm-name churn as specified by the experimental package rules.
|
||||
The release family carries explicitly named experimental exceptions. Promotion still creates path and npm-name churn as specified by the experimental package rules.
|
||||
+14
-12
@@ -1,4 +1,4 @@
|
||||
# Agent Note: 将 Agent Teams 作为私有实验性包孵化
|
||||
# Agent Note: 以实验性包名发布 Agent Teams
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,34 +6,36 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
Agent Teams 的服务与工具约定仍在变化,但它需要使用真实 Session 日志、subagent 生命周期、工具、示例、快照和仓库检查。把这些包放在产品职责组会使其成为 dsh 发布系列成员,并获得与稳定包相同的发布预期。
|
||||
Agent Teams 的服务与工具约定仍在变化,但它需要使用真实 Session 日志、subagent 生命周期、工具、示例、快照和仓库检查。用户还需要直接从 npm 安装完整 Team 组合,而无需构建源码 checkout。
|
||||
|
||||
没有实际包的 experimental 目录曾经让没有消费方的放置、依赖、promotion 和发布规则长期存在。Agent Teams 提供了具体消费方,但该目录需要机械强制的发布排除与依赖隔离,不能只用文档标记状态。
|
||||
把这些包移入产品职责组会移除实验性名称,并暗示稳定包 owner 已经就位。发布 `packages/experimental/` 下的所有包又会暴露无关的内部原型。发布策略需要为 Agent Teams 设置显式例外,同时保留默认私有原则。
|
||||
|
||||
## 决策
|
||||
|
||||
`packages/experimental/agent-team`、`packages/experimental/tool-agent-team`、`packages/experimental/agent-team-profile`、`packages/experimental/client-ui-agent-team` 与 `packages/experimental/agent-team-web-profile` 是私有 workspace 包。[实验性包规则](../../../../packages/experimental/AGENTS.md)负责其 npm 名和 promotion 重命名;本记录负责其目录归属、发布排除与依赖隔离。
|
||||
`packages/experimental/agent-team`、`packages/experimental/tool-agent-team`、`packages/experimental/agent-team-profile`、`packages/experimental/client-ui-agent-team` 与 `packages/experimental/agent-team-web-profile` 是公开 workspace 包。它们保留现有 `@deepseek-ai/dsh-experimental-*` 名称并加入 dsh 发布系列。[实验性包规则](../../../../packages/experimental/AGENTS.md)负责默认私有原则、本例外与后续 promotion。
|
||||
|
||||
dsh pack 与 publish 集合以及本地 baseline 发布器均排除 `packages/experimental/` 下的所有 manifest。`release:dsh` 仍会让这些 manifest 跟随 dsh 共享版本递增,但不会创建发布 tag。workspace 约束要求每个实验性包设置 `private: true` 并省略 `publishConfig`。同一个顶层检查会拒绝发布包、发布 app 或 Python runtime 通过 `dependencies`、`optionalDependencies` 或 `peerDependencies` 依赖实验性包。实验性包可以依赖发布包和其他实验性包;测试可以通过 `devDependencies` 使用它们,示例可以显式加载它们。
|
||||
dsh pack 与 publish 集合以及本地 baseline 发布器只会纳入这五个实验性包目录。workspace 约束要求它们省略 `private`、设置 `publishConfig.access` 为 `public`,并保留实验性 npm 前缀。其他实验性包默认仍为私有且不发布。实验组外的发布包与 app 以及 Python runtime 不得通过 `dependencies`、`optionalDependencies` 或 `peerDependencies` 引用实验性包;实验性包可以依赖发布包和其他实验性包。
|
||||
|
||||
通用的调用方预留 continuable child 身份和精确 direct-child drain 仍属于稳定 Subagent 服务。它们负责 Subagent 身份与 Activation 生命周期,不 import 或命名 Agent Teams;实验性 Team 服务沿允许的方向消费这些能力。
|
||||
|
||||
私有 Host 侧 Agent Teams profile bundle 依赖 Team 包,并在 `dsh-base` 之后应用。它会插入 Team 配置行,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control。独立的私有 Web profile 在 `dsh-web-app` 与 Host profile 之后应用;它会插入 Team UI,后者挂载 Team package 生成的 Remote contribution。两个层都保持已发布 base、CLI、Web 与 Python runtime 的依赖图不变。
|
||||
公开发布的 Host 侧 Agent Teams profile bundle 依赖 Team 包,并在 `dsh-base` 之后应用。它会插入 Team 配置行,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control。独立公开发布的 Web profile 在 `dsh-web-app` 与 Host profile 之后应用;它会插入 Team UI,后者挂载 Team package 生成的 Remote contribution。两个层都保持显式启用,不改变随附 base、CLI、Web 与 Python runtime 的依赖图。
|
||||
|
||||
profile 启动会先解析所选 bundle,再修复模块 fallback。共享 fallback 保留 dsh 安装的载体专用条目:普通 Node 下使用 symlink,打包 executable 中使用 ESM proxy。仅由所选 bundle 闭包携带的缺失包会链接到当前 profile 自己的 `node_modules` 下,而 pnpm 管理的 profile 条目仍具有优先权。闭包发现从每个显式外部 bundle 的真实包目录开始;即使前一个依赖具有相同包名,也会遍历所有列出的根。后续发现会排除 dsh 所有的 profile projection,避免投影后的依赖重新进入自己的闭包。link ownership 通过规范化父路径比较,使 junction 规范化后的 target 仍可删除。因此,私有 profile 层可以携带实验性 plugin 配置行,而无需把这些 plugin 加入发布 app、要求 profile 用户直接安装传递依赖、破坏 packaged-runtime 的模块身份,或改变其他 profile 的解析结果。
|
||||
profile 安装通过自身 package manager 解析每个公开 bundle 及其依赖。通用 profile launcher 随后应用所选层,不会把它们加入任何随附 profile,也不会改变其他 profile 的解析结果。
|
||||
|
||||
实验性状态只改变发布与兼容性预期。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前必须评审公开约定、限制、测试证据、发布 payload、运行时依赖方,并由一名具名 owner 接受稳定包义务。
|
||||
对这五个包而言,实验性状态改变兼容性与支持预期,而不阻止发布。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前仍须评审公开约定、限制、测试证据、运行时依赖方,并由一名具名 owner 接受稳定包义务。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**把 Agent Teams 留在产品职责组,并标为显式启用。** 显式启用的组合可以控制模型行为,但不会阻止包发布,也不能阻止稳定包对其建立运行时依赖。
|
||||
**把 Agent Teams 移入产品职责组。** 这会移除要求保留的实验性 npm 名称,并在约定稳定前暗示已有稳定包 owner。
|
||||
|
||||
**预留空的 experimental 组。** 没有实际包的目录没有 owner,也没有可供测试的发布机制。只有具体包需要这套强制处理时,该组才存在。
|
||||
**让 Agent Teams 保持私有且仅供源码 checkout 使用。** 这会保留最简单的实验性策略,但用户无法从 npm 安装完整 opt-in 组合。
|
||||
|
||||
**发布所有实验性包。** 其他原型仍只供内部使用,也没有接受公开包约定。
|
||||
|
||||
**把 Subagent 前置能力移入 experimental 目录。** child 身份分配与 Activation teardown 属于 Subagent owner,且不包含 Team 专用约定。移动或复制这些能力会反转依赖方向,或把同一个生命周期拆到多个包中。
|
||||
|
||||
## 后果
|
||||
|
||||
Agent Teams 可以使用完整仓库依赖图与质量检查,而不进入正式 tarball,也不会成为受支持的运行时依赖。在 Team 包 promotion 前,发布包不能暴露 Team,因此 CLI 实验会安装显式的私有 profile 层,而不是修改已发布 bundle。通用 profile launcher 可以接受该层,而不会让它的 plugin 依赖进入 dsh 发布闭包。
|
||||
Agent Teams 会作为 dsh 发布系列中的五个可安装 tarball 发布,同时保持包名不变,也不会在随附 profile 中启用 Team。公开可用不代表这些包稳定或默认受支持,稳定发布包也不能对其建立运行时依赖。
|
||||
|
||||
孵化期间的产品职责分组不够直接。promotion 会按照实验性包规则产生路径和 npm 名改动。
|
||||
发布系列需要维护显式命名的实验性例外。promotion 仍会按照实验性包规则产生路径和 npm 名改动。
|
||||
+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-23-client-derived-tool-presentation.md
|
||||
2026-08-23-client-derived-tool-presentation.md: 58a8f23d717580355b852703f448c723d9c3a7ea
|
||||
2026-08-23-client-derived-tool-presentation.zh.md: 8e1a02eff57cce7967985a22c5bdcec7c18e6059
|
||||
2026-08-23-client-derived-tool-presentation.md: 6b19dc881d572bfece345cbbd5eca688b2e05aab
|
||||
2026-08-23-client-derived-tool-presentation.zh.md: 98ada31627398f317e4c41336af56e8c6cf0837e
|
||||
+1
-1
@@ -248,7 +248,7 @@ Host presenter APIs describe top-level calls and results. Code Dispatch subcalls
|
||||
|
||||
Code Dispatch start and result events already carry `parentCallId`. Conversation preserves that existing fact on each child `ToolCallBlock`; root Session calls omit it. The diff, read, search, and web models accept only blocks without `parentCallId`; the terminal model and existing renderers that intentionally support nested calls accept child blocks.
|
||||
|
||||
The Details panel delegates the selected block unchanged. Shared card models apply the same terminal eligibility and nonterminal child restrictions in rows and Details, so the Details slot needs no placement field.
|
||||
Shared card models apply the same terminal eligibility and nonterminal child restrictions wherever a block renders, so no second presentation surface needs a placement field; the details panel that once delegated a selected block was removed with the right-hand details column ([decision](../feature/2026-09-04-right-sidebar-docking-infrastructure.md)).
|
||||
|
||||
The keyed slot continues dispatching every subcall by its real tool name. `parentCallId` restricts only the diff, read, search, and web structured models covered by this decision. Existing specialized renderers such as Skill and Cordis, which already read raw blocks, remain unchanged.
|
||||
|
||||
|
||||
+1
-1
@@ -248,7 +248,7 @@ Host presenter API 描述顶层 call/result。本决定覆盖的 diff、read、s
|
||||
|
||||
Code Dispatch start 与 result event 已经携带 `parentCallId`。Conversation 在每个 child `ToolCallBlock` 上保留这项现有事实,root Session call 则不携带它。diff、read、search 和 web model 只接受没有 `parentCallId` 的 block;terminal model 与原本有意支持嵌套调用的 renderer 接受 child block。
|
||||
|
||||
Details panel 原样委托选中的 block。共享 card model 在行与 Details 中应用相同的 terminal 适用规则和非 terminal 子调用限制,因此 Details slot 不需要 placement 字段。
|
||||
共享的 card model 在 block 渲染到哪里都施加同样的终端资格与非终端子调用限制,因此不需要第二个展示面带 placement 字段;曾经原样委托选中 block 的详情面板已随右侧详情列一并删除([决策](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md))。
|
||||
|
||||
keyed slot 仍按每个子调用的真实 tool name 分发;`parentCallId` 只限制本决定覆盖的 diff/read/search/web 结构化模型。Skill、Cordis 等已经直接读取 raw block 的专用 renderer 保持现状。
|
||||
|
||||
|
||||
+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-23-locale-owned-client-ui-copy.md
|
||||
2026-08-23-locale-owned-client-ui-copy.md: 623189b8faa45f0ef2a1b724d3e050d33f7d59bd
|
||||
2026-08-23-locale-owned-client-ui-copy.zh.md: e2a6f49de89e6e6ba2734dd69cf536a3e13d2aea
|
||||
2026-08-23-locale-owned-client-ui-copy.md: bc4d8efbce1f246f712e3afe6af40d1518e8cb17
|
||||
2026-08-23-locale-owned-client-ui-copy.zh.md: ce996a4aafc392e739bec2eccd8714cfeff7d8e9
|
||||
@@ -12,6 +12,8 @@ Typed locale namespaces and bilingual dictionary parity proved that registered d
|
||||
|
||||
**Locale dictionaries own all product-authored client UI wording.** Visible text, accessibility names, tooltips, placeholders, empty states, status labels, units, and formatting templates reach presentation through a typed `t` seat or an already-localized prop. A value authored by a user, model, provider, plugin, wire peer, or operating system remains data and renders verbatim; protocol tags, tool names, paths, URLs, JSON/JavaScript literals, and stable internal ids are not translated.
|
||||
|
||||
Product-owned catalog descriptions follow the same rule. The client maps an exact built-in provider, model, and description to a locale key; a changed description or an external provider description remains provider data and renders verbatim.
|
||||
|
||||
**Cordis-free primitives require complete localized copy props and own no language fallback.** `MarkdownText`, `JsonTree`, `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, `CodeBlock`, `JsonBlock`, `HoverCard`, and `ConnectionIndicator` receive their chrome from the feature render site. This preserves the primitive package's runtime independence while making omission a type error instead of silently selecting Chinese or English. Shared words live in the `common` namespace; feature-specific phrases stay with the feature that decides their meaning.
|
||||
|
||||
**Localized display text is never an identity.** Models and stores retain discriminants, stable ids, and non-display markers. Renderers translate after matching, and request maps carry stable group membership into the trajectory ledger. A client-synthesized error that must survive in a view model uses a stable marker and is translated only when displayed. Language switching therefore changes wording without changing selection, grouping, search identity, or lifecycle state.
|
||||
@@ -22,7 +24,7 @@ The product-authored error and design-literal exclusions, primitive defaults, an
|
||||
|
||||
## Verification
|
||||
|
||||
The AST check's own Vitest spec pins direct JSX, template branches, semantic copy props, label data, formatter returns, locale-key calls, structural attributes, and dictionary owners. Locale dictionary parity pins identical `zh`/`en` keys. Client component suites exercise both direct translated seats and locale-prop adapters, and the assembled web replay plus the required real-server GIF demonstrate the shipped locale switch on the actual trajectory surface.
|
||||
The AST check's own Vitest spec pins direct JSX, template branches, semantic copy props, label data, formatter returns, locale-key calls, structural attributes, and dictionary owners. Locale dictionary parity pins identical `zh`/`en` keys. Client suites exercise direct translated seats, locale-prop adapters, and built-in catalog-description localization without altering external descriptions. The assembled web replay plus the required real-server GIF demonstrate the shipped locale switch on the actual trajectory surface.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ typed locale namespace 与双语字典对等性可以证明已注册字典完整
|
||||
|
||||
**所有产品编写的 client UI 措辞都由 locale 字典持有。** 可见文本、无障碍名称、tooltip、placeholder、空状态、状态标签、单位和格式模板必须经 typed `t` 席位或已本地化 prop 到达展示层。由用户、模型、提供方、插件、wire 对端或操作系统编写的值仍是数据并原样渲染;协议 tag、工具名称、路径、URL、JSON/JavaScript 字面量和稳定内部 id 不翻译。
|
||||
|
||||
产品持有的目录说明遵循同一规则。client 将完全匹配的内置提供方、模型与说明映射到 locale key;说明发生变化或来自外部提供方时,它仍是提供方数据并原样渲染。
|
||||
|
||||
**Cordis-free 原子组件要求完整的本地化文案 prop,且自身不持有语言回落值。** `MarkdownText`、`JsonTree`、`TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock`、`CodeBlock`、`JsonBlock`、`HoverCard` 与 `ConnectionIndicator` 的 chrome 均由功能渲染点传入。这样既保留原子组件包的运行时独立性,也让遗漏成为类型错误,而不是静默选择中文或英文。共享用词进入 `common` namespace;功能专属短语留在决定其语义的功能侧。
|
||||
|
||||
**本地化展示文本绝不承担身份。** 模型与存储保留判别字段、稳定 id 和非展示 marker。渲染器先匹配再翻译,请求映射通过稳定的组成员关系进入 trajectory ledger。必须保存在视图模型中的 client 合成错误使用稳定 marker,只在展示时翻译。因此语言切换只改变措辞,不改变选择、分组、搜索身份或生命周期状态。
|
||||
@@ -22,7 +24,7 @@ typed locale namespace 与双语字典对等性可以证明已注册字典完整
|
||||
|
||||
## Verification
|
||||
|
||||
AST 检查自身的 Vitest spec 固定直接 JSX、模板分支、语义文案 prop、label 数据、格式化函数返回值、locale key 调用、结构属性和字典 owner。locale 字典对等性固定 `zh`/`en` key 一致。client 组件测试同时覆盖直接翻译席位与 locale prop 适配器;组装 web 回放和规定的真实服务器 GIF 在实际 trajectory 界面上展示发布的语言切换。
|
||||
AST 检查自身的 Vitest spec 固定直接 JSX、模板分支、语义文案 prop、label 数据、格式化函数返回值、locale key 调用、结构属性和字典 owner。locale 字典对等性固定 `zh`/`en` key 一致。client 测试覆盖直接翻译席位、locale prop 适配器,以及不改变外部说明的内置目录说明本地化。组装 web 回放和规定的真实服务器 GIF 在实际 trajectory 界面上展示发布的语言切换。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+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-25-electron-desktop-packaging-and-updates.md
|
||||
2026-08-25-electron-desktop-packaging-and-updates.md: 6d22777c8913911dbb1d89c09de9e891636873c8
|
||||
2026-08-25-electron-desktop-packaging-and-updates.zh.md: 5108492ba6f029847735f570aa77c2cb505f981c
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
# Agent Note: Package and update the Electron desktop application
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-25-electron-desktop-packaging-and-updates.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
DeepSeek Harness needs an Electron desktop application that reuses the Web UI, works without system Node.js or pnpm, installs dsh and desktop plugins through an application-bundled pnpm, and updates the complete desktop release through one user-facing flow.
|
||||
|
||||
The desktop application and an npm-installed dsh share the `.dsh` data root, but they may have different dsh and plugin versions. They must share supported product data without sharing executable packages, lockfiles, `node_modules`, plugin activation, or package-manager configuration.
|
||||
|
||||
The current GUI protocol binds the Web client and backend release. Independently versioning the Electron artifact and its pnpm-installed dsh would create unqualified shell, client, backend, and plugin combinations and make update availability ambiguous.
|
||||
|
||||
## Decision
|
||||
|
||||
Ship a small Electron shell with a bundled upstream Node.js executable and pinned pnpm. Electron starts the private Desktop Host package as an isolated child process; that package composes the installed dsh backend and matching client graph. Fetch metadata and bounded raw request and response chunks travel over two versioned framed byte pipes, Node IPC is reserved for readiness, fatal failure, and shutdown, and Electron serves validated assets through `dsh-app://`; it opens no listening port. Each frame carries a fixed marker, type, monotonic stream id, payload length, and validated payload. Serialized writers honor pipe drain, readers pause globally when a request or response stream applies backpressure, cancellation closes the matching stream, and late response frames for a retired stream stay inert. The Connection plugin provides its carrier-neutral RPC and Fetch registries without requiring `webServer`, while Client Modules provides the exact advertised combo-bundle responses to the shell-owned carrier; Web compositions attach their optional HTTP routes for both. The renderer keeps the same Fetch, RPC, and Remote-stream formats, while the child carrier avoids Base64 expansion and V8 serialization compatibility between Electron and the bundled upstream Node.js. Electron closes its request-pipe writer after sending shutdown, releasing an in-flight Windows pipe read before it waits for child exit. This follows the Electron reservation in the [GUI layering and RPC protocol note](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md).
|
||||
|
||||
Electron owns the reserved profile at `.dsh/profiles/desktop`. Its exact `@deepseek-ai/dsh` dependency supplies the backend and matching Web UI, while the matching private `@deepseek-ai/dsh-desktop-host` dependency supplies only the Electron child-process entry and composition overlay. The dsh release, private Host, and their first-party dependency closures use local npm tarballs packed from the same source build; the profile manifest lists every core package as a local `file:` dependency, and `pnpm-workspace.yaml` repeats the mapping as overrides. The Host remains outside the public CLI package and is never published to npm. Desktop plugins are additional registry npm dependencies and ordered `dsh.profile.bundles` entries in the same profile, and resolve from its one `node_modules`.
|
||||
|
||||
One Desktop release number identifies the Electron artifact and its exact `@deepseek-ai/dsh` and `@deepseek-ai/dsh-desktop-host` dependencies. A release cannot select a different core version at build or runtime. Updating dsh therefore requires a new Electron release even when shell code is unchanged.
|
||||
|
||||
The browser Web UI, dsh backend, existing `dsh plugin` CLI, user npm, and user pnpm cannot mutate this profile. The CLI reserves every case variant of the `desktop` name and rejects boot, config-dump, and plugin-management requests for it. Electron acquires its process-lifetime single-instance lock before project recovery or Host startup; later launches focus or recreate the primary window without touching profile state. An Electron-only GUI sends structured install, remove, and update requests through preload; Electron invokes only its bundled pnpm.
|
||||
|
||||
## Ownership
|
||||
|
||||
| Owner | Responsibility |
|
||||
|---|---|
|
||||
| Electron shell | Window and child lifecycle, framed byte pipes, lifecycle IPC, custom protocol, reserved desktop profile, plugin GUI, update coordination, rollback |
|
||||
| Bundled Node.js and pnpm | Execute dsh and install exact desktop-project dependencies without consulting user `PATH` or pnpm state |
|
||||
| Desktop profile | One dependency graph, ordered bundle list, and `node_modules` for the desktop dsh package and desktop plugins |
|
||||
| Private Desktop Host package | Electron-only child-process entry and composition overlay installed with dsh but excluded from the public CLI package and npm publication |
|
||||
| Installed dsh package | Backend, matching Web UI, boot manifest, client bundles, and product behavior |
|
||||
| Shared `.dsh` owners | Sessions, settings, credentials, workspaces, and storage, guarded by their existing locks and format versions |
|
||||
| npm-installed dsh | Its own executable installation and user-managed profiles; no access to the reserved desktop profile or package state |
|
||||
|
||||
The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandbox: true`. Preload exposes typed RPC, lifecycle, update, locale, and desktop-plugin actions rather than raw `ipcRenderer`, filesystem access, shell commands, or pnpm arguments. Electron selects a typed English or Chinese dictionary from its application locale and falls back to English; menus, native dialogs, and the plugin-management renderer use that locale-owned copy.
|
||||
|
||||
## Filesystem layout
|
||||
|
||||
```text
|
||||
~/.dsh/
|
||||
desktop/
|
||||
staging/<transaction-id>/profile/
|
||||
rollback/profile/
|
||||
pending.json
|
||||
lock
|
||||
pnpm/
|
||||
store/
|
||||
cache/
|
||||
state/
|
||||
config/
|
||||
profiles/
|
||||
desktop/
|
||||
package.json
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
desktop-release.json
|
||||
desktop-packages.json
|
||||
desktop-packages/
|
||||
node_modules/
|
||||
sessions/
|
||||
storages/
|
||||
```
|
||||
|
||||
`.dsh/profiles/desktop` is the only active desktop profile. Its package manifest records the built-in and installed plugin bundle order; Electron alone mutates its dependencies, lockfile, and `node_modules`. Production startup rejects a bundle resolved outside this profile, including the CLI-maintained `.dsh/profiles/node_modules` fallback. Package content installed for the desktop profile uses `.dsh/desktop/pnpm/store`.
|
||||
|
||||
## Installation and resolution
|
||||
|
||||
The installer never mutates the active profile in place. It copies profile metadata into a transaction staging directory and applies an exact dependency change with the bundled pnpm. Before testing staging, Electron stops the active backend; it starts and stops the staged backend alone, then restores the active backend before activation, so two Desktop backends never concurrently share `.dsh` state. Activation stops the backend again, persists each next `pending.json` phase before its corresponding filesystem move, moves the active profile to `rollback/profile`, moves staging into `.dsh/profiles/desktop`, and restarts. Recovery combines the write-ahead phase with the actual active, rollback, and staging directories so either write-to-move interruption retains or restores a complete profile.
|
||||
|
||||
The process-lifetime Electron lock is the authoritative Desktop owner. The package transaction lock is depth defense and records the process that can still mutate package state: Electron between package operations and the spawned pnpm PID while pnpm runs. The owner change is truncated, written, and synchronized through the already-open exclusive lock file. If Electron terminates during pnpm execution, a later process observes the live worker and refuses to start a competing store or staging transaction; after that worker exits, the stale PID can be recovered.
|
||||
|
||||
The packaged seed is an offline installation kit, not an executable dsh tree. It contains the release identity, initial desktop-project manifest, a descriptor and immutable tarballs for the union of the first-party package closures rooted at dsh and the private Desktop Host, lockfile, integrity inventory, and required store subset. Each `mac-arm64`, `mac-x64`, and `win-x64` build owns its packed packages, runtime, package set, seed, pnpm preparation state, unpacked application, update metadata, and final artifacts under `.desktop-build/targets/<target>`; only the immutable, checksum-verified Node.js download cache is shared. The release build requires the Electron package, root dsh package, and private Host package to have the same version, creates final npm tarballs from the official source build, locally packs the private Host, selects the reachable dsh, Host, and vendored packages plus the Landlock entry, and verifies that the Host tarball contains `lib/index.js` and `config/desktop.cordis.patch.yml`. The Host `files` manifest contains only that runtime entry and overlay, and the package is never published to npm. Public package tarballs remain the official `pnpm pack` results governed by each package's publication manifest; Desktop does not remove published declarations or otherwise create a second package-content policy. The seed manifest lists every selected package as a local direct dependency, automatic peer installation is disabled, and the workspace file overrides every selected first-party name to its local tarball. The target Node.js executes bundled pnpm, so pnpm's operating-system and CPU selection makes the materialized dependency graph and seed target-specific. Bundled pnpm disables its global virtual store, materializes external production dependencies from npm without lifecycle scripts, deletes `node_modules` and every temporary pnpm cache, config, and state directory, then performs a clean offline installation from the final store alone and checks the private Host entry and overlay. The build rejects any lockfile that resolves one of the local first-party names by registry version. Inventory generation follows removal of that second `node_modules` tree and temporary pnpm project registrations. Requiring both Host files before copying the package set and after offline installation prevents a release whose process entry loads but cannot compose its required overlay from reaching application signing.
|
||||
|
||||
The seed stores pnpm content in 16 deterministic uncompressed tar shards selected by normalized store path. Apple notarization inspects Mach-O code inside those archives, so macOS seed preparation stages every referenced Mach-O content-addressed object and runs at most four independent Developer ID signers concurrently with a secure timestamp and hardened runtime. A signer failure is observed only after every active signer exits and leaves the original CAS objects and package index unchanged. After all signers succeed, preparation writes each object at its new SHA-512 path and transactionally rewrites every base and side-effects file reference in pnpm's MessagePack SQLite index. A second offline installation proves that pnpm resolves the rewritten store; preparation then shards it, extracts the final archives, and verifies every embedded signature. Package paths and non-native bytes remain unchanged, and the seed retains bundled architecture variants because removing files would create a Desktop-specific package file set. Seed integrity covers the shard manifest and every archive before extraction. Startup validates archive paths, entry types, uniqueness, and counts, extracts every shard into a unique Desktop-owned staging directory, replaces matching immutable store files, and transactionally merges each pnpm store version's SQLite `package_index` into `.dsh/desktop/pnpm/store`. Seed records replace matching keys while records downloaded for Desktop plugins remain. An interrupted file merge may leave valid immutable cache content, but each SQLite merge is atomic, and profile installation and activation still require pnpm integrity and the complete health check.
|
||||
|
||||
Startup requires the packaged release identity to equal Electron's application version, then compares `.dsh/profiles/desktop/desktop-release.json` plus the installed dsh and Desktop Host packages with that release before launching the backend. It installs the new seed manifest and lockfile with `pnpm install --offline --frozen-lockfile --trust-lockfile` in staging. After Electron replacement, it restores every plugin bundle recorded in the active profile at its exact installed version through one offline pnpm add from the existing desktop store and metadata cache. The complete graph must pass the same health check before activation.
|
||||
|
||||
The plugin GUI performs registry npm-package operations equivalent to `pnpm add <package> --save-exact`, `pnpm remove <package>`, and exact-version update in staging. Every mutation retains the local core-package descriptor, tarballs, dsh and Desktop Host dependencies, and complete override map. Electron validates the installed package manifest and updates the profile's dependency and ordered bundle entries; no renderer request can choose the registry, install directory, lifecycle policy, or arbitrary pnpm flags.
|
||||
|
||||
The backend and Loader use `.dsh/profiles/desktop/package.json` as their profile manifest and npm resolution anchor. The shared profile loader composes its ordered bundle entries, then the private Desktop Host applies its packaged overlay. The Host, dsh, Cordis, desktop plugins, plugin dependencies, and peer dependencies resolve through the ordinary pnpm `node_modules` graph. A desktop plugin contributing `dsh.client` code enters the boot manifest only after the complete profile passes health checking.
|
||||
|
||||
## Updates and recovery
|
||||
|
||||
Electron update uses one `electron-updater` release stream and signed `electron-builder` artifacts. Its version is the Desktop release version; there is no independent dsh manifest, compatibility range, or dsh-only update operation. A foreground install waits for an in-flight background check rather than reusing its result as an install result. The update dialog downloads and installs the Electron artifact, then restarts into the new release.
|
||||
|
||||
Before the new release opens a window, startup reconciles dsh from its packaged seed while retaining installed desktop plugins. The health check covers dependency resolution, native modules, shell API compatibility, backend startup and shutdown, Web assets, and the client boot graph. An incompatible plugin blocks activation and leaves the previous project available for rollback. Startup fails visibly rather than launching a shell and dsh version that do not match.
|
||||
|
||||
`DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm staging transaction remain separate operations.
|
||||
|
||||
## Security and release policy
|
||||
|
||||
Core dsh and the private Desktop Host come only from integrity-recorded local npm tarballs inside the signed Electron release; pnpm overrides prevent transitive core packages from falling back to a registry. Store archives are integrity-checked and fully validated in an isolated extraction directory before their files can enter writable package state. Plugin installation accepts registry package specs allowed by desktop policy but never raw pnpm commands. Exact versions, lockfile integrity, a reviewed `allowBuilds` set, user-only directory permissions, redacted diagnostics, and health checking are required before activation.
|
||||
|
||||
Electron artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Seed preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. Electron-builder then notarizes and staples the application and signs the DMG. The DMG artifact-completion hook separately notarizes and staples every DMG before requiring the configured identity, a valid ticket, and Gatekeeper acceptance; the upload event runs only after that hook succeeds. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
|
||||
|
||||
Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and seed-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
|
||||
|
||||
Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the Node.js binary, pnpm entry, seed, or active project.
|
||||
|
||||
The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compressed and 120–165 MB installed before the seed store subset. Architecture-specific builds must report actual component-level size deltas.
|
||||
|
||||
## Implementation
|
||||
|
||||
| Surface | Implementation |
|
||||
|---|---|
|
||||
| Shell | `apps/desktop` owns Electron windows, restricted preloads, the custom protocol, child lifecycle, project transactions, the plugin GUI, update coordination, and electron-builder configuration. |
|
||||
| Installed runtime | Private `@deepseek-ai/dsh-desktop-host` boots the portless desktop composition from the active project and streams API and asset responses over validated framed byte pipes. |
|
||||
| Package state | The release seed and every later mutation run through bundled Node.js and pnpm with desktop-owned store, config, cache, state, and home paths; core packages resolve from release tarballs while plugins resolve from the fixed npm registry. |
|
||||
| Qualification | macOS packaging requires the configured company identity and notary credentials, verifies every native seed object after final archive extraction, verifies the completed application signature, and requires notarization plus Gatekeeper acceptance for both the application and DMG. Windows packaging requires the configured public certificate, SafeNet private-key container, Token Password, and SignTool, and verifies every produced signature. Update hosting, previous-version installed-artifact tests, and platform GUI recordings remain release-environment gates. |
|
||||
|
||||
`dev:desktop` builds the current workspace, projects the built CLI and private Desktop Host packages plus their dependency links into a disposable project, uses an isolated Harness home, opens the Main, Renderer, and Host debuggers, and starts unpackaged Electron without preparing release resources. Package mutation is disabled in this mode because its linked dependency graph is not a pnpm-installed desktop project. Fixed macOS arm64, macOS x64, and Windows x64 package commands pass one target through runtime preparation, seed installation, and electron-builder; each also has an unpacked-directory variant for release-path verification before installer generation.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Use Electron's Node.js for dsh.** This saves package size but couples dsh to Electron's Node patches, fuses, native ABI, TLS behavior, and process lifecycle. A bundled upstream Node.js keeps dsh on its supported runtime.
|
||||
|
||||
**Carry Fetch bodies through JSON IPC as Base64.** JSON IPC keeps one message mechanism but expands every request and response body, constructs large strings in both processes, buffers each request before dispatch, and double-encodes image bytes already represented as Base64 inside RPC JSON. Raw framed pipes retain an explicit versioned protocol without relying on Electron and upstream Node.js to share V8 serialization behavior.
|
||||
|
||||
**Bake the product Web UI into Electron.** Independent UI and backend updates would require a new versioned compatibility program. Installing backend and Web UI from the same dsh package preserves the current release binding.
|
||||
|
||||
**Reuse the existing CLI or browser plugin installer.** That crosses the desktop authorization and release scope and can use the user's package-manager state. Desktop package mutation remains exclusively Electron-owned.
|
||||
|
||||
**Let the desktop profile use CLI-managed packages or plugins.** Either product could change the other's dependency graph, Cordis version, plugin version, or native module. The desktop profile therefore owns a complete `node_modules` and rejects bundle resolution through the CLI profile fallback.
|
||||
|
||||
**Install dsh and plugins into separate desktop projects.** This creates a second resolution anchor and peer-dependency fallback. One ordinary npm project already provides the required installation and resolution model.
|
||||
|
||||
**Remove non-target Mach-O files from registry packages.** Architecture pruning saves a small amount of seed space, but packages can deliberately ship several architecture variants and callers can observe their installed file set. Signing every shipped Mach-O object satisfies notarization without inventing a Desktop-specific package layout.
|
||||
|
||||
**Export the Windows EV private key in a PFX file.** The externally supplied public leaf certificate lets SignTool construct the signature while `/csp` and `/kc` locate the hardware key. The EV private key remains non-exportable on the token.
|
||||
|
||||
**Commit a credential-bearing signing script or persist the Token Password.** A credential-bearing CMD file, `.env`, or Windows user or system environment variable leaves the Token Password recoverable at rest. The checked-in CMD contains only environment-variable references, and the packaging step accepts the password as an ephemeral runner secret.
|
||||
|
||||
**Let electron-builder or a general directory sync publish directly.** A direct publisher can expose channel metadata before every referenced artifact exists, mix stale or cross-target files into a release, and cannot prove that the completed signed build still matches the current dsh version. A target-specific validated upload keeps publication ordering and release identity explicit.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A clean offline machine with no system Node.js or pnpm installs the seed into `.dsh/profiles/desktop` and starts a working dsh session.
|
||||
- The signed application inventories a fixed small set of seed store shards instead of every pnpm cache file; every Mach-O object inside the macOS shards has the release Developer ID, secure timestamp, and hardened runtime, every Windows artifact has the configured hardware-backed EV signature, and the installed private store retains the ordinary pnpm layout.
|
||||
- `.dsh/profiles/desktop/node_modules` contains and resolves the desktop dsh package and every GUI-installed desktop plugin.
|
||||
- Every desktop pnpm operation uses the bundled executable and `.dsh/desktop/pnpm/store`; none reads user `PATH`, config, store, or profile `node_modules`.
|
||||
- The Electron-only GUI installs, removes, and updates ordinary npm plugin packages without exposing raw pnpm arguments.
|
||||
- The backend and browser application cannot mutate desktop packages.
|
||||
- npm/CLI dsh and Electron never resolve or install plugins from each other's `node_modules`.
|
||||
- The active backend and Web UI report the same dsh version and a compatible shell API before the product window opens.
|
||||
- Failed installation, health checking, or update leaves the current profile usable or restores `rollback/profile` after restart.
|
||||
- One Desktop version binds Electron and dsh; every dsh update arrives through one Electron update dialog and one user-visible restart.
|
||||
- Shared `.dsh` data rejects incompatible readers before migration or mutation.
|
||||
- No loopback listener is opened, and the sandboxed renderer cannot access arbitrary filesystem or Electron APIs.
|
||||
- Workspace development runs current built code without downloading release resources, while unpacked-package verification retains the production installation path.
|
||||
- Windows release packaging requires the validated SignTool, EV token, matching public leaf certificate, Token Password, and explicit key container; it never falls back to an unsigned artifact or an exportable key file.
|
||||
- A target update cannot expose new channel metadata until the completed signed build and every referenced artifact pass release validation; retained historical artifacts remain available for differential updates.
|
||||
- Signed installed artifacts update successfully from the previous supported release on each release-blocking platform.
|
||||
|
||||
## Review decisions
|
||||
|
||||
| Decision | Recommendation |
|
||||
|---|---|
|
||||
| First launch | Bundle an offline seed store subset and install it through pnpm |
|
||||
| Desktop profile | One Electron-owned reserved profile containing exact dsh and plugin dependencies |
|
||||
| Plugin management | Electron-only GUI and package service; no CLI, backend, or browser installation path |
|
||||
| Activation | Staging project, complete health check, journaled directory replacement, one rollback copy |
|
||||
| Initial platforms | macOS arm64/x64 and Windows x64; Linux has no supported release target |
|
||||
| Update behavior | Background check, explicit confirmation before differential download and restart, startup dsh reconciliation |
|
||||
|
||||
## Risks
|
||||
|
||||
Plugin lifecycle scripts execute third-party code. The allowed registry, package policy, exact versions, integrity, `allowBuilds`, and diagnostics require security review before GUI installation ships.
|
||||
|
||||
Updating the bound dsh can invalidate plugin peer dependencies or native modules. pnpm resolution and full-project health checking must reject the staged project before replacing the active one.
|
||||
|
||||
An npm-installed dsh and desktop dsh may have different versions while sharing durable data. Each shared owner must enforce its format version and process lock before reading, migrating, or writing.
|
||||
|
||||
Directory replacement differs across operating systems and can be interrupted. The activation journal and installed-artifact fault tests must prove recovery at every filesystem move.
|
||||
|
||||
Code signing, notarization, and update hosting require production release infrastructure. Repository tests alone cannot complete that qualification.
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
# Agent Note: 打包并更新 Electron 桌面应用
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-25-electron-desktop-packaging-and-updates.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
DeepSeek Harness 需要一个复用 Web UI 的 Electron 桌面应用。该应用无需系统 Node.js 或 pnpm 即可工作,通过应用内置 pnpm 安装 dsh 与桌面插件,并通过一个面向用户的流程更新完整桌面发布。
|
||||
|
||||
桌面应用与通过 npm 安装的 dsh 共享 `.dsh` 数据根目录,但两者可能使用不同的 dsh 与插件版本。它们必须共享受支持的产品数据,同时不得共享可执行包、lockfile、`node_modules`、插件激活状态或包管理器配置。
|
||||
|
||||
当前 GUI 协议绑定 Web 客户端与后端版本。Electron 产物与其中通过 pnpm 安装的 dsh 如果独立定版本,就会产生未经验证的壳、客户端、后端与插件组合,也无法明确判断更新是否可用。
|
||||
|
||||
## 决策
|
||||
|
||||
交付一个小型 Electron 壳,其中内置上游 Node.js 可执行文件和固定版本的 pnpm。Electron 把私有 Desktop Host 包作为隔离子进程启动;该包组合已安装的 dsh 后端与匹配的客户端图。Fetch 元数据及有界的原始请求与响应分块通过两条带版本的分帧字节管道传递,Node IPC 只承载就绪、致命失败和关闭,Electron 通过 `dsh-app://` 提供经过验证的资源;它不会打开监听端口。每个帧都包含固定标记、类型、单调 stream id、负载长度和经过验证的负载。串行 writer 遵守 pipe drain,请求或响应 stream 施加背压时 reader 会全局暂停,取消会关闭匹配的 stream,已退役 stream 的迟到响应帧保持无效。Connection 插件无需 `webServer` 即可提供与载体无关的 RPC 与 Fetch 注册表,Client Modules 则向 shell-owned carrier 提供与广告内容完全一致的组合 bundle 响应;Web 组合为两者挂载可选 HTTP route。渲染进程保留相同的 Fetch、RPC 与 Remote-stream 格式,子进程载体则避免 Base64 膨胀,也不依赖 Electron 与内置上游 Node.js 之间的 V8 序列化兼容性。发送 shutdown 后,Electron 会关闭自己持有的请求管道写端,以便在等待子进程退出前释放 Windows 上仍在进行的管道读取。该设计沿用 [GUI 分层与 RPC 协议 Agent Note](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中的 Electron 预留。
|
||||
|
||||
Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepseek-ai/dsh` 依赖提供后端与匹配的 Web UI,匹配的私有 `@deepseek-ai/dsh-desktop-host` 依赖则只提供 Electron 子进程入口与组合 overlay。dsh 发布、私有 Host 及其第一方依赖闭包使用同一次源码构建生成的本地 npm tarball;profile manifest 把每个核心包列为本地 `file:` 依赖,`pnpm-workspace.yaml` 再通过 overrides 重复该映射。Host 不进入公共 CLI 包,也不会发布到 npm。桌面插件既是同一 profile 中来自 registry 的其他 npm 依赖,也是有序的 `dsh.profile.bundles` 条目,并从该 profile 唯一的 `node_modules` 解析。
|
||||
|
||||
一个 Desktop 发布号同时标识 Electron 产物及其精确的 `@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-desktop-host` 依赖。发布不能在构建或运行时选择不同的核心版本。因此,即使壳代码没有变化,更新 dsh 也必须产生新的 Electron 发布。
|
||||
|
||||
浏览器 Web UI、dsh 后端、现有 `dsh plugin` CLI、用户 npm 和用户 pnpm 都不能修改该 profile。CLI 保留 `desktop` 名称的所有大小写变体,并拒绝针对它的启动、配置 dump 和插件管理请求。Electron 在项目恢复或 Host 启动前获取进程生命周期单实例锁;后续启动只会聚焦或重建主窗口,不会接触 profile 状态。Electron-only GUI 通过 preload 发送结构化安装、删除和更新请求;Electron 只调用其内置 pnpm。
|
||||
|
||||
## 归属
|
||||
|
||||
| Owner | 职责 |
|
||||
|---|---|
|
||||
| Electron 壳 | 窗口与子进程生命周期、分帧字节管道、生命周期 IPC、自定义协议、保留 desktop profile、插件 GUI、更新协调、回滚 |
|
||||
| 内置 Node.js 与 pnpm | 执行 dsh 并安装桌面项目的精确依赖,不读取用户 `PATH` 或 pnpm 状态 |
|
||||
| Desktop profile | 为桌面 dsh 包与桌面插件提供一个依赖图、有序 bundle 列表和一个 `node_modules` |
|
||||
| 私有 Desktop Host 包 | 与 dsh 一起安装、但不进入公共 CLI 包或 npm 发布的 Electron 专用子进程入口与组合 overlay |
|
||||
| 已安装 dsh 包 | 后端、匹配的 Web UI、启动 manifest、客户端包和产品行为 |
|
||||
| 共享 `.dsh` owner | 会话、设置、凭据、工作区和存储,由其现有锁与格式版本保护 |
|
||||
| 通过 npm 安装的 dsh | 自己的可执行安装和用户管理的 profile;不能访问保留 desktop profile 或包状态 |
|
||||
|
||||
渲染进程使用 `nodeIntegration: false`、`contextIsolation: true` 和 `sandbox: true`。Preload 暴露类型化 RPC、生命周期、更新、locale 与桌面插件操作,而不暴露原始 `ipcRenderer`、文件系统访问、shell 命令或 pnpm 参数。Electron 根据应用 locale 选择类型化的中英文字典,并以英文作为 fallback;菜单、原生对话框与插件管理渲染进程使用这些由 locale 持有的文案。
|
||||
|
||||
## 文件系统布局
|
||||
|
||||
```text
|
||||
~/.dsh/
|
||||
desktop/
|
||||
staging/<transaction-id>/profile/
|
||||
rollback/profile/
|
||||
pending.json
|
||||
lock
|
||||
pnpm/
|
||||
store/
|
||||
cache/
|
||||
state/
|
||||
config/
|
||||
profiles/
|
||||
desktop/
|
||||
package.json
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
desktop-release.json
|
||||
desktop-packages.json
|
||||
desktop-packages/
|
||||
node_modules/
|
||||
sessions/
|
||||
storages/
|
||||
```
|
||||
|
||||
`.dsh/profiles/desktop` 是唯一活跃的 desktop profile。其 package manifest 记录内置与已安装插件 bundle 的顺序;只有 Electron 可以修改它的依赖、lockfile 和 `node_modules`。生产启动会拒绝解析到该 profile 之外的 bundle,包括 CLI 维护的 `.dsh/profiles/node_modules` fallback。desktop profile 安装的所有包内容都使用 `.dsh/desktop/pnpm/store`。
|
||||
|
||||
## 安装与解析
|
||||
|
||||
安装器绝不原地修改活跃 profile。它把 profile 元数据复制到事务暂存目录,并使用内置 pnpm 应用精确依赖变更。测试 staging 前,Electron 会停止活跃后端;它单独启动并停止 staging 后端,再在激活前恢复活跃后端,因此两个 Desktop 后端绝不会并发共享 `.dsh` 状态。激活过程再次停止后端,在对应目录移动前先持久化 `pending.json` 的每个下一阶段,把活跃 profile 移到 `rollback/profile`,把暂存 profile 移到 `.dsh/profiles/desktop`,然后重启。恢复过程会结合预写阶段与真实的 active、rollback 和 staging 目录,因此任一个写入与移动间隙中断后仍会保留或恢复一个完整 profile。
|
||||
|
||||
进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 store 或 staging 事务;该 worker 退出后,陈旧 PID 才可以恢复。
|
||||
|
||||
打包 seed 是离线安装包,而不是可执行 dsh 目录。它包含发布身份、初始桌面项目 manifest、分别以 dsh 和私有 Desktop Host 为根的第一方包闭包之并集的描述文件及不可变 tarball、lockfile、完整性清单和所需 store 子集。每个 `mac-arm64`、`mac-x64` 和 `win-x64` 构建都在 `.desktop-build/targets/<target>` 下持有自己的打包输入、运行时、包集合、seed、pnpm 准备状态、未打包应用、更新元数据和最终产物;只有不可变且经过校验和验证的 Node.js 下载缓存会被共享。发布构建要求 Electron 包、根 dsh 包与私有 Host 包使用相同版本,从正式源码构建生成最终 npm tarball,在本地打包私有 Host,选择可达的 dsh、Host 与 vendored 包以及 Landlock 入口,并验证 Host tarball 中包含 `lib/index.js` 与 `config/desktop.cordis.patch.yml`。Host 的 `files` manifest 只包含该运行入口与 overlay,并且该包不会发布到 npm。公共包 tarball 仍是由各包发布 manifest 控制的正式 `pnpm pack` 结果;Desktop 不删除已发布的声明文件,也不建立第二套包内容策略。seed manifest 把每个选中的包列为本地直接依赖,关闭 peer dependency 自动安装,workspace 文件再把每个选中的第一方包 override 到对应本地 tarball。目标 Node.js 执行内置 pnpm,因此 pnpm 的操作系统和 CPU 选择会使物化的依赖图与 seed 成为目标专用内容。内置 pnpm 关闭全局 virtual store,在禁用生命周期脚本的情况下从 npm 物化外部生产依赖,删除 `node_modules` 以及所有临时 pnpm cache、config 和 state 目录,然后只使用最终 store 执行一次干净的离线安装,并检查私有 Host 的入口与 overlay。构建会拒绝任何通过 registry 版本解析本地第一方包名的 lockfile。生成清单前会删除第二次生成的 `node_modules` 和临时 pnpm 项目注册。在复制 package set 前与离线安装后都要求这两个 Host 文件,可防止进程入口能够加载、却无法组合所需 overlay 的发布进入应用签名阶段。
|
||||
|
||||
种子根据规范化 store 路径,把 pnpm 内容放入 16 个确定性的未压缩 tar 分片。Apple 公证会检查这些归档内的 Mach-O 代码,因此 macOS seed 会 staging 每个被引用的内容寻址 Mach-O 对象,最多并发四个独立的 Developer ID 签名进程,并带上安全时间戳与 hardened runtime。任一签名失败后,准备过程会等待已启动的签名进程全部退出,原始 CAS 对象与包索引保持不变。所有签名成功后,准备过程把每个对象写到新的 SHA-512 路径,并以事务方式重写 pnpm MessagePack SQLite 索引内全部基础文件和 side-effects 文件引用。第二次离线安装证明 pnpm 可以解析重写后的 store;准备过程随后完成分片、解包最终归档并验证每个内嵌签名。包路径和非原生字节保持不变;种子保留包内附带的架构变体,因为删除文件会创建 Desktop 专属的包文件集。种子完整性覆盖分片 manifest 和解包前的每个归档。启动时验证归档路径、条目类型、唯一性和数量,把所有分片解包到唯一且由 Desktop 拥有的 staging 目录,替换匹配的不可变 store 文件,并以事务方式把各 pnpm store 版本的 SQLite `package_index` 合并进 `.dsh/desktop/pnpm/store`。Seed 记录替换匹配的键,为 Desktop 插件下载的记录继续保留。中断的文件合并可能留下有效的不可变缓存内容,但每次 SQLite 合并都是原子的,profile 安装与激活仍必须通过 pnpm 完整性与完整健康检查。
|
||||
|
||||
启动过程先要求安装包内的发布身份等于 Electron 应用版本,再在启动后端前比较 `.dsh/profiles/desktop/desktop-release.json`、已安装 dsh 包、已安装 Desktop Host 包与该发布版本。它在 staging 中通过 `pnpm install --offline --frozen-lockfile --trust-lockfile` 安装新的 seed manifest 与 lockfile。Electron 替换后,启动过程再通过一次离线 pnpm add,从桌面端现有 store 与元数据缓存恢复活跃 profile 记录的每个插件 bundle 精确版本。完整依赖图必须通过同一套健康检查才能激活。
|
||||
|
||||
插件 GUI 执行等价于 `pnpm add <package> --save-exact`、`pnpm remove <package>` 和精确版本更新的 registry npm 包操作。每次修改都保留本地核心包描述文件、tarball、dsh 与 Desktop Host 依赖和完整 override 映射。Electron 验证已安装包 manifest,并更新 profile 的依赖与有序 bundle 条目;任何渲染进程请求都不能选择 registry、安装目录、生命周期策略或任意 pnpm flag。
|
||||
|
||||
后端与 Loader 把 `.dsh/profiles/desktop/package.json` 作为 profile manifest 和 npm 解析锚点。公共 profile loader 先组合其中的有序 bundle 条目,再由私有 Desktop Host 应用其打包的 overlay。Host、dsh、Cordis、桌面插件、插件依赖和 peer dependency 均通过普通 pnpm `node_modules` 图解析。贡献 `dsh.client` 代码的桌面插件只有在完整 profile 通过健康检查后才进入启动 manifest。
|
||||
|
||||
## 更新与恢复
|
||||
|
||||
Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-builder` 产物。该版本就是 Desktop 发布版本;不存在独立 dsh manifest、兼容范围或仅更新 dsh 的操作。前台安装会等待正在进行的后台检查,而不会把检查结果复用成安装结果。更新弹窗下载并安装 Electron 产物,然后重启进入新发布。
|
||||
|
||||
新发布在打开窗口前从安装包种子校准 dsh,同时保留已安装桌面插件。健康检查覆盖依赖解析、原生模块、壳 API 兼容性、后端启停、Web 资源和客户端启动图。不兼容插件会阻止激活,并保留上一个项目用于回滚。启动过程会明确失败,而不会运行版本不匹配的壳与 dsh。
|
||||
|
||||
`DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm staging 事务仍是两个独立操作。
|
||||
|
||||
## 安全与发布策略
|
||||
|
||||
核心 dsh 与私有 Desktop Host 只能来自签名 Electron 发布内经过完整性记录的本地 npm tarball;pnpm overrides 防止传递核心包回退到 registry。Store 归档经过完整性检查,并在隔离的解包目录中完成全部验证,归档文件随后才能进入可写包状态。插件安装接受桌面策略允许的 registry 包 spec,但绝不接受原始 pnpm 命令。激活前必须具备精确版本、lockfile 完整性、经过评审的 `allowBuilds` 集合、仅限用户的目录权限、遮盖后的诊断和健康检查。
|
||||
|
||||
Electron 产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。Seed 准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。Electron-builder 随后公证应用并钉票、签署 DMG。DMG 的 artifact-completion hook 会单独公证每个 DMG 并钉票,再要求其使用配置的身份、具备有效票据并通过 Gatekeeper;只有该 hook 成功,上传事件才会执行。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
|
||||
|
||||
Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 seed 准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
|
||||
|
||||
打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、seed 或活跃项目。
|
||||
|
||||
在种子 store 子集之外,内置上游 Node.js 与 pnpm 预计增加约 35–50 MB 压缩体积和 120–165 MB 安装体积。分架构构建必须报告实际组件级体积增量。
|
||||
|
||||
## 实现
|
||||
|
||||
| 表面 | 实现 |
|
||||
|---|---|
|
||||
| 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、项目事务、插件 GUI、更新协调和 electron-builder 配置。 |
|
||||
| 已安装运行时 | 私有 `@deepseek-ai/dsh-desktop-host` 从活跃项目启动无端口桌面组合,并通过经过验证的分帧字节管道流式传输 API 与资源响应。 |
|
||||
| 包状态 | 发布种子和后续每次修改都通过内置 Node.js 与 pnpm 执行,并使用桌面端拥有的 store、config、cache、state 和 home 路径;核心包从发布 tarball 解析,插件从固定 npm registry 解析。 |
|
||||
| 资格验证 | macOS 打包要求已配置的公司身份与公证凭据可用,在解包最终归档后验证每个原生 seed 对象,验证完整应用签名,并要求应用和 DMG 都完成公证且通过 Gatekeeper。Windows 打包要求已配置的公开证书、SafeNet 私钥容器、Token Password 与 SignTool,并验证生成的每个签名。更新托管、跨上一版本的已安装产物测试和各平台 GUI 录制仍是发布环境门槛。 |
|
||||
|
||||
`dev:desktop` 会构建当前 workspace,把已构建 CLI 包、私有 Desktop Host 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。该模式的链接依赖图不是由 pnpm 安装的桌面项目,因此会禁用包修改。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、seed 安装和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**使用 Electron 的 Node.js 执行 dsh。** 这可以减小包体积,但会让 dsh 耦合到 Electron 的 Node 补丁、fuse、原生 ABI、TLS 行为和进程生命周期。内置上游 Node.js 可以让 dsh 继续使用其受支持运行时。
|
||||
|
||||
**通过 JSON IPC 以 Base64 承载 Fetch 消息体。** JSON IPC 可以只保留一种消息机制,但会膨胀每个请求与响应消息体、在两个进程中构造大字符串、在分派前缓冲完整请求,还会再次编码 RPC JSON 中已经表示为 Base64 的图片字节。原始分帧管道保留明确的带版本协议,同时不要求 Electron 与上游 Node.js 共享 V8 序列化行为。
|
||||
|
||||
**把产品 Web UI 永久打包进 Electron。** 独立 UI 与后端更新需要新的版本化兼容计划。从同一个 dsh 包安装后端与 Web UI 可以保持当前发布绑定。
|
||||
|
||||
**复用现有 CLI 或浏览器插件安装器。** 这会跨越桌面授权与发布 scope,并可能使用用户的包管理器状态。桌面包修改完全由 Electron 拥有。
|
||||
|
||||
**让 desktop profile 使用 CLI 管理的包或插件。** 任一产品都可能改变另一方的依赖图、Cordis 版本、插件版本或原生模块。因此 desktop profile 持有完整 `node_modules`,并拒绝通过 CLI profile fallback 解析 bundle。
|
||||
|
||||
**把 dsh 与插件安装到不同桌面项目。** 这会产生第二解析锚点和 peer dependency 回退。一个普通 npm 项目已经提供所需安装与解析模型。
|
||||
|
||||
**从 registry 包删除非目标 Mach-O 文件。** 架构裁剪可以节省少量 seed 空间,但包可能有意附带多个架构变体,调用方也可以观察安装后的文件集。签署每个实际携带的 Mach-O 对象,无需发明 Desktop 专属包布局就能满足公证要求。
|
||||
|
||||
**把 Windows EV 私钥导出到 PFX 文件。** 外部提供的公开叶证书让 SignTool 构造签名,`/csp` 与 `/kc` 则定位硬件密钥。EV 私钥保持不可导出,并留在 Token 上。
|
||||
|
||||
**提交包含凭据的签名脚本或持久保存 Token Password。** 包含凭据的 CMD 文件、`.env` 或 Windows 用户/系统环境变量都会让 Token Password 以静态形式被读取。已提交的 CMD 只包含环境变量引用,打包步骤则把密码作为 runner 临时 secret 接收。
|
||||
|
||||
**让 electron-builder 或通用目录同步直接发布。** 直接发布可能在所有引用产物就绪前暴露频道元数据,可能把陈旧或其他目标的文件混入发布,也无法证明已完成签名的构建仍与当前 dsh 版本一致。目标专用且经过校验的上传可以明确控制发布顺序与发布身份。
|
||||
|
||||
## 结果
|
||||
|
||||
- 没有系统 Node.js 或 pnpm 的干净离线机器把种子安装进 `.dsh/profiles/desktop`,并启动可工作的 dsh 会话。
|
||||
- 已签名应用记录固定少量的 seed store 分片,而不是记录每个 pnpm 缓存文件;macOS 分片内每个 Mach-O 对象都带有发布 Developer ID、安全时间戳与 hardened runtime,每个 Windows 产物都带有配置的硬件支持 EV 签名,安装后的私有 store 仍保持普通 pnpm 布局。
|
||||
- `.dsh/profiles/desktop/node_modules` 包含并解析桌面 dsh 包和每个 GUI 安装的桌面插件。
|
||||
- 每个桌面 pnpm 操作都使用内置可执行文件和 `.dsh/desktop/pnpm/store`;不读取用户 `PATH`、配置、store 或 profile `node_modules`。
|
||||
- Electron-only GUI 安装、删除和更新普通 npm 插件包,而不暴露原始 pnpm 参数。
|
||||
- 后端与浏览器应用不能修改桌面包。
|
||||
- npm/CLI dsh 与 Electron 绝不从对方的 `node_modules` 解析或安装插件。
|
||||
- 在产品窗口打开前,活跃后端与 Web UI 报告相同 dsh 版本和兼容壳 API。
|
||||
- 安装、健康检查或更新失败后,当前 profile 仍然可用,或在重启后恢复 `rollback/profile`。
|
||||
- 一个 Desktop 版本绑定 Electron 与 dsh;每次 dsh 更新都通过一个 Electron 更新弹窗交付,并产生一次用户可见的重启。
|
||||
- 共享 `.dsh` 数据在迁移或修改前拒绝不兼容的读取方。
|
||||
- 不打开回环监听端口,沙箱渲染进程不能访问任意文件系统或 Electron API。
|
||||
- Workspace 开发无需下载发布资源即可运行当前已构建代码,未封装安装器的应用验证仍保留生产安装路径。
|
||||
- Windows 发布打包要求已验证的 SignTool、EV Token、匹配的公开叶证书、Token Password 和明确的密钥容器,绝不会回退到未签名产物或可导出的密钥文件。
|
||||
- 目标更新只有在已完成签名的构建及其引用的每个产物通过发布校验后才能暴露新频道元数据;保留的历史产物继续供差分更新使用。
|
||||
- 每个发布阻断平台上的签名已安装产物均能从上一个受支持版本成功更新。
|
||||
|
||||
## 评审决策
|
||||
|
||||
| 决策 | 建议 |
|
||||
|---|---|
|
||||
| 首次启动 | 打包离线种子 store 子集,并通过 pnpm 安装 |
|
||||
| Desktop profile | 一个包含精确 dsh 与插件依赖、由 Electron 持有的保留 profile |
|
||||
| 插件管理 | Electron-only GUI 与包服务;没有 CLI、后端或浏览器安装路径 |
|
||||
| 激活 | 暂存项目、完整健康检查、记录式目录替换和一个回滚副本 |
|
||||
| 初始平台 | macOS arm64/x64 与 Windows x64;Linux 尚无受支持的发布目标 |
|
||||
| 更新行为 | 后台检查,差分下载与重启前显式确认,启动时校准 dsh |
|
||||
|
||||
## 风险
|
||||
|
||||
插件生命周期脚本会执行第三方代码。在 GUI 安装功能交付前,获准 registry、包策略、精确版本、完整性、`allowBuilds` 和诊断都需要安全评审。
|
||||
|
||||
更新绑定的 dsh 可能使插件 peer dependency 或原生模块失效。pnpm 解析与完整项目健康检查必须在替换活跃项目前拒绝暂存项目。
|
||||
|
||||
通过 npm 安装的 dsh 与桌面 dsh 可能在共享持久化数据时使用不同版本。每个共享 owner 都必须在读取、迁移或写入前执行格式版本与进程锁。
|
||||
|
||||
不同操作系统的目录替换行为不同,而且可能中断。激活记录与已安装产物故障测试必须证明每次文件系统移动都可以恢复。
|
||||
|
||||
代码签名、公证和更新托管需要生产发布基础设施。只运行仓库测试不能完成这些认证。
|
||||
+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-27-handle-based-session-persistence.md
|
||||
2026-08-27-handle-based-session-persistence.md: 9b5ff5d46444ac924859c4121abc1cf5d1538485
|
||||
2026-08-27-handle-based-session-persistence.zh.md: c9230c899141f4ab9979f93256da42a3aaae0984
|
||||
2026-08-27-handle-based-session-persistence.md: e2f07856b7ef8ffd8180ed7ff515210c95dde29b
|
||||
2026-08-27-handle-based-session-persistence.zh.md: 472a1024887ecca67245de27f52c68ec0b34caa1
|
||||
+1
-1
@@ -32,7 +32,7 @@ The previous persistence seam owned far more than storage. A shared coordinator
|
||||
|
||||
## Consequences
|
||||
|
||||
Resume, fork, subagent, ACP, webhook, and SDK sessions all persist through one explicit acquisition point, and dispose provably releases write ownership (reopening for write succeeds after teardown). The costs: a backend plugin reload under live sessions invalidates their handles — writes fail loudly until the sessions restart, where adoption previously re-attached silently; `ctx.sessions.create` + `flush` in a test persists nothing without a handle (tests seed through `create`/`append`/`close`); resume re-reads a cold log only when no immediately preceding observation parsed the same artifact — a bounded provider-local memo (session id + stat revision, invalidated by every local mutation) serves the observe-then-promote and authorize-then-resume handoffs without restoring the deleted borrow/reservation lifecycle, and the session-query reader's own prepared cache remains the pin-capable layer above it (a later consolidation may fold one into the other); and an empty created session is invisible to other processes until an explicit flush (ACP forces one for its resumable-empty-session promise). `SESSION_FORMAT_VERSION` stays 0.
|
||||
Resume, fork, subagent, ACP, webhook, and SDK sessions all persist through one explicit acquisition point, and dispose provably releases write ownership (reopening for write succeeds after teardown). The costs: a backend plugin reload under live sessions invalidates their handles — writes fail loudly until the sessions restart, where adoption previously re-attached silently; `ctx.sessions.create` + `flush` in a test persists nothing without a handle (tests seed through `create`/`append`/`close`); resume re-reads a cold log only when no immediately preceding observation parsed the same artifact — a bounded provider-local memo (session id + stat revision, invalidated by every local mutation) serves the observe-then-promote and authorize-then-resume handoffs without restoring the deleted borrow/reservation lifecycle, and the session-query reader's own prepared cache remains the pin-capable layer above it (a later consolidation may fold one into the other); and an empty created session is invisible to other processes until an explicit flush (ACP forces one for its resumable-empty-session promise). Handle ownership does not change the serialized Session representation.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
恢复、fork、subagent、ACP、webhook 与 SDK 会话全部经由一个显式获取点持久化,且 dispose 可证明地释放写所有权(teardown 之后重新以写模式打开可以成功)。代价:在有活跃会话时重载后端插件会使它们的句柄失效——写入会响亮地失败,直到会话重启,而以前接管会静默重连;测试中 `ctx.sessions.create` + `flush` 在没有句柄时什么也不持久化(测试通过 `create`/`append`/`close` 播种);只有当紧邻其前没有观察读解析过同一产物时,恢复才重新读取冷日志——一个有界的 provider 内部 memo(按会话 id + stat 修订号,任何本地修改都使其失效)服务观察后提升与授权后恢复这两类交接,而不恢复已删除的 borrow/reservation 生命周期;session-query reader 自己的已准备缓存仍是其上方具备 pin 能力的一层(后续可考虑二者收敛);空的已创建会话在显式 flush 之前对其他进程不可见(ACP 为其可恢复空会话承诺强制执行一次 flush)。`SESSION_FORMAT_VERSION` 保持为 0。
|
||||
恢复、fork、subagent、ACP、webhook 与 SDK 会话全部经由一个显式获取点持久化,且 dispose 可证明地释放写所有权(teardown 之后重新以写模式打开可以成功)。代价:在有活跃会话时重载后端插件会使它们的句柄失效——写入会响亮地失败,直到会话重启,而以前接管会静默重连;测试中 `ctx.sessions.create` + `flush` 在没有句柄时什么也不持久化(测试通过 `create`/`append`/`close` 播种);只有当紧邻其前没有观察读解析过同一产物时,恢复才重新读取冷日志——一个有界的 provider 内部 memo(按会话 id + stat 修订号,任何本地修改都使其失效)服务观察后提升与授权后恢复这两类交接,而不恢复已删除的 borrow/reservation 生命周期;session-query reader 自己的已准备缓存仍是其上方具备 pin 能力的一层(后续可考虑二者收敛);空的已创建会话在显式 flush 之前对其他进程不可见(ACP 为其可恢复空会话承诺强制执行一次 flush)。句柄所有权不改变序列化的 Session 表示。
|
||||
|
||||
## 相关
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md
|
||||
2026-08-31-alpha-historical-unknown-event-refusal.md: 58690e30281c1f5e10f85726c1f1e50fd4664fe9
|
||||
2026-08-31-alpha-historical-unknown-event-refusal.zh.md: 73ab2ca47ab3f68b11e71ffec09287253215aa53
|
||||
2026-08-31-alpha-historical-unknown-event-refusal.md: c63b36f63206e0992416239483d908b0645a98c2
|
||||
2026-08-31-alpha-historical-unknown-event-refusal.zh.md: 231c46c11486b05a69a200b857a0ea6df35b6e79
|
||||
+1
-1
@@ -14,7 +14,7 @@ Silently copying such an event can leave stale numeric references after a later
|
||||
|
||||
The alpha v0-to-v1 edge owns a frozen complete released-v0 event and payload inventory. It refuses every unknown historical event type before target staging, including an event marked `ignorable: true`, and refuses unexpected members of known payloads except fields explicitly classified as owner-opaque JSON. Merge-extensible nested discriminants remain part of that explicit policy: unknown content-block types, message-source kinds, assistant finish-reason kinds, and turn-ending reason kinds are preserved as owner-opaque JSON, while known arms receive structural validation. The diagnostic names the event type, its sequence number, and the unchanged source generation.
|
||||
|
||||
The rule applies only while crossing a historical format edge. Ordinary current-format reading retains the established envelope behavior: an unknown required event refuses, while an unknown event carrying `ignorable: true` remains readable. New v1 external events therefore keep the existing equal-version extension seam, but they do not become implicitly migratable by a future format edge.
|
||||
The rule applies only while crossing a historical format edge. Ordinary current-format reading retains the established envelope behavior: an unknown required event refuses, while an unknown event carrying `ignorable: true` remains readable. Native current-format external events therefore keep the existing equal-version extension seam, but they do not become implicitly migratable by a future format edge.
|
||||
|
||||
Every first-party source event type has an executable disposition and target validator in the edge package. The catalog is build-static and profile-independent, so mounting or omitting the producer plugin cannot change whether an old artifact migrates.
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
Alpha v0-to-v1 迁移边拥有冻结且完整的已发布 v0 事件与 payload 清单。它在目标 staging 前拒绝每个未知历史事件类型,包括标记了 `ignorable: true` 的事件;除明确分类为 owner 不透明 JSON 的字段外,它也拒绝已知 payload 的意外成员。可合并扩展的嵌套判别字段同样属于这项显式策略:未知 content-block type、message-source kind、assistant finish-reason kind 与 turn-ending reason kind 会作为 owner 不透明 JSON 保留,已知分支则接受结构校验。诊断会点名事件类型、序号和保持不变的源 generation。
|
||||
|
||||
该规则只适用于跨越历史格式迁移边。普通当前格式读取保留既有信封行为:未知必需事件被拒绝,带 `ignorable: true` 的未知事件仍可读取。因此新的 v1 外部事件继续使用既有同版本扩展 seam,但不会自动获得未来格式迁移能力。
|
||||
该规则只适用于跨越历史格式迁移边。普通当前格式读取保留既有信封行为:未知必需事件被拒绝,带 `ignorable: true` 的未知事件仍可读取。因此原生当前格式的外部事件继续使用既有同版本扩展 seam,但不会自动获得未来格式迁移能力。
|
||||
|
||||
每个第一方源事件类型都在迁移边包中拥有可执行 disposition 与目标 validator。catalog 在构建时静态确定且与 profile 无关,因此 producer 插件是否挂载不会改变旧产物能否迁移。
|
||||
|
||||
|
||||
+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-31-explicit-agent-runtime-identity.md
|
||||
2026-08-31-explicit-agent-runtime-identity.md: f52b8ec116c312a27306fe73dc0bd5b99fcd9039
|
||||
2026-08-31-explicit-agent-runtime-identity.zh.md: 6b6fd2f2ea1f1645069264f09fd53c3f71e1d02f
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Note: Explicit Agent identity at runtime boundaries
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-31-explicit-agent-runtime-identity.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
An Agent's Cordis Context owns registrations and their cleanup. Agent identity instead selects the Session, runtime owner, event subject, authority decision, or wire identity for one operation. A reverse Agent property on Context made those two facts appear interchangeable: a caller could choose a Context for effect ownership and accidentally let that choice determine domain identity.
|
||||
|
||||
The reverse association also required compensating mechanisms after type erasure. Host Remote forwarding inspected a routed subject for its Context, creation inferred runtime parentage from the caller Context, and adapters maintained reverse identity scans. These mechanisms duplicated identity already present in typed requests and obscured which caller owned an Agent at runtime.
|
||||
|
||||
Without an explicit owner, `SubagentContinuationManager` creates and resumes children through its private plugin Context, so Context-based inference classifies every continuable child as a runtime root even though the manager holds its exact parent. Root-only consumers could then attach scheduling tools, grant direct-human goal authority, or route user questions as if the child were top-level.
|
||||
|
||||
## Decision
|
||||
|
||||
Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup` receives `(agentCtx, agent)`; Agent creation and resume options carry `parentAgent` for a runtime child; scoped events carry their Agent in the payload; Remote forwarding verifies that `request.agent` is the carrier key; and Host Typert Context resolution maps wire identity to a live Agent Context without a reverse scan. `agent.ctx` remains the registration and lifecycle owner and exposes no reverse Agent property.
|
||||
|
||||
Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` passes the unpublished Session explicitly and installs through the supplied Context before publication. For a settings-backed standing preset, the event payload supplies the Agent, its Session supplies the policy target, and its Context owns the registrations.
|
||||
|
||||
`SubagentContinuationManager` puts the exact parent in both fresh-creation and cold-resume options. A live continuable child is therefore excluded from `AgentRegistry.roots()` and satisfies `isOwnedBy(child.id, parent)`. Durable `parentSession` metadata does not substitute for this relation: a fork or resumed Session may be a runtime root when no live Agent owns it.
|
||||
|
||||
The [Agent registration-scope decision](2026-07-08-agent-scope-contexts.md), its [runtime design](2026-07-12-agent-scope-runtime-design.md), and the [initiator-scope decision](2026-07-15-agent-initiator-scope.md) retain their independent registration, lifecycle, and private-chain rationale. This decision supersedes only the reverse Context association and implicit runtime-owner derivation described there.
|
||||
|
||||
## Verification
|
||||
|
||||
Agent creation tests pin explicit root and child ownership. Continuation integration tests keep a real child live long enough to assert both `roots()` exclusion and `isOwnedBy()` membership. Existing Schedule tests verify that root-only registrations stay absent from an explicitly owned child.
|
||||
|
||||
Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Session publication; standing-preset tests verify per-Session policy sampling and inheritance.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep `Context.agent`.** A reverse accessor makes registration ownership look like operation identity and requires every Context derivation, adapter, and test double to preserve an association unrelated to Cordis service selection or effect cleanup.
|
||||
|
||||
**Infer runtime ownership from the caller Context.** A private manager Context, an Agent Context, and a standing preset Context can all call the same factory. Context ancestry therefore does not state which live Agent owns the result; the creator must put the parent it already knows in the request options.
|
||||
|
||||
**Classify Agent scope keys.** An opaque scope key states routing membership, not domain identity. Classifying it would make Agent the center of composition and would still couple a plugin's effect owner to the Session whose policy it needs.
|
||||
|
||||
**Use the initiating Agent as creation ownership.** Initiator scope records causal asynchronous execution, not lifetime ownership. A parent may initiate work that intentionally creates a root, and setup remains outside the child's driver boundary.
|
||||
|
||||
**Use durable Session lineage.** `parentSession` records conversation ancestry across process lifetimes. Runtime ownership controls live roots and teardown, so equating the two would prevent a legitimately resumed fork from becoming a top-level Agent.
|
||||
|
||||
## Consequences
|
||||
|
||||
Lifecycle options, events, service requests, and transport requests carry explicit Agent identities, so each operation states the identity it uses and TypeScript checks both sides. Context remains reusable for dependency access and effect ownership without becoming an alternate domain-object locator.
|
||||
|
||||
Continuable children have the same runtime parent relation as one-shot in-process children. Root-only consumers exclude them, parent teardown can reason from one live ownership graph, and durable lineage remains free to describe history rather than process-local lifetime.
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Agent Note: 运行时边界显式携带 Agent 身份
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-31-explicit-agent-runtime-identity.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操作选择会话、运行时所属方、事件主体、权限决策或协议身份。Context 上反向的 Agent 属性让这两个事实看起来可以互换:调用方选择用于管理 effect 所有权的 Context 时,可能意外地让该选择决定领域身份。
|
||||
|
||||
类型信息被擦除后,这项反向关联还需要补偿机制。Host Remote 转发会从已路由主体检查其 Context,创建流程会从调用方 Context 推断运行时父级,适配器则维护反向身份扫描。这些机制重复类型化请求中已有的身份,也掩盖了哪个调用方在运行时拥有 Agent。
|
||||
|
||||
若没有显式所属方,`SubagentContinuationManager` 会通过私有插件 Context 创建和恢复子级,因此基于 Context 的推断会把每个可续跑子级归类为 runtime root,尽管管理器持有其确切父级。仅限根级的消费方随后可能附加调度工具、授予直接人类输入对应的 Goal 权限,或像处理顶层 Agent 一样路由用户问题。
|
||||
|
||||
## 决策
|
||||
|
||||
运行时接口在拥有身份的位置携带 Agent 身份。`AgentSetup` 接收 `(agentCtx, agent)`;创建与恢复 Agent 的 options 通过 `parentAgent` 标识运行时子级;作用域事件在 payload 中携带 Agent;Remote 转发校验 `request.agent` 就是 carrier key;Host Typert Context 解析则把协议身份映射到存活 Agent Context,不执行反向扫描。`agent.ctx` 继续拥有注册和生命周期,不暴露反向 Agent 属性。
|
||||
|
||||
感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。对于由设置控制的常驻 preset,事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项。
|
||||
|
||||
`SubagentContinuationManager` 会把确切父级放进全新创建与冷恢复的 options。因此,存活的可续跑子级不会出现在 `AgentRegistry.roots()` 中,并且满足 `isOwnedBy(child.id, parent)`。持久化 `parentSession` 元数据不能代替这项关系:没有存活 Agent 拥有 fork 或已恢复会话时,它仍可成为 runtime root。
|
||||
|
||||
[Agent 注册作用域决策](2026-07-08-agent-scope-contexts.zh.md)、其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md)和[发起方作用域决策](2026-07-15-agent-initiator-scope.zh.md)继续拥有各自独立的注册、生命周期及私有调用链理由。本决策只取代其中描述的反向 Context 关联和隐式运行时所属方推导。
|
||||
|
||||
## 验证
|
||||
|
||||
Agent 创建测试锁定显式的根级与子级归属。continuation 集成测试让一个真实子级保持存活,直到断言其既不属于 `roots()`、又满足 `isOwnedBy()`。现有 Schedule 测试验证仅限根级的注册项不会出现在显式归属的子级中。
|
||||
|
||||
Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Session 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取与继承。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**保留 `Context.agent`。** 反向 accessor 会让注册所有权看起来等同于操作身份,还要求每个 Context 派生、适配器和测试替身保留一项与 Cordis 服务选择或 effect 清理无关的关联。
|
||||
|
||||
**从调用方 Context 推断运行时归属。** 私有管理器 Context、Agent Context 和常驻 preset Context 都能调用同一个工厂。因此,Context 祖先关系无法说明由哪个存活 Agent 拥有结果;创建方必须把它已知的父级放进请求 options。
|
||||
|
||||
**分类 Agent 作用域键。** 不透明作用域键表达路由成员关系,而不是领域身份。分类该键会让 Agent 成为组合中心,也仍会把插件的 effect 所有者与策略所需的 Session 耦合起来。
|
||||
|
||||
**使用发起 Agent 作为创建归属。** 发起方作用域记录异步执行的因果关系,而非生命周期归属。父级可能发起有意创建根级 Agent 的工作,而 setup 仍位于子级驱动边界之外。
|
||||
|
||||
**使用持久化会话谱系。** `parentSession` 跨进程生命周期记录对话祖先关系。运行时归属控制存活根级和 teardown,因此把二者等同会阻止合法恢复的 fork 成为顶层 Agent。
|
||||
|
||||
## 后果
|
||||
|
||||
生命周期 options、事件、服务请求和传输请求会携带显式 Agent 身份,因此每项操作都会声明自身使用的身份,TypeScript 也会检查两侧。Context 可以继续复用于依赖访问与 effect 所有权,而不会成为另一种领域对象定位器。
|
||||
|
||||
可续跑子级与一次性进程内子级使用同一种运行时父级关系。仅限根级的消费方会排除这些子级,父级 teardown 可以依据唯一的存活归属图推理,而持久化谱系仍可描述历史,不必承担进程内生命周期语义。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
|
||||
2026-08-31-released-session-format-migrations.md: 592322c0e4c1b2fa52dcf71652f3878f43a8c8ca
|
||||
2026-08-31-released-session-format-migrations.zh.md: ba2317903845739cda8da1c01c2f959c7a2ccd50
|
||||
2026-08-31-released-session-format-migrations.md: d566459af64f4177ed0135813e75aaf77480823c
|
||||
2026-08-31-released-session-format-migrations.zh.md: f82b406117691197d808111f1c8aa4c722d4bab2
|
||||
+24
-1
@@ -58,16 +58,33 @@ JSONL record
|
||||
→ released physical row decoder
|
||||
→ v0-to-v1 stage
|
||||
→ v1-to-v2 stage
|
||||
→ v2-to-v3 stage
|
||||
→ current event collector
|
||||
```
|
||||
|
||||
The chain contains no `flatMap`, spread expansion, intermediate event array, or scheduler. The final event collector expands a compact run only after every migration stage has had the opportunity to consume it directly.
|
||||
|
||||
### Adjacent version ownership
|
||||
|
||||
The [V2-to-V3 delivery guards](../../../../packages/session/session-format-v2-to-v3/README.md#delivery-guards) prevent a marker ignored in the source generation from becoming an active upload watermark merely because the header changes. Python release smoke checks generated logs against the source `SESSION_FORMAT_VERSION` independently of generation-neutral golden comparison, so coherent filenames and headers cannot conceal an outdated writer.
|
||||
|
||||
The [V2-to-V3 README](../../../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification) is the single specification for that edge's transformations, preservation, and refusal; its separate [native admission section](../../../../packages/session/session-format-v2-to-v3/README.md#native-v3-admission) prevents current-only capabilities from being mistaken for historical transformations. The released V2 codec remains owned by V1→V2 and is reused, not copied. The [system-prompt](2026-09-02-system-prompt-as-surface-node.md), [PTC](../feature/2026-06-15-ptc.md), and [canonical-envelope](2026-09-06-v3-canonical-session-envelopes.md) notes retain their independent rationale, not duplicate conversion specifications. The [format-version cookbook](../../../../docs/cookbook/adding-a-session-format-version.md) owns package wiring, current consumers, snapshot successors, and validation commands.
|
||||
|
||||
Historical content admission belongs to the incoming edge, not native V3 extension validation. Preserving an unknown block without understanding its fields cannot establish that migration preserves its meaning. The [source audit](../../../../packages/session/session-format-v2-to-v3/README.md#source-audit) therefore uses one historical kind set across its explicitly owned content positions, including partial streams. It inspects admitted content without rewriting it and leaves owner-opaque JSON uninterpreted. Narrowing native acceptance or editing frozen predecessor validators would change independent promises rather than establish safe conversion.
|
||||
|
||||
Preset renames cover the creation header and every selection event because the latest selection controls resume while earlier selections control historical forks. Rewriting only the last selection loses that distinction. The released `code` id denotes the legacy built-in preset; migration is independent of the installed roster so the same bytes produce the same result on every host. Native V3 custom ids remain available without a global runtime alias.
|
||||
|
||||
A source inherited count can be unknown before EOF: V2 derives it from seed markers, and V1→V2 can change cardinality. The chain passes that absence to the next stage instead of fabricating a count. The [V2-to-V3 inheritance rules](../../../../packages/session/session-format-v2-to-v3/README.md#sequence-references) support this case; older stages that require a header-supplied count still refuse when it is absent. This permits seeded multi-hop restoration without retaining an intermediate artifact array.
|
||||
|
||||
The [version and release-status reference](../../../../docs/session-format-status.md) owns the published-format record and identifies the code’s writer authority. Released formats retain their semantics; committed generations remain byte-preserved during migration. A subsequent structural change requires the next adjacent edge under the [versioning rule](2026-08-10-session-log-version-mechanism.md), not an amendment to a released conversion. Ordinary event additions follow that rule’s required-event refusal mechanism rather than automatically allocating a version. A current-format file does not rerun its incoming migration; integration tests use isolated disposable homes and unchanged historical inputs.
|
||||
|
||||
The [committed-corpus inventory](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) identifies deliberately unsupported historical conversions by source path, generation, and exact refusal reason. Retaining those artifacts must not force chronology-changing migration or permit a blanket skip: every listed artifact must still raise the typed migration refusal, and unlisted artifacts must restore. Native current-generation fixtures cannot be classified as unsupported, because they do not traverse an incoming edge. Headerless test-harness protocol examples remain a separate explicit class. The corpus test checks source bytes after both successful and refused restoration; it does not rewrite historical evidence to satisfy the current reader.
|
||||
|
||||
### Physical codecs and packed runs
|
||||
|
||||
Each released codec creates a row decoder with explicit `strict` or `recoverable` recovery. The decoder validates and emits one event or one codec-owned `SessionFormatEventRun` at a time through separate context methods. v0-to-v1 and v1-to-v2 implement both `transformEvent()` and `transformRun()`, so packed Assistant chunks can reach the folding edge without first becoming millions of ordinary events.
|
||||
|
||||
The v0-to-v1 edge preserves logical headers, sequence numbers, references, timestamps, and payloads except for bounded released-v0 normalizations. It translates the retired `steering/message` and `compact/*` event names, accepts a released `llm/retry` after its matching `step/end`, deterministically supplies a missing `llm/retry.retryId` per turn/step/provider/policy chain, and supplies one deterministic `compactionId` across a legacy compaction group that omitted it. The v1-to-v2 edge owns attempt folding and reference remapping, and emits only settled current events. It splits a legacy goal-sourced user message into `goal/change` plus the original model-visible message. It also inserts an interrupted `turn/end` for the bounded released restart in which an open turn with no open step is followed by a non-empty `next-turn` inbox splice and the next numbered `turn/start`.
|
||||
The v0-to-v1 edge preserves logical headers, sequence numbers, references, timestamps, and payloads except for bounded released-v0 normalizations. It translates the retired `steering/message` and `compact/*` event names, accepts a released `llm/retry` after its matching `step/end`, deterministically supplies a missing `llm/retry.retryId` per turn/step/provider/policy chain, and supplies one deterministic `compactionId` across a legacy compaction group that omitted it. The v1-to-v2 edge owns attempt folding and reference remapping, and emits only settled v2 events. It splits a legacy goal-sourced user message into `goal/change` plus the original model-visible message. It also inserts an interrupted `turn/end` for the bounded released restart in which an open turn with no open step is followed by a non-empty `next-turn` inbox splice and the next numbered `turn/start`.
|
||||
|
||||
The catalog exposes one `createRestore()` operation for production, Worker, fixture, and replay callers. Recovery policy and final validation policy are chosen once at restore creation. Historical production uses recoverable source parsing with transformed-current validation; this validates the released current result after migration, while input that is already current receives only codec validation. Worker and fixture verification use strict parsing with full installed current restoration. A migration-stage or transformed-current validation refusal remains `SessionFormatUnsupportedMigrationError`; physical decoding failures remain corruption. Test support keeps only fixture-specific token and envelope materialization.
|
||||
|
||||
@@ -77,6 +94,8 @@ The JSONL provider scans frame boundaries once, reuses one Zstandard decoder, pa
|
||||
|
||||
Current encoding is record based. The provider serializes about 1 MiB of plaintext per main-thread slice, streams it through one Zstandard context with source-error propagation, writes compressed output in 4 MiB batches to an exclusively created same-directory temporary file, and syncs it before publication. A process-wide scheduler admits at most two full verification Workers and hands a released permit directly to the oldest waiter.
|
||||
|
||||
The shipped `lib/worker.cjs` bundles its JavaScript workspace dependencies so each fresh verifier avoids resolving and compiling their runtime module graph. This is safe because the Worker communicates through plain request/result messages and shares no service or class identity with its host. The Host build applies the existing TypeScript and Typert transforms; the Client pass skips this Node-only package instead of replacing its worker with untransformed source. Native add-ons remain external. Verification, scheduler admission, termination, and durable publication still complete before writable open returns. The built-worker smoke copies the package manifest and worker into an isolated temporary package with ambient module paths removed, accepts a valid generation, and rejects an incorrect event count.
|
||||
|
||||
Preparation forwards cancellation through source reads and observes it at the existing approximately 500 ms Decode yield boundary. Once `publish()` starts, encode, Worker verification, and publication do not receive caller cancellation and run to settlement; write open checks its caller signal again afterward. A published generation is never rolled back.
|
||||
|
||||
The Stage pipeline ends at one prepared current artifact. [Historical Session read preparation](2026-09-05-read-only-session-migration-preparation.md) defines how read open consumes that artifact immediately while write open performs encode, verification, and publication before returning append access.
|
||||
@@ -105,6 +124,10 @@ Existing write handles retain the process-local claim and kernel-backed cross-pr
|
||||
|
||||
## Verification
|
||||
|
||||
The migration specification requires evidence for transformations, preservation, and refusal separately. Direct-edge and native V3 tests cannot establish seeded multi-hop publication: preceding assistant-stream folding changes source coordinates before V3 inserts system events. Tests through the real catalog and JSONL provider therefore need raw and compressed V0/V1 inputs, mapped references and inherited cuts, publish/reopen equivalence, unchanged predecessor bytes, and no intermediate generations. Coverage percentages alone cannot prove those cross-stage relationships; combined assertions must compare the resulting history and refusal effects.
|
||||
|
||||
Content-admission evidence must cover every position named in the specification, nested results, partial starts, and malformed known blocks, with source-coordinate diagnostics. Successful migration must preserve admitted content and opaque values. Refusal through real persistence must leave the source unchanged and publish no successor. Native V3 tests must independently retain extension acceptance under both catalog validation policies; historical refusal is not evidence of native rejection.
|
||||
|
||||
### Benchmark input and meanings
|
||||
|
||||
The benchmark uses Node v24.18.0 and one 116,228,655-byte v0 Zstandard log containing 317,540 frames and 454,151 physical rows. The old reader restores 9,143,111 expanded v0 events. Migration produces 72,784 current v2 events with artifact SHA-256 `fa16ff9472ca350595a3112c20a3db79655bc2673973469987ecaf2a57ebd17c`.
|
||||
|
||||
+24
-1
@@ -58,16 +58,33 @@ JSONL record
|
||||
→ released physical row decoder
|
||||
→ v0-to-v1 stage
|
||||
→ v1-to-v2 stage
|
||||
→ v2-to-v3 stage
|
||||
→ current event collector
|
||||
```
|
||||
|
||||
Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 scheduler。只有在每个 migration stage 都已获得直接消费 compact run 的机会后,最终 event collector 才会展开它。
|
||||
|
||||
### 相邻版本所有权
|
||||
|
||||
[V2 到 V3 投递保护](../../../../packages/session/session-format-v2-to-v3/README.zh.md#delivery-guards)防止源代中被忽略的标记仅因头部变化就成为有效上传水位。Python 发布冒烟测试独立于跨代 golden 比较,按源代码中的 `SESSION_FORMAT_VERSION` 检查生成日志,因此文件名与 header 自洽不能掩盖过期 writer。
|
||||
|
||||
[V2 到 V3 README](../../../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification)是该迁移边转换、保留与拒绝规则的单一规范真源;单列的[原生准入章节](../../../../packages/session/session-format-v2-to-v3/README.zh.md#native-v3-admission)避免将仅当前版本支持的能力误认为历史转换。已发布 V2 codec 仍归 V1→V2 所有,并被复用而非复制。[系统提示词](2026-09-02-system-prompt-as-surface-node.zh.md)、[PTC](../feature/2026-06-15-ptc.zh.md)和[规范信封](2026-09-06-v3-canonical-session-envelopes.zh.md)记录保留各自独立依据,而非重复转换规范。[格式版本实操手册](../../../../docs/cookbook/adding-a-session-format-version.zh.md)负责包接线、当前消费方、快照后继代际与验证命令。
|
||||
|
||||
历史内容准入归入边所有,而非原生 V3 扩展校验。在不了解字段的情况下保留未知块,不能证明迁移保留了其含义。因此,[源审计](../../../../packages/session/session-format-v2-to-v3/README.zh.md#source-audit)在明确归其所有的内容位置(包括未完成的流)使用同一历史种类集合。它检查已接纳的内容而不改写,并且不解释归其他所有者所有的不透明 JSON。收紧原生准入或修改冻结的前代校验器,会改变独立承诺,而非证明转换安全。
|
||||
|
||||
预设更名覆盖创建头部和每条选择事件,因为最新选择决定恢复时的预设,而更早的选择决定历史 fork 的预设。只改写最后一条选择会丢失这种区别。已发布的 `code` 标识表示旧内置预设;迁移不依赖已安装的预设列表,因此相同字节在每台主机上产生相同结果。原生 V3 的自定义标识仍可使用,无需全局运行时别名。
|
||||
|
||||
源继承数量在 EOF 前可能未知:V2 从种子标记推导它,而 V1→V2 可以改变事件数量。迁移链将这种缺失传递给下一个 Stage,而不伪造数量。[V2 到 V3 继承规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#sequence-references)支持此情况;需要 header 提供数量的旧 Stage 仍在数量缺失时拒绝。这使有种子的多跳恢复无需保留中间产物数组。
|
||||
|
||||
[版本与发布状态参考](../../../../docs/session-format-status.zh.md)拥有已发布格式记录,并指明代码中的写入器真源。已发布格式保留其语义;迁移期间已提交代际的字节保持不变。后续结构性变更必须按[版本规则](2026-08-10-session-log-version-mechanism.zh.md)添加下一条相邻迁移边,而非修改已发布转换。普通事件新增遵循该规则的必需事件拒绝机制,而非自动分配版本。当前格式文件不会重新执行入边迁移;集成测试使用隔离、可丢弃的 home 和未变更的历史输入。
|
||||
|
||||
[已提交语料清单](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) 按源路径、代际与精确拒绝原因标识有意不支持的历史转换。保留这些产物不能迫使迁移改变时序,也不能允许统一跳过:每个清单中的产物仍必须抛出类型化迁移拒绝,未列入的产物必须还原。原生当前代际 fixture 不经过入边,因此不能被归为不支持。没有版本 header 的测试框架协议示例保持为独立的显式类别。语料测试在还原成功和拒绝后都检查源字节;它不通过改写历史证据来满足当前 reader。
|
||||
|
||||
### Physical codec 与 packed run
|
||||
|
||||
每个 released codec 会用显式 `strict` 或 `recoverable` 策略创建 row decoder。Decoder 每次通过不同的 context 方法校验并 emit 一个 event 或 codec-owned `SessionFormatEventRun`。v0-to-v1 与 v1-to-v2 都实现 `transformEvent()` 和 `transformRun()`,因此 packed Assistant chunk 可以直接到达 folding edge,无需先变成数百万个普通事件。
|
||||
|
||||
v0-to-v1 除了有限的 released-v0 归一化外,会保留逻辑 header、seq、引用、时间戳与 payload。它转换已移除的 `steering/message` 与 `compact/*` 事件名称,接受出现在对应 `step/end` 之后的已发布 `llm/retry`,按 turn/step/provider/policy chain 为缺失的 `llm/retry.retryId` 确定性补值,并为省略 id 的旧 compaction group 确定性补充同一个 `compactionId`。v1-to-v2 负责 attempt folding 与引用重写,并且只 emit 已结算的 current event。它会把旧的 goal 来源 user message 拆成 `goal/change` 与原本的模型可见 message。它还会为一种有限的已发布 restart 插入 interrupted `turn/end`:一个没有 open step 的 open turn 后出现非空 `next-turn` inbox splice,随后直接开始编号连续的下一轮。
|
||||
v0-to-v1 除了有限的 released-v0 归一化外,会保留逻辑 header、seq、引用、时间戳与 payload。它转换已移除的 `steering/message` 与 `compact/*` 事件名称,接受出现在对应 `step/end` 之后的已发布 `llm/retry`,按 turn/step/provider/policy chain 为缺失的 `llm/retry.retryId` 确定性补值,并为省略 id 的旧 compaction group 确定性补充同一个 `compactionId`。v1-to-v2 负责 attempt folding 与引用重写,并且只 emit 已结算的 v2 event。它会把旧的 goal 来源 user message 拆成 `goal/change` 与原本的模型可见 message。它还会为一种有限的已发布 restart 插入 interrupted `turn/end`:一个没有 open step 的 open turn 后出现非空 `next-turn` inbox splice,随后直接开始编号连续的下一轮。
|
||||
|
||||
Catalog 为 production、Worker、fixture 与 replay 暴露同一个 `createRestore()`。Recovery policy 与最终 validation policy 在 restore 创建时一次确定。Historical production 使用 recoverable source parsing 与 transformed-current validation;这种策略会在迁移后校验已发布 current 结果,而已经是 current 的输入只接受 codec 校验。Worker 与 fixture verification 使用 strict parsing 与已安装 current 格式的完整 restoration。Migration stage 或 transformed-current validation 的拒绝会保持为 `SessionFormatUnsupportedMigrationError`;物理解码失败仍是 corruption。Test support 只保留 fixture 自身需要的 token 和 envelope materialization。
|
||||
|
||||
@@ -77,6 +94,8 @@ JSONL provider 只扫描一次 frame boundary,复用一个 Zstandard decoder
|
||||
|
||||
Current encode 以单条 record 为单位。Provider 在主线程每个 slice 序列化约 1 MiB plaintext,通过一个会传播 source error 的 Zstandard context 流式压缩,以 4 MiB batch 写入同目录排他创建的临时文件,并在 publication 前 sync。进程级 scheduler 最多允许两个完整 verification Worker 并行,并把释放的 permit 直接交给最早的 waiter。
|
||||
|
||||
发布的 `lib/worker.cjs` 将 JavaScript workspace 依赖一起打包,使每个新 verifier 无需解析并编译它们的运行时模块图。Worker 只通过普通 request/result 消息通信,与 host 不共享 service 或 class identity,因此可以这样处理。Host build 应用现有 TypeScript 与 Typert 转换;Client pass 跳过这个 Node-only package,不会用未经转换的源代码覆盖 worker。Native add-on 保持 external。Verification、scheduler admission、termination 与 durable publication 仍在 writable open 返回前完成。Built-worker 冒烟测试把 package manifest 与 worker 复制到隔离的临时 package,移除环境中的模块搜索路径,接受有效 generation,并拒绝错误的 event count。
|
||||
|
||||
Preparation 会把 cancellation 传给 source read,并在现有的约 500 ms Decode yield 边界观察它。`publish()` 一旦开始,encode、Worker verification 与 publication 不接收 caller cancellation,并运行到终态;write open 会在之后再次检查 caller signal。已经发布的 generation 绝不会回滚。
|
||||
|
||||
Stage pipeline 终止于一份 prepared current artifact。[历史 Session 只读迁移准备](2026-09-05-read-only-session-migration-preparation.zh.md)定义 read open 如何立即消费该 artifact,以及 write open 如何在返回 append 权限前完成 encode、verification 与 publication。
|
||||
@@ -105,6 +124,10 @@ POSIX publication 使用 hard-link creation 加目录 sync;Windows 使用 no-o
|
||||
|
||||
## 验证
|
||||
|
||||
迁移规范要求分别提供转换、保留与拒绝的证据。直接迁移边和原生 V3 测试不能证明有种子的多跳发布:前代 assistant 流折叠会在 V3 插入系统事件前改变源坐标。因此,经过真实目录与 JSONL 提供方的测试需要原始及压缩的 V0/V1 输入、映射后的引用和继承切点、发布/重新打开等价性、前代字节不变,以及不产生中间代。覆盖率百分比本身不能证明这些跨阶段关系;组合断言必须比较结果历史与拒绝效果。
|
||||
|
||||
内容准入证据必须覆盖规范列出的每个位置、嵌套结果、未完成的起始记录和已知种类的畸形块,并验证诊断使用源坐标。成功迁移必须保留已接纳的内容与不透明值。经真实持久化路径拒绝时,必须保持源不变且不发布后继代。原生 V3 测试必须独立证明两种目录校验策略均保留扩展准入;历史拒绝不能证明原生输入也被拒绝。
|
||||
|
||||
### Benchmark 输入与口径
|
||||
|
||||
Benchmark 使用 Node v24.18.0 和一份 116,228,655-byte 的 v0 Zstandard 日志,其中包含 317,540 个 frame 与 454,151 个 physical row。老 reader 会恢复 9,143,111 个展开后的 v0 event;migration 会生成 72,784 个 current v2 event,artifact SHA-256 为 `fa16ff9472ca350595a3112c20a3db79655bc2673973469987ecaf2a57ebd17c`。
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md
|
||||
2026-09-01-parent-owned-subagent-catalog.md: 20e2dd035b41612592e7baeb55f89322dff02848
|
||||
2026-09-01-parent-owned-subagent-catalog.zh.md: 6ec33e8be8d4373ba98a9934178a67e207ffb0bb
|
||||
@@ -0,0 +1,49 @@
|
||||
# Agent Note: Parent-owned subagent catalog events
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-01-parent-owned-subagent-catalog.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Direct-child discovery once reconstructed a catalog from the global Session corpus and each selected child's log. Creation already knows the direct parent, child id, mode, and label, so repository-wide enumeration and child-log reads duplicated an owned fact and made browser refresh cost depend on unrelated Sessions.
|
||||
|
||||
The child descriptor remains necessary for recovery and composition, but it cannot be the discovery source because a reader must find and open the child before it can read the descriptor. Forks add a separate requirement: a seeded copy of a parent log must not inherit the original Session's children.
|
||||
|
||||
## Decision
|
||||
|
||||
The parent Session's required `subagent/catalog` events are the persistent authority for direct-child discovery. Each event is one successful creation fact containing `childId`, `childCreatedAt`, mode, and the mode-discriminated label. Remote one-shot runs without a local Session remain outside this catalog. Invalid own facts, including unsupported payload versions, reject projection restoration because silently dropping a required fact would return an incomplete catalog.
|
||||
|
||||
Creation publishes only successful facts. A one-shot run appends the catalog event after its provider returns a local child and before the run reaches its caller. A continuable run admits the initial prompt, appends the catalog event, then returns the child id. If admission or catalog append fails, creation fails and releases the activation; there is no compensating catalog event or rollback protocol.
|
||||
|
||||
The child header and `subagent/descriptor` remain authoritative for recovery and composition. An Activation and the exact parent relationship remain authoritative for authorization and delivery. Mode and label are snapshotted once and the same detached values reach the parent catalog fact and child descriptor.
|
||||
|
||||
The registered `subagentCatalog` projection materializes the parent facts. It delegates storage, append, iteration, and checkpoint validation to [`dsh-chunked-list`](../../../../packages/util/chunked-list/README.md), which stores facts in a persistent stack of 64-entry chunks, so an append copies at most the head chunk in bounded O(1) work. Materialization visits chunks from oldest to newest and preserves parent catalog event order in O(D) time for D facts. Concurrent creation is ordered by successful catalog append, independent of child timestamps and ids. A projection checkpoint clones the state once in O(D); projection-cache writes remain asynchronous and use the existing mandatory creation, turn-end, and disposal points.
|
||||
|
||||
The utility owns chunk layout and its shared capacity constant; the catalog owns event validation, fork filtering, and row conversion. Catalog projection state version 2 stores generic chunk values, so the projection registry rebuilds incompatible caches from Session events. Session event payloads and public catalog rows retain their formats.
|
||||
|
||||
Fork isolation uses the exact `Session.inheritedEventCount` supplied to projection initialization. The fold ignores `subagent/catalog` events below that offset. The state stores the inherited offset but not each event seq because acceptance is decided during folding.
|
||||
|
||||
Headless snapshot collection assigns sibling fixture roles by their parent catalog order, regardless of child creation timestamps: provider startup can publish an older Session after a newer one. The collection preserves each log verbatim.
|
||||
|
||||
Snapshot normalizers zero `childCreatedAt` because it originates from the process clock. Event order and source-event references remain intact: adjacent facts can come from sequential creation, so adjacency does not establish commutativity.
|
||||
|
||||
Current-writer snapshot expectations include catalog facts even when replay input retains a historical Session generation. The comparison preserves the catalog and its source-event references; historical replay files remain unchanged.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A flat immutable array.** Appending with `[...facts, fact]` copies D facts, so creation is O(D). Mutating a shared array would violate projection state ownership and checkpoint safety.
|
||||
|
||||
**A node-per-fact linked list.** It provides O(1) append and O(D) read, but persisted projection checkpoints form JSON nested D levels deep. Sixty-four-entry chunks preserve the asymptotic costs while reducing nesting.
|
||||
|
||||
**Separate host-state observation output.** Returning internal projection states duplicates the existing observation result mechanism and copies states unrelated to child discovery. A catalog view supplies the direct-child list through the existing typed projection map.
|
||||
|
||||
**A durable SQLite child index.** An index would create another write path, reconciliation protocol, schema, and corruption surface for a fact already ordered in the parent Session log.
|
||||
|
||||
**A compensating failure event.** Recording catalog membership before initial prompt admission requires a second operation, pairing rules, rollback cleanup, and client reconciliation. Delaying the success fact until admission completes removes that protocol.
|
||||
|
||||
## Consequences
|
||||
|
||||
Session observations and client snapshots expose the direct-child list through `projections.values.subagentCatalog`. The projection change feed publishes a complete list when catalog state changes. Each view costs O(D), so D creations can incur O(D²) cumulative view work; this follows the existing projection mechanism. Direct-child and descendant listing still use the Session corpus and child identity projection.
|
||||
|
||||
Backends that do not know the required event refuse the log under the existing Session event mechanism. Catalog projection does not reconstruct missing parent facts by scanning old child logs.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Agent Note: parent 自有的 subagent 目录事件
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-01-parent-owned-subagent-catalog.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
直接 child discovery 曾从全局 Session 语料与每个入选 child 的日志重建目录。创建过程已经知道直接 parent、child id、mode 与 label,因此仓库范围枚举和 child 日志读取重复推导了已有归属的事实,并让浏览器刷新成本取决于无关 Session。
|
||||
|
||||
child descriptor 对恢复与 composition 仍然必要,但它不能作为 discovery 来源,因为读取方必须先找到并打开 child 才能读取 descriptor。fork 还有独立要求:从 parent 日志播种的副本不能继承原 Session 的 child。
|
||||
|
||||
## 决策
|
||||
|
||||
parent Session 的 required `subagent/catalog` 事件是直接 child discovery 的持久化权威。每个事件都是一条成功创建事实,包含 `childId`、`childCreatedAt`、mode 与按 mode 区分的 label。没有本地 Session 的远程 one-shot run 不进入该目录。无效的自身 fact(包括不支持的 payload 版本)会使 projection 恢复失败,因为静默丢弃 required fact 会返回不完整的目录。
|
||||
|
||||
创建只发布成功事实。one-shot run 在 provider 返回本地 child 后、run 到达调用方前追加目录事件。continuable run 先准入初始 prompt,再追加目录事件,最后返回 child id。准入或目录追加失败时,创建失败并释放 activation;不存在补偿目录事件或 rollback 协议。
|
||||
|
||||
child header 与 `subagent/descriptor` 继续拥有恢复与 composition 权威。Activation 与精确 parent 关系继续拥有授权与投递权威。mode 与 label 只快照一次,同一份分离值写入 parent catalog fact 与 child descriptor。
|
||||
|
||||
注册的 `subagentCatalog` projection 物化 parent fact。它将存储、追加、迭代和检查点校验交给 [`dsh-chunked-list`](../../../../packages/util/chunked-list/README.zh.md),后者以每块 64 项的持久 stack 保存事实,因此 append 最多复制 head chunk,以有界 O(1) 工作完成。materialization 从旧到新访问 chunk,对 D 条事实以 O(D) 时间保留父目录事件顺序。并发创建按目录成功追加的顺序排列,与 child 时间戳和 id 无关。projection checkpoint 以 O(D) 克隆 state;projection-cache 继续异步写入,并使用既有创建、turn-end 与 disposal 强制点。
|
||||
|
||||
工具库拥有分块布局及其共享容量常量;目录拥有事件校验、fork 过滤和目录行转换。目录 projection state 版本 2 保存通用块值,因此 projection registry 从 Session 事件重建不兼容的缓存。Session 事件载荷和公开目录行保持各自格式。
|
||||
|
||||
fork 隔离使用 projection 初始化时提供的精确 `Session.inheritedEventCount`。fold 忽略该 offset 之前的 `subagent/catalog` 事件。state 保存 inherited offset,但不保存每条 event seq,因为接受判定已在 fold 时完成。
|
||||
|
||||
Headless 快照采集按父目录顺序分配同父子级的 fixture 角色,不依赖子级创建时间戳:provider 启动可能在较新的 Session 之后发布较旧的 Session。采集过程原样保留每份日志。
|
||||
|
||||
snapshot normalizer 会把 `childCreatedAt` 归零,因为它来自 process clock。事件顺序与来源事件引用保持不变:相邻 fact 也可能来自顺序创建,因此相邻关系不能证明可交换性。
|
||||
|
||||
即使 replay 输入保留历史 Session generation,当前 writer 的快照预期也包含 catalog 事实。比较保留 catalog 及其来源事件引用;历史 replay 文件保持不变。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**扁平不可变数组。** 用 `[...facts, fact]` append 会复制 D 个 fact,因此创建是 O(D)。修改共享数组会违反 projection state ownership 与 checkpoint 安全。
|
||||
|
||||
**每 fact 一个 node 的 linked list。** 它提供 O(1) append 与 O(D) read,但持久 projection checkpoint 会形成 D 层 JSON 嵌套。每块 64 项保留渐进复杂度,同时降低嵌套深度。
|
||||
|
||||
**独立的 host state 观察输出。** 返回内部 projection state 会重复已有观察结果机制,并复制与子级发现无关的状态。目录视图通过既有的类型化 projection map 提供直接子级列表。
|
||||
|
||||
**持久 SQLite child index。** index 会为 parent Session 日志中已有顺序的 fact 增加另一套写路径、reconciliation protocol、schema 与 corruption surface。
|
||||
|
||||
**补偿失败事件。** 在初始 prompt 准入前记录 catalog membership 会引入第二种 operation、配对规则、rollback 清理与 client reconciliation。把成功事实推迟到准入完成后即可删除该协议。
|
||||
|
||||
## 后果
|
||||
|
||||
Session 观察和客户端快照通过 `projections.values.subagentCatalog` 暴露直接子级列表。目录状态变化时,projection 变更通知发布完整列表。每次视图计算成本为 O(D),因此 D 次创建的累计视图工作量可能为 O(D²);这沿用既有 projection 机制。直接子级和后代列表仍使用 Session 语料库与子级身份 projection。
|
||||
|
||||
不认识该 required event 的 backend 会按既有 Session event 机制拒绝日志。目录投影不通过扫描旧子级日志来重建缺失的父级事实。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md
|
||||
2026-09-01-v2-embedded-assistant-streams.md: bee4d50fb830caa277bb700f3415e6d7f98ff64b
|
||||
2026-09-01-v2-embedded-assistant-streams.zh.md: 9116e94111b78af68d33f6f01dc289ee9f349b7e
|
||||
2026-09-01-v2-embedded-assistant-streams.md: 98207749028e2182c5e60073fc07985688ecfa30
|
||||
2026-09-01-v2-embedded-assistant-streams.zh.md: 90dff5a385cf83071ec52a2fc2b57a893107b861
|
||||
@@ -14,6 +14,8 @@ Changing event cardinality also changes Session sequence numbers. A released mig
|
||||
|
||||
## Decision
|
||||
|
||||
The [V3 canonical-envelope decision](2026-09-06-v3-canonical-session-envelopes.md) owns current replacement-key and header-acceptance rules. It preserves the embedded streams, attempt settlements, and frozen v1-to-v2 conversion described here.
|
||||
|
||||
Session format v2 has no top-level `assistant/chunk` event. Each model attempt commits one durable settlement containing `stream: AssistantStreamRecord[]`:
|
||||
|
||||
- `assistant/message` is the surface settlement for a successful response or a cancelled response with visible assembled content. It embeds the exact compact timed stream beside the assembled message, optional usage, and optional `interrupted: true` marker.
|
||||
|
||||
@@ -14,6 +14,8 @@ Token 粒度的 `assistant/chunk` 事件会保留精确的 stream 顺序、时
|
||||
|
||||
## 决策
|
||||
|
||||
[V3 规范信封决策](2026-09-06-v3-canonical-session-envelopes.zh.md)负责当前替换键与请求头接纳规则。它保留本文的嵌入式 stream、尝试结算与冻结的 v1-to-v2 转换。
|
||||
|
||||
Session format v2 没有顶层 `assistant/chunk` 事件。每个模型 attempt 提交一个包含 `stream: AssistantStreamRecord[]` 的持久 settlement:
|
||||
|
||||
- `assistant/message` 是成功响应或具有可见组装内容的已取消响应所对应的 surface settlement。它在组装 message 旁嵌入精确的紧凑带时间 stream、可选 usage 与可选 `interrupted: true` marker。
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md
|
||||
2026-09-02-system-prompt-as-surface-node.md: 1500fd2350f02ab5d8f203c0d62b98832c6c5de5
|
||||
2026-09-02-system-prompt-as-surface-node.zh.md: 306f347092caa3e9daf2494fa26d290b1c1ab9a7
|
||||
@@ -0,0 +1,95 @@
|
||||
# Agent Note: The system prompt is surface node 0
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-02-system-prompt-as-surface-node.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A system prompt held outside the surface has a different durable representation from every other message the model reads. Conversation messages are surface events (`user/message`, `assistant/message`, `tool/result`) folded in seq order by `Session.deriveMessages()`; a prompt stored as a `system` field of the log-only `request/header` snapshot has to be prepended by each serializer as wire message 0. The [reconstructable-requests Agent Note](2026-07-05-reconstructable-requests.md) made both halves durable, but that layout leaves one model-visible fact with two homes: the surface owns the messages, the header owns the message in front of them.
|
||||
|
||||
That split forces every reader of "what did the model see" to join two sources: the compaction summarizer copies the header prompt in front of the region's derived messages, `dsh-token-meter` estimates the system prompt from the header while pricing every other message from the surface, and the Web request-prompt card, the trajectory view, and the snapshot normalizer's `{{system}}` placeholder each read the header on their own. Change detection is split the same way: a `headerEquals` that compares `system` byte-for-byte beside `config` and `tools` makes a prompt change and a tool change indistinguishable in the log (`request/header` reason `change`) even though they are different operations on the conversation.
|
||||
|
||||
The split also blocks the next step. A model that accepts a mid-conversation `system` message as a prompt replacement needs the harness to append a system-role message to history; with the prompt living in the header there is no surface representation to append, and the header would have to be frozen by special case. The [in-history replacement decision](../feature/2026-09-02-in-history-system-prompt-replacement.md) depends on this note.
|
||||
|
||||
## Decision
|
||||
|
||||
The system prompt lives on the surface. It is an ordinary surface event, `system/message`, and every prompt lifecycle operation is one of the two existing `SurfaceOp` variants applied to that event type. The wire request is unchanged: the surface fold yields the message list the serializers send, with the system message first.
|
||||
|
||||
### The event
|
||||
|
||||
`system/message` is a member of `SurfaceEventType` beside `user/message`, `assistant/message`, and `tool/result` (`packages/core/session/src/types.ts`). Its payload mirrors `tool/result`: `{ turn, step, message }`, where `message` is a `SystemMessage` with `role: 'system'`, one text block holding the rendered prompt, and source `{ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }`. Empty `content` records "no system prompt": the node keeps its surface position and `deriveEventMessage` projects it to `null`, so it contributes no wire message. A non-empty node projects verbatim, so `deriveMessages()` returns the system message at its surface position and the DeepSeek serializers, which pass a `role: 'system'` history message through unchanged, emit it as wire message 0. `EpochHeader` is `{ config, adapterDefaults?, tools? }`; `canonicalHeader` and `headerEquals` in `packages/core/session/src/request-header.ts` compare config, adapter defaults, and tools only.
|
||||
|
||||
### The operations
|
||||
|
||||
| Situation | Surface operation |
|
||||
|---|---|
|
||||
| No `system/message` survives on the surface (including an empty rendered prompt) | append `system/message`; on the session's first step it is surface node 0, before the first `user/message` of the step |
|
||||
| A `system/message` survives and the rendered prompt differs from its text (including a prompt that becomes empty) | replace exactly that node: `surfaceOp: { op: 'replace', startSeq: <seq of the node>, endSeq: <same> }`, `sourceEventSeqs: [<seq of the node>]`; an empty prompt produces an empty-content node that projects to no message |
|
||||
| The rendered prompt equals the surviving node's text | no operation |
|
||||
|
||||
When the initial rendered prompt is empty, the loop reserves an empty system head before the initial admitted user messages so a prompt that first becomes non-empty later still replaces node 0. Omitting that empty node would append the later prompt behind user history, where pi-ai converts it to a user message rather than its `systemPrompt`. Replacing node 0 is a head rewrite expressed on the surface: the provider prefix changes from the first token, the log records the shadowed node through `sourceEventSeqs`, and `replaceGeneration` advances as it does for a compaction replacement. The loop's `startsSeries` detection (`requestSurfaceGeneration !== surfaceGeneration`) therefore covers the prompt change without a `system` comparison in `headerEquals`. `request/header` keeps reasons `initial`, `resume`, `change`, and `series`; `change` means config or tools changed, and the unchanged header that follows a prompt replacement logs as `series`.
|
||||
|
||||
`packages/core/session/src/surface.ts` enforces the head invariant in `assertSystemHeadRewrite`: a replacement whose range covers surface node 0 while node 0 is a `system/message` is rejected unless the replacing event is itself a `system/message` covering exactly that node. System nodes at later positions carry no such protection; a compaction range may shadow them.
|
||||
|
||||
### Ownership in the loop
|
||||
|
||||
`dsh-agent-loop` owns `SystemPromptProjection` beside `RuntimeContextProjection` in `packages/core/agent-loop/src/runtime-context.ts`. It reads the surviving `system/message` nodes from the current surface on every projection, so a compaction or replacement that ran earlier in the same step is already reflected. `project(rendered, { inHistory, startsSeries })` returns `{ message, intent }` — `intent` is `{ surfaceOp: 'append' }` when no system node survives or when the [in-history rule](../feature/2026-09-02-in-history-system-prompt-replacement.md) applies, otherwise a replacement of exactly the latest surviving system node — or `undefined` when the latest node already holds the rendered text.
|
||||
|
||||
In `packages/core/agent-loop/src/agent.ts`, `preStep` renders the prompt with `renderPrompt(assembly)` and projects it after the `agent/pre-step` waterfall, so a compaction provider's replacement inside that waterfall is visible to the decision; `turn()` commits the `system/message` immediately after `step/start` and before the step's `user/message` events, so log order is wire order. `buildRequest` sets no `system` on the request: the request is `header.config`, `session.deriveMessages()` (system message first), and `header.tools`. The loop step order is: claim inbox → `systemPrompt.assemble()` → project runtime context → `agent/pre-step` waterfall → project system prompt → `step/start` → commit `system/message` (when changed) → commit `user/message`s → `agent/request` waterfall → `request/header` → `request/context` → stream. The `dsh-agent-loop/invariant` companion (`packages/core/agent-loop/src/invariant.ts`) asserts that a loop-built request has `system === undefined` and `messages` equal to `deriveMessages()`.
|
||||
|
||||
`dsh-token-meter` anchors usage to the priced surface immediately before the successful `assistant/message`, not to `step/start`. The loop admits the system prompt and user messages after step start, and retry recovery can replace nodes before rebuilding the request. Capturing that current surface includes every admitted input once; the embedded provider output remains separately priced so durable assistant rewrites retain their signed delta. The open step stores only turn and step for lifecycle validation, not a second node snapshot.
|
||||
|
||||
### Consumers
|
||||
|
||||
| Consumer | Reads |
|
||||
|---|---|
|
||||
| DeepSeek serializers (`serializeRequest`, `serializeRequestWithImages`) | `options.messages`, passing the `role: 'system'` history message through as wire message 0; `GenerateOptions.system` remains for direct one-shot callers such as title providers |
|
||||
| `dsh-llm-pi-ai` | a leading system history message maps to pi-ai's `systemPrompt` |
|
||||
| `compaction-basic` `buildSummarizationInput` | node 0's derived message prepended to the region in `SummarizationInput.messages`, with no separate `system` field; an empty-content head projects to no message while staying protected from compaction |
|
||||
| `compaction-basic` `selectCompactableRange` | anchors at the first non-system node; node 0 is never inside a compaction range |
|
||||
| `dsh-token-meter` | the system node is priced as a surface node under the `systemTokens` breakdown |
|
||||
| Web request-prompt card, trajectory request node, request inspection | the `system/message` node; a replaced node 0 is shown as a prompt change and an appended in-history node as a prompt update, each in a collapsed inspectable card, never a chat bubble |
|
||||
| Snapshot normalizer `{{system}}` placeholder, plan-mode tests | the system node's text |
|
||||
| TypeScript and Python SDK expected outputs | include the `system/message` event |
|
||||
| Human transcript projections | skip `system/message`; it is model history, not conversation |
|
||||
|
||||
`RuntimeContextProjection` and `SystemPromptProjection` both hand the loop an uncommitted message that `turn()` commits. They differ in how they observe the surface and in their operation set: runtime context follows `session/event` for its owned user-role snapshots and appends only, while the system prompt scans the current surface for system nodes on each projection because its decision depends on how many survive, and it appends or replaces per the route.
|
||||
|
||||
### V2-to-V3 structural conversion
|
||||
|
||||
The [V2-to-V3 specification](../../../../packages/session/session-format-v2-to-v3/README.md#system-head) owns system-head conversion and message identities; its [reference rules](../../../../packages/session/session-format-v2-to-v3/README.md#sequence-references) and [source refusal](../../../../packages/session/session-format-v2-to-v3/README.md#source-audit) define preservation and unsupported inputs. The migrated layout is semantically equivalent to native requests, not byte-identical to a native recording. A valid V2 source can lack an order-preserving conversion under the current step invariant; refusing it is preferable to moving history or relaxing ownership. Historical acceptance coordinates must not become acknowledgements of the transformed log.
|
||||
|
||||
The [released-format policy](2026-08-31-released-session-format-migrations.md) preserves each released conversion’s semantics; an existing target-format generation does not rerun its incoming edge. Projection-cache versions are independent of Session format versions.
|
||||
|
||||
The [canonical-envelope specification](../../../../packages/session/session-format-v2-to-v3/README.md#canonical-envelopes) defines composition with the structural conversion; the [canonical-envelope decision](2026-09-06-v3-canonical-session-envelopes.md) owns the strict-acceptance rationale.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep `header.system` and add `system/message` only for updates.** Two homes for one fact: every consumer above would read the header for message 0 and the surface for later messages, and the loop would need a special case that ignores `system` in `headerEquals` while a surface system node exists. Rejected because the point of the change is one representation.
|
||||
|
||||
**A dedicated log-only `system-prompt/change` event that rewrites the header.** Preserves the header as the home of the prompt and records changes as their own event kind, but still cannot express a system message inside history, so the in-history proposal would need a second mechanism anyway. Rejected.
|
||||
|
||||
**Synthesize the system message inside the adapter from consecutive headers.** The adapter is stateless per request and never sees the log; a wire history that depends on adapter state is not reconstructable from the surface fold. Rejected.
|
||||
|
||||
**Express the prompt as a `user/message` snapshot like runtime context.** Reuses an existing event type but sends the wrong role, so a model that treats a system message as authoritative would not. Rejected.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One representation: every reader of "what did the model see" folds the surface; no consumer joins the header to the message list. `EpochHeader` has no `system` field, so a reader that expects one fails at compile time.
|
||||
- A prompt change and a tool or config change are distinguishable in the log: the former is a `system/message` replacement of node 0 followed by a `series` header, the latter a `request/header` with reason `change`.
|
||||
- Compaction carries an invariant: node 0 is never compacted. The `dsh-session` surface manager enforces it in the replace operation itself, so a compaction provider other than `compaction-basic` cannot shadow the prompt by anchoring at `surfaceNodes[0]`. Later system nodes are unprotected by design.
|
||||
- `replaceGeneration` advances for a prompt replacement as well as for compaction; a reader that needs to distinguish them inspects the replacement event's type.
|
||||
- A mid-history system node has a surface representation, which is what the [in-history replacement decision](../feature/2026-09-02-in-history-system-prompt-replacement.md) builds on.
|
||||
- An initially empty prompt occupies the protected head without contributing a wire message; in replacement mode, a later non-empty prompt replaces it and remains the leading system message.
|
||||
- Recorded snapshot fixtures carry the `system/message` event instead of a header `system` field. The snapshot normalizer tokenizes that event's text to `{{system}}`, the prompt sidecar is harvested from the `system/message` sequence (one section per prompt version, declared as `header.promptChanges`), and `request/header` pins compare config and tools only.
|
||||
|
||||
## Testing
|
||||
|
||||
- `packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts` pins zero post-call surface delta with provider usage through initial, growing, shrinking, and empty prompts, same-step retry replacement, request middleware, and fresh replay.
|
||||
- `packages/core/session/tests/surface.spec.ts` (`system/message surface node` block) pins the leading system-role projection, the empty-content `null` projection, `assertSystemHeadRewrite`'s acceptance and rejection paths, the unprotected later system nodes, and the rejection of a seeded `system/message` with a non-system role or non-plugin source.
|
||||
- `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` pins the append on first render (including empty), the later non-empty prompt at the derived head in replacement mode, the no-op on an unchanged prompt, the replacement of the latest surviving node on change, the tail append after a replacement shadowed a non-head system node, and the in-history append and re-baseline rules.
|
||||
- `packages/core/agent-loop/tests/request-reconstruction.spec.ts` (`a system-prompt change replaces surface node 0 and starts a new series under the same header`) pins the `series` header that follows a prompt replacement.
|
||||
- `packages/core/agent-loop/tests/invariant.spec.ts` pins the companion's rejection of a loop request carrying a `system` field and its `messages` equality check against the boundary derivation.
|
||||
- `packages/llm/llm-deepseek/tests/serialize.spec.ts` (`serializes a leading system message byte-for-byte like the same prompt passed as options.system`) pins wire identity. `packages/llm/llm-pi-ai/tests/context.spec.ts` compares both system sources on text and image paths. `packages/compaction/compaction-basic/tests/compaction-basic.spec.ts` pins the derived prefix, routed tools, absent separate `system` option, and protected non-empty or empty head through the region transaction and default summarizer.
|
||||
- The recorded snapshots under `snapshots/` pin the model-visible wire request of every shipped profile; a recorded session that renders a prompt carries the `system/message` event at surface node 0 in its `session.jsonl`, and a session with a mid-session prompt change carries the replacement of node 0 or, on an in-history route, the appended node.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Agent Note: 系统提示词是 surface 的第 0 号节点
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-02-system-prompt-as-surface-node.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
放在 surface 之外的系统提示词,其持久化表示与模型读到的其他所有消息都不同。对话消息是 surface 事件(`user/message`、`assistant/message`、`tool/result`),由 `Session.deriveMessages()` 按 seq 顺序折叠;而存放在仅记日志的 `request/header` 快照 `system` 字段中的提示词,必须由每个序列化器前置为协议消息 0。[可重建请求 Agent Note](2026-07-05-reconstructable-requests.zh.md) 让两半都成为持久数据,但这种布局让一个模型可见的事实拥有两个归属:surface 拥有消息,header 拥有排在这些消息之前的那条消息。
|
||||
|
||||
这种拆分迫使每个想知道「模型看到了什么」的读取方都要合并两个来源:压缩(compaction)摘要器把 header 中的提示词复制到区域派生消息之前,`dsh-token-meter` 从 header 估算系统提示词却从 surface 为其他每条消息计价,Web 请求提示词卡片、轨迹视图和快照归一化器的 `{{system}}` 占位符各自单独读取 header。变更检测同样被拆开:在 `config` 和 `tools` 旁边逐字节比较 `system` 的 `headerEquals`,让提示词变更与工具变更在日志中无法区分(`request/header` 的 reason 都是 `change`),尽管它们是对对话的两种不同操作。
|
||||
|
||||
这种拆分还阻塞了下一步。一个把对话中途的 `system` 消息当作提示词替换来接受的模型,需要 harness 向历史追加一条 system 角色消息;当提示词住在 header 里时,没有可追加的 surface 表示,header 也只能靠特例被冻结。[历史内替换决定](../feature/2026-09-02-in-history-system-prompt-replacement.zh.md) 依赖本 Agent Note。
|
||||
|
||||
## Decision
|
||||
|
||||
系统提示词住在 surface 上。它是一个普通的 surface 事件 `system/message`,提示词生命周期中的每个操作都是对该事件类型施加现有两种 `SurfaceOp` 变体之一。协议请求不变:surface 折叠产出的就是序列化器发送的消息列表,系统消息在最前面。
|
||||
|
||||
### 事件
|
||||
|
||||
`system/message` 是 `SurfaceEventType` 的成员,与 `user/message`、`assistant/message`、`tool/result` 并列(`packages/core/session/src/types.ts`)。它的载荷与 `tool/result` 对称:`{ turn, step, message }`,其中 `message` 是 `role: 'system'` 的 `SystemMessage`,一个文本块承载渲染后的提示词,source 为 `{ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }`。空的 `content` 记录「没有系统提示词」:该节点保持其 surface 位置,`deriveEventMessage` 把它投影为 `null`,因此不贡献任何协议消息。非空节点逐字投影,因此 `deriveMessages()` 在其 surface 位置返回系统消息,而原样透传 `role: 'system'` 历史消息的 DeepSeek 序列化器把它作为协议消息 0 发出。`EpochHeader` 是 `{ config, adapterDefaults?, tools? }`;`packages/core/session/src/request-header.ts` 中的 `canonicalHeader` 与 `headerEquals` 只比较 config、适配器默认值和工具。
|
||||
|
||||
### 操作
|
||||
|
||||
| 情形 | surface 操作 |
|
||||
|---|---|
|
||||
| surface 上没有存活的 `system/message`(包括渲染后的提示词为空时) | 追加 `system/message`;在会话的首个步骤中它是 surface 第 0 号节点,位于该步骤首条 `user/message` 之前 |
|
||||
| 有存活的 `system/message` 且渲染后的提示词与其文本不同(包括提示词变为空) | 恰好替换该节点:`surfaceOp: { op: 'replace', startSeq: <该节点的 seq>, endSeq: <同一值> }`,`sourceEventSeqs: [<该节点的 seq>]`;空提示词产生一个投影为无消息的空内容节点 |
|
||||
| 渲染后的提示词与存活节点的文本相同 | 无操作 |
|
||||
|
||||
当初始渲染的提示词为空时,循环在初始接纳的用户消息之前预留空系统头部,使稍后首次变为非空的提示词仍替换第 0 号节点。省略该空节点会让后来的提示词追加在用户历史之后,pi-ai 会将其转换为用户消息,而不是 `systemPrompt`。替换第 0 号节点是头部重写在 surface 上的表达:提供方前缀从第一个 token 起改变,日志通过 `sourceEventSeqs` 记录被遮蔽的节点,`replaceGeneration` 与压缩替换时一样推进。因此循环的 `startsSeries` 检测(`requestSurfaceGeneration !== surfaceGeneration`)无需在 `headerEquals` 中比较 `system` 即可覆盖提示词变更。`request/header` 保留 `initial`、`resume`、`change`、`series` 四种 reason;`change` 表示 config 或 tools 变更,提示词替换之后跟随的未变 header 记为 `series`。
|
||||
|
||||
`packages/core/session/src/surface.ts` 在 `assertSystemHeadRewrite` 中强制头部不变量:当第 0 号节点是 `system/message` 时,范围覆盖第 0 号节点的替换会被拒绝,除非替换事件本身是恰好覆盖该节点的 `system/message`。位于更后位置的系统节点没有此类保护;压缩范围可以遮蔽它们。
|
||||
|
||||
### 循环中的归属
|
||||
|
||||
`dsh-agent-loop` 在 `packages/core/agent-loop/src/runtime-context.ts` 中与 `RuntimeContextProjection` 并列拥有 `SystemPromptProjection`。它在每次投影时从当前 surface 读取存活的 `system/message` 节点,因此同一步骤中更早运行的压缩或替换已经反映在内。`project(rendered, { inHistory, startsSeries })` 返回 `{ message, intent }`——没有系统节点存活或[历史内规则](../feature/2026-09-02-in-history-system-prompt-replacement.zh.md)适用时 `intent` 为 `{ surfaceOp: 'append' }`,否则是对最新存活系统节点的精确替换——最新节点已持有渲染文本时返回 `undefined`。
|
||||
|
||||
在 `packages/core/agent-loop/src/agent.ts` 中,`preStep` 用 `renderPrompt(assembly)` 渲染提示词,并在 `agent/pre-step` waterfall 之后投影它,因此压缩提供者在该 waterfall 内做出的替换对决定可见;`turn()` 紧接在 `step/start` 之后、该步骤的 `user/message` 事件之前提交 `system/message`,因此日志顺序即协议顺序。`buildRequest` 不在请求上设置 `system`:请求由 `header.config`、`session.deriveMessages()`(系统消息在先)和 `header.tools` 构成。循环步骤顺序为:领取收件箱 → `systemPrompt.assemble()` → 投影运行时上下文 → `agent/pre-step` waterfall → 投影系统提示词 → `step/start` → 提交 `system/message`(有变化时) → 提交各条 `user/message` → `agent/request` waterfall → `request/header` → `request/context` → 流式请求。`dsh-agent-loop/invariant` 伴随组件(`packages/core/agent-loop/src/invariant.ts`)断言循环构建的请求满足 `system === undefined` 且 `messages` 等于 `deriveMessages()`。
|
||||
|
||||
`dsh-token-meter` 把用量锚定到成功的 `assistant/message` 之前的已计价 surface,而不是 `step/start`。循环在步骤开始之后接纳系统提示词与用户消息,重试恢复还可能在重建请求之前替换节点。捕获当前 surface 会让每个已接纳输入恰好计入一次;内嵌的提供方输出仍单独计价,因此持久 assistant 改写保留其带符号增量。开放步骤只保存 turn 与 step 以验证生命周期,不保存第二份节点快照。
|
||||
|
||||
### 消费方
|
||||
|
||||
| 消费方 | 读取内容 |
|
||||
|---|---|
|
||||
| DeepSeek 序列化器(`serializeRequest`、`serializeRequestWithImages`) | `options.messages`,把 `role: 'system'` 的历史消息作为协议消息 0 透传;`GenerateOptions.system` 为标题提供方等直接单次调用方保留 |
|
||||
| `dsh-llm-pi-ai` | 开头的 system 历史消息映射为 pi-ai 的 `systemPrompt` |
|
||||
| `compaction-basic` 的 `buildSummarizationInput` | 第 0 号节点的派生消息前置于 `SummarizationInput.messages` 中的区域消息,无单独的 `system` 字段;空内容头节点不投影为消息,但仍受保护而不能被压缩 |
|
||||
| `compaction-basic` 的 `selectCompactableRange` | 锚定在首个非系统节点;第 0 号节点永不落入压缩范围 |
|
||||
| `dsh-token-meter` | 系统节点作为 surface 节点计价,归入 `systemTokens` 明细 |
|
||||
| Web 请求提示词卡片、轨迹请求节点、请求检视 | `system/message` 节点;被替换的第 0 号节点显示为提示词变更,追加的历史内节点显示为提示词更新,各自以折叠可检视的卡片呈现,永不作为聊天气泡 |
|
||||
| 快照归一化器的 `{{system}}` 占位符、plan-mode 测试 | 系统节点的文本 |
|
||||
| TypeScript 与 Python SDK 预期输出 | 包含 `system/message` 事件 |
|
||||
| 人类 transcript(文本记录)投影 | 跳过 `system/message`;它是模型历史,不是对话 |
|
||||
|
||||
`RuntimeContextProjection` 与 `SystemPromptProjection` 都把一条未提交的消息交给循环由 `turn()` 提交。两者在观察 surface 的方式与操作集上不同:运行时上下文跟随 `session/event` 观察自己拥有的 user 角色快照且只做追加,而系统提示词在每次投影时扫描当前 surface 上的系统节点,因为它的决定取决于有多少节点存活,并按路由追加或替换。
|
||||
|
||||
### V2-to-V3 结构转换
|
||||
|
||||
[V2 到 V3 规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#system-head)负责系统头节点转换与消息身份;其[引用规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#sequence-references)和[源拒绝](../../../../packages/session/session-format-v2-to-v3/README.zh.md#source-audit)定义保留内容与不支持的输入。迁移布局与原生请求语义等价,而非与原生录制逐字节相同。有效 V2 源在当前步骤不变量下可能没有保持顺序的转换方式;拒绝它优于移动历史或放宽归属。历史接收坐标不得变为对转换后日志的确认。
|
||||
|
||||
[已发布格式策略](2026-08-31-released-session-format-migrations.zh.md)保留每条已发布转换的语义;已有目标格式代际不会重跑其入边。投影缓存版本独立于 Session 格式版本。
|
||||
|
||||
[规范信封规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#canonical-envelopes)定义与结构转换的组合;[规范信封决策](2026-09-06-v3-canonical-session-envelopes.zh.md)负责严格准入的依据。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留 `header.system`,只为更新添加 `system/message`。** 一个事实两个归属:上述每个消费方都要从 header 读消息 0、从 surface 读后续消息,循环还需要一个在 surface 存在系统节点时让 `headerEquals` 忽略 `system` 的特例。被否决,因为本次变更的目的就是单一表示。
|
||||
|
||||
**用专门的仅记日志事件 `system-prompt/change` 重写 header。** 保留 header 作为提示词归属,并把变更记录为独立事件种类,但仍无法表达历史内部的系统消息,历史内替换提案还是需要第二套机制。被否决。
|
||||
|
||||
**在适配器内根据相邻 header 合成系统消息。** 适配器逐请求无状态且从不接触日志;依赖适配器状态的协议历史无法从 surface 折叠重建。被否决。
|
||||
|
||||
**像运行时上下文那样用 `user/message` 快照表达提示词。** 复用了现有事件类型,却发送了错误的角色,因此把系统消息视为权威的模型不会这样对待它。被否决。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 单一表示:每个想知道「模型看到了什么」的读取方都折叠 surface;没有消费方需要把 header 与消息列表合并。`EpochHeader` 没有 `system` 字段,因此期望该字段的读取方在编译期失败。
|
||||
- 提示词变更与工具或 config 变更在日志中可以区分:前者是对第 0 号节点的 `system/message` 替换加随后的 `series` header,后者是 reason 为 `change` 的 `request/header`。
|
||||
- 压缩带有一条不变量:第 0 号节点永不被压缩。`dsh-session` 的 surface 管理器在替换操作本身中强制它,因此除 `compaction-basic` 以外的压缩提供方无法通过锚定在 `surfaceNodes[0]` 来遮蔽提示词。更后位置的系统节点按设计不受保护。
|
||||
- `replaceGeneration` 在提示词替换时和压缩时一样推进;需要区分两者的读取方检查替换事件的类型。
|
||||
- 历史中途的系统节点拥有 surface 表示,这正是[历史内替换决定](../feature/2026-09-02-in-history-system-prompt-replacement.zh.md)所依赖的基础。
|
||||
- 初始空提示词占据受保护的头部,但不贡献协议消息;在替换模式下,后来的非空提示词替换它,并保持为开头的系统消息。
|
||||
- 录制的快照 fixture 携带 `system/message` 事件而非 header 的 `system` 字段。快照归一化器把该事件的文本标记化为 `{{system}}`,提示词伴随文件从 `system/message` 序列采集(每个提示词版本一节,以 `header.promptChanges` 声明),`request/header` 的 pin 只比较 config 与 tools。
|
||||
|
||||
## Testing
|
||||
|
||||
- `packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts` 钉住提供方用量下调用后的表面增量为零,覆盖初始、增长、缩短与空提示词、同一步骤中的重试替换、请求中间件和全新回放。
|
||||
- `packages/core/session/tests/surface.spec.ts`(`system/message surface node` 块)钉住开头 system 角色的投影、空内容的 `null` 投影、`assertSystemHeadRewrite` 的接受与拒绝路径、更后位置系统节点不受保护,以及对 seed 中非 system 角色或非插件 source 的 `system/message` 的拒绝。
|
||||
- `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` 钉住首次渲染时的追加(包括空提示词)、替换模式下后来非空提示词位于派生历史头部、提示词未变时的无操作、变更时对最新存活节点的替换、替换遮蔽了非头部系统节点之后的尾部追加,以及历史内追加与重新基线规则。
|
||||
- `packages/core/agent-loop/tests/request-reconstruction.spec.ts`(`a system-prompt change replaces surface node 0 and starts a new series under the same header`)钉住提示词替换之后跟随的 `series` header。
|
||||
- `packages/core/agent-loop/tests/invariant.spec.ts` 钉住伴随组件对携带 `system` 字段的循环请求的拒绝,以及其 `messages` 与边界派生结果的相等性检查。
|
||||
- `packages/llm/llm-deepseek/tests/serialize.spec.ts`(`serializes a leading system message byte-for-byte like the same prompt passed as options.system`)钉住协议一致性。 `packages/llm/llm-pi-ai/tests/context.spec.ts` 在文本与图片路径上比较两种系统提示词来源。`packages/compaction/compaction-basic/tests/compaction-basic.spec.ts` 通过区域事务与默认摘要器钉住派生前缀、已路由工具、不携带单独 `system` 选项,以及非空或空头节点的保护。
|
||||
- `snapshots/` 下的录制快照钉住每个随发 profile 的模型可见协议请求;渲染了提示词的录制会话在其 `session.jsonl` 中于 surface 第 0 号节点携带 `system/message` 事件,会话中途发生提示词变更的会话则携带对第 0 号节点的替换,或在历史内路由上携带追加的节点。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-client-resource-model.md
|
||||
2026-09-05-client-resource-model.md: e6ad8dae331d8a6392585f5dc8eb8fc52150bdc3
|
||||
2026-09-05-client-resource-model.zh.md: d716aa8a42f5c3037a329deabd572a56ab1b0537
|
||||
@@ -0,0 +1,86 @@
|
||||
# Agent Note: Client resource model
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-05-client-resource-model.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A right-Sidebar tab body, a chat card, or any other slot component often needs live data it knows only by address: the file an agent just wrote, later a chat node or a terminal. Before the resource model each consumer fetched for itself — the text preview owned its own Remote call and refresh loop — so every mount re-read, two components showing one file held two copies, switching tabs unmounted the body and lost its content, and each new kind of content meant a new bespoke hook.
|
||||
|
||||
The tab record set the constraint. A tab must survive undo, redo, body remount, and hot module replacement without the code that opened it, so the record can hold only serializable data: an address and navigation parameters. Browser-page reload resets the memory-only Sidebar state. The opener therefore cannot hand a body its data, and injection is the wrong tool — injection is a registration-time relation between a domain and a seat, while opening is a runtime event. A component has to find its data from the address alone, through something registered once by whoever owns that kind of data.
|
||||
|
||||
## Decision
|
||||
|
||||
[`packages/client/resources`](../../../../packages/client/resources/README.md) (`@deepseek-ai/dsh-client-resources`) provides `ctx.resources` and the `useResource` global standard hook. Anything a consumer reads live is a **resource**, a resource is identified by its **address** and nothing else, and the address's protocol names the one **provider** that turns it into a frame stream.
|
||||
|
||||
### Addresses
|
||||
|
||||
A resource address is a `dsh-resource://<type>/…` URL. The host is the protocol key — the key of `ResourceProtocolMap` — and the path belongs to the protocol's owner. `RESOURCE_SCHEME = 'dsh-resource'` is the one scheme constant; `protocolOf(address)` parses the string with `new URL`, requires `protocol === 'dsh-resource:'`, and returns the lower-cased host, or `undefined` for a string the parser rejects, another scheme, or an empty host. `dsh-resource` is not one of the URL specification's special schemes, so the parser keeps the host's case and treats the path as opaque; the lower-casing is explicit, and each path segment is percent-encoded by the protocol that defines it. A protocol that needs a scope encodes it in the path: `dsh-resource://file/session/<sessionId>/<path>`, with `session/<sessionId>` naming the Session whose Host workspace resolves the relative or absolute path ([grammar](../../../../packages/util/workspace-path/README.md)). Any other scheme — `sidebar://guide` — is a navigation address: it names a tab, not data, and the model answers `none` for it ([tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md)).
|
||||
|
||||
### The service
|
||||
|
||||
```ts ignore-check
|
||||
interface Resources {
|
||||
register<P extends ResourceProtocol>(provider: ResourceProvider<P>): () => void
|
||||
pin(address: string, signal: AbortSignal): void
|
||||
source(address: string): ObservableSnapshot<ResourceSnapshot<unknown>>
|
||||
}
|
||||
|
||||
interface ResourceProvider<P extends ResourceProtocol> {
|
||||
readonly protocol: P
|
||||
open(address: string, ctx: { readonly signal: AbortSignal }): AsyncIterable<RemoteResult<ResourceProtocolMap[P]>>
|
||||
}
|
||||
|
||||
interface ResourceSnapshot<Value> {
|
||||
readonly status: 'none' | 'loading' | 'live' | 'failed'
|
||||
readonly value: Value | undefined
|
||||
readonly failure: RemoteFailure | undefined
|
||||
}
|
||||
|
||||
type UseResource = <P extends ResourceProtocol>(address: string) => ResourceSnapshot<ResourceProtocolMap[P]>
|
||||
```
|
||||
|
||||
`register` owns exactly one provider per protocol: a second registration for the same protocol throws, and the registration is an effect on the registering plugin's fiber, so a protocol leaves with its plugin and may be registered again afterwards. `pin` holds a resource open without subscribing until the signal aborts; an already-aborted signal pins nothing. `source` is the bare observable behind the hook, reference-stable per address, for callers outside React. The value type is looked up in `ResourceProtocolMap`, declared as an empty interface in `ui-slots` beside `SlotMap` — a module augmentation cannot introduce an export the target module lacks, and every consumer already depends on `ui-slots` — and each protocol's owner declaration-merges its member (`file: WorkspaceFileStat`); the resources package re-exports the type.
|
||||
|
||||
### The hook
|
||||
|
||||
`useResource` is declared on `GlobalStandardProps` in `ui-slots`, so every slot component has it whatever its scope, and the plugin provides it through `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })`, the same root keyed-hook path `useSessions` uses. It is not a session standard prop: a resource carries its own scope in its address, and components outside any session scope read resources too. `useResource<P>(address)` returns the snapshot: `none` when the address's protocol has no provider or the address is not a resource address, `loading` between the stream opening and its first frame, `live` with the latest `ok` value, `failed` with the latest frame's failure beside the last value.
|
||||
|
||||
### Frames
|
||||
|
||||
A provider yields `RemoteResult` frames: the current state first, one frame per later change. An `ok` frame makes the resource `live`, replaces the value, and clears the failure; an `ok: false` frame makes it `failed`, records the failure, and keeps the last value. Failure is data, not an exception: the Remote face already folds failures into `ok: false` and never rejects, providers pass those frames on, and the model neither catches nor wraps — a throw inside a provider's stream is a programming error left to surface. A stream that ends on its own keeps its last state; frames a provider yields after the release that aborted it are dropped and the iterator is returned. Streams carry metadata, not payload: the `file` value is `{ absolutePath, version, bytes? }`, and a consumer reads content itself through the [Workspace Files service](2026-09-05-workspace-files-service.md).
|
||||
|
||||
### Lifecycle
|
||||
|
||||
One record exists per address. Holds start and stop observation, not the underlying file or Session. Its holders are the hook's subscribers plus pins; the first holder opens the provider's stream under an `AbortController`, later holders share it and read the latest value at once, and the last release aborts the stream and resets the snapshot to idle — `loading` while a provider is registered, `none` otherwise. A provider that arrives while an address is already held opens that address's stream; one that leaves aborts it and the address reads `none`. Records are kept for the page lifetime so `source(address)` stays reference-stable across React's render-then-subscribe window and a StrictMode remount, where a recreated record would resubscribe and restart the stream on every render.
|
||||
|
||||
The right Sidebar's Tab domain pins every open tab record's address for the record's life, so switching tabs unmounts a body without closing its stream and switching back reads the latest value; a record restored by undo is a new pin, and a resource the model already let go is read again ([tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md)). `openResource(address)` accepts resource addresses only; pages such as the guide and the file tree are opened by kind and never enter the resource model.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Session-bound resources: `useResource` on the session kit and a `(session, address)` identity.** The first form. Rejected because a file is not a session concern — the session is only who authorizes the path — and because the model must serve protocols and components outside any session scope. Identity became the address alone, the scope moved into the address grammar, and the hook moved to the global kit.
|
||||
|
||||
**Content in the resource stream.** Rejected: content can be arbitrarily large, and a stream is for pushing change, not payload. The stream carries metadata and the consumer chooses how much content to read and retain.
|
||||
|
||||
**Failure as a thrown error, wrapping a non-`RemoteFailure` throw as `gateway/internal`.** Rejected: the Remote face never rejects, so anything a provider throws is a bug, and wrapping it would be a fallback that hides the bug from the developer who caused it. A failure is an `ok: false` frame; a throw surfaces.
|
||||
|
||||
**`file:/<scope>/<id>/<path>`, then `file://<scope>/<id>/<path>` with the scope in the authority.** Two earlier grammars. The single-slash form was not a URL the platform parser accepted, so every consumer hand-parsed it. Moving the scope into the authority made it a URL but gave each resource protocol its own scheme — `file://`, later `chat://`, `terminal://` — so the set of schemes grew with the set of protocols, a `file://` address no longer meant what it means everywhere else, and telling a resource address from a navigation address needed a list. The single `dsh-resource://<type>/…` scheme makes that test one comparison, leaves the host free to name the protocol, and keeps every other scheme available to navigation.
|
||||
|
||||
**A hand-parsed scheme prefix instead of the URL parser.** The first `protocolOf` matched a regular expression for the scheme. Rejected once addresses were URLs: the parser already decides validity and case, and a string it rejects should read as "no protocol" rather than be half-parsed.
|
||||
|
||||
**A per-tab stream hook, or a framework-managed `useTabResource(fetch)`.** Rejected in turn: a stream hook on the tab domain asks the wrong owner — `file` data must come from the workspace file service, chat data from the chat domain — and a framework-owned fetch has no good cache key. What remains is the framework-bound `useTabInfo` on the tab plus one client-wide `useResource` keyed by address.
|
||||
|
||||
## Consequences
|
||||
|
||||
Any slot component reads live data by address and nothing else, so an opener passes data only and a body reconstructs itself from its record after undo, body remount, or hot replacement. Two components showing one address share one stream, and a pinned address survives its body's unmount. A protocol's transport lives in exactly one provider, and adding a protocol is one declaration-merged type plus one registration.
|
||||
|
||||
The costs are recorded here so they are not rediscovered. Records are never reclaimed: memory grows with the number of distinct addresses ever read, not with reads. Abort compliance rests with the provider; the model drops what a released stream still yields but cannot stop a provider that ignores the signal before its next frame. The failure type is the Remote face's `RemoteFailure`, so a provider whose source is not a Remote call has to mint one. A navigation address or a malformed string reads as `none` rather than an error, which keeps mixed address lists cheap to render but gives a misspelled protocol no diagnostic beyond the missing value.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/resources/tests/resources.client.spec.ts` drives the registry with scripted feeds: protocol ownership and disposal, `none` for a protocol without a provider and for a navigation address, a provider arriving after a held address and leaving while it is held, registrations dropped with their fiber, open-on-first-holder and close-on-last, one source per address, pins including an already-aborted signal, a remount reading the latest value without reopening, reopening as a fresh stream, frames after abort dropped with the iterator returned, a stream ending on its own, and failure frames beside the last value. `tests/apply.client.spec.ts` mounts the plugin in `SlotTestRuntime` and checks, through a root-scope probe component, that `useResource` reaches props, that rendering it opens the provider's stream, and that disposing the plugin withdraws both the service and the hook.
|
||||
|
||||
## Deferred
|
||||
|
||||
Reclaiming idle records, a resource-owned failure type decoupled from the Remote face, and the `chat` and `terminal` protocols are open; each waits for a consumer. The developer-facing reference is [docs/subsystems/client-resources.md](../../../../docs/subsystems/client-resources.md); the Sidebar that consumes the model is described in [docs/subsystems/sidebar-right.md](../../../../docs/subsystems/sidebar-right.md).
|
||||
@@ -0,0 +1,86 @@
|
||||
# Agent Note: 客户端资源模型
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-05-client-resource-model.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
右侧 Sidebar 的 tab 正文、聊天卡片或任何别的 slot 组件,常常需要只以地址可知的活数据:agent 刚写的文件,将来的聊天节点或终端。资源模型出现前每个消费方各自取数——文本预览自己持有 Remote 调用与刷新循环——于是每次挂载都重读、两个组件显示同一文件就持有两份、切 tab 卸载正文就丢内容,每种新内容都意味着一个新的专用 hook。
|
||||
|
||||
约束来自 tab 记录。tab 必须在打开它的代码不在场时挺过撤销、重做、正文重挂载与热替换,所以记录只能存可序列化的数据:一个地址与导航参数。浏览器页面刷新会重置只在内存中的 Sidebar 状态。因此开启方不能把数据交给正文,注入也不是合适的工具——注入是领域与席位之间注册期的关系,而打开是运行期事件。组件必须只凭地址找到数据,途径是由数据拥有者注册一次的东西。
|
||||
|
||||
## Decision
|
||||
|
||||
[`packages/client/resources`](../../../../packages/client/resources/README.zh.md)(`@deepseek-ai/dsh-client-resources`)提供 `ctx.resources` 与 `useResource` 全局标准 hook。消费方活读的任何东西都是**资源**,资源只由其**地址**标识,地址的协议命名唯一一个把它变成帧流的**提供方**。
|
||||
|
||||
### 地址
|
||||
|
||||
资源地址是 `dsh-resource://<type>/…` 形式的 URL。host 是协议键——`ResourceProtocolMap` 的键——路径归协议拥有者。`RESOURCE_SCHEME = 'dsh-resource'` 是唯一的 scheme 常量;`protocolOf(address)` 用 `new URL` 解析字串,要求 `protocol === 'dsh-resource:'`,返回小写 host;解析器拒绝的字串、其它 scheme 或空 host 返回 `undefined`。`dsh-resource` 不是 URL 规范里的特殊 scheme,解析器会保留 host 的大小写并把路径当作不透明串,所以小写化是显式做的,每段路径由定义它的协议做百分号编码。需要作用域的协议把作用域编进路径:`dsh-resource://file/session/<sessionId>/<path>`,`session/<sessionId>` 命名由其 Host 工作区解析相对或绝对路径的 Session([语法](../../../../packages/util/workspace-path/README.zh.md))。其它任何 scheme——`sidebar://guide`——是导航地址:它命名一个 tab 而非数据,模型对它回答 `none`([tab 类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md))。
|
||||
|
||||
### 服务
|
||||
|
||||
```ts ignore-check
|
||||
interface Resources {
|
||||
register<P extends ResourceProtocol>(provider: ResourceProvider<P>): () => void
|
||||
pin(address: string, signal: AbortSignal): void
|
||||
source(address: string): ObservableSnapshot<ResourceSnapshot<unknown>>
|
||||
}
|
||||
|
||||
interface ResourceProvider<P extends ResourceProtocol> {
|
||||
readonly protocol: P
|
||||
open(address: string, ctx: { readonly signal: AbortSignal }): AsyncIterable<RemoteResult<ResourceProtocolMap[P]>>
|
||||
}
|
||||
|
||||
interface ResourceSnapshot<Value> {
|
||||
readonly status: 'none' | 'loading' | 'live' | 'failed'
|
||||
readonly value: Value | undefined
|
||||
readonly failure: RemoteFailure | undefined
|
||||
}
|
||||
|
||||
type UseResource = <P extends ResourceProtocol>(address: string) => ResourceSnapshot<ResourceProtocolMap[P]>
|
||||
```
|
||||
|
||||
`register` 让每个协议恰有一个提供方:同一协议的第二次注册抛错,注册是挂在注册方插件 fiber 上的 effect,所以协议随插件离开、之后可再注册。`pin` 在不订阅的情况下让资源保持打开直到信号中止;已中止的信号什么也不钉。`source` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用。值类型在 `ResourceProtocolMap` 里查得,它作为空接口声明在 `ui-slots` 里、与 `SlotMap` 并列——模块增强无法给目标模块添加它没有的导出,而每个消费方本来就依赖 `ui-slots`——各协议拥有者声明合并自己的成员(`file: WorkspaceFileStat`);resources 包再导出这个类型。
|
||||
|
||||
### hook
|
||||
|
||||
`useResource` 声明在 `ui-slots` 的 `GlobalStandardProps` 上,因此每个 slot 组件不论作用域都有它,插件经 `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })` 提供,与 `useSessions` 走同一条根 keyed hook 路径。它不是会话标准 prop:资源的作用域随地址携带,会话作用域之外的组件也要读资源。`useResource<P>(address)` 返回快照:地址协议没有提供方或地址不是资源地址时为 `none`,流已打开、首帧未到时为 `loading`,`live` 携带最新 `ok` 值,`failed` 在最后一个值旁携带最新帧的失败。
|
||||
|
||||
### 帧
|
||||
|
||||
提供方产出 `RemoteResult` 帧:首帧是当前状态,之后每次变化一帧。`ok` 帧使资源 `live`、替换值、清除失败;`ok: false` 帧使其 `failed`、记下失败、保留最后一个值。失败是数据不是异常:Remote 面本来就把失败折进 `ok: false` 且从不 reject,提供方原样转发这些帧,模型既不捕获也不包装——提供方流里抛出是编程错误,任其冒出。自行结束的流保持最后状态;提供方在中止它的那次释放之后产出的帧被丢弃,迭代器被归还。流只推元数据不推载荷:`file` 的值是 `{ absolutePath, version, bytes? }`,消费方自己经 [Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)读内容。
|
||||
|
||||
### 生命周期
|
||||
|
||||
每个地址一条记录。持有只控制观察的启停,不控制底层文件或 Session 的生灭。持有者是 hook 的订阅者加 pin;第一个持有者在 `AbortController` 下打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并把快照重置为空闲——有提供方注册时为 `loading`,否则为 `none`。地址已被持有时到达的提供方会打开该地址的流;离开的提供方中止它,地址读作 `none`。记录在页面存续期内保留,使 `source(address)` 在 React 渲染到订阅的窗口与 StrictMode 重挂载之间保持引用稳定,否则重建记录会让每次渲染重订阅、重开流。
|
||||
|
||||
右侧 Sidebar 的 Tab 域在每条打开的 tab 记录存续期内钉住其地址,所以切 tab 卸载正文不关流、切回读到最新值;撤销恢复的记录是一次新的钉住,模型已放掉的资源会重新读取([tab 类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md))。`openResource(address)` 只收资源地址;引导页与文件树这类页面按 kind 打开,从不进入资源模型。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**会话绑定的资源:`useResource` 挂会话标准件、身份为 `(session, address)`。** 第一版形态。被否,因为文件不是会话的事——会话只是路径的授权者——而且模型必须服务会话作用域之外的协议与组件。身份改为只有地址,作用域进入地址语法,hook 移到全局标准件。
|
||||
|
||||
**内容进资源流。** 被否:内容可能任意大,流是用来推变化的,不是推载荷。流只带元数据,消费方选择读取并保留多少内容。
|
||||
|
||||
**以抛错表达失败,并把非 `RemoteFailure` 的抛出包装成 `gateway/internal`。** 被否:Remote 面从不 reject,所以提供方抛出的任何东西都是 bug,包装它就是把 bug 藏起来不让肇事者看见的 fallback。失败是 `ok: false` 帧;抛出就冒出来。
|
||||
|
||||
**`file:/<scope>/<id>/<path>`,再到把作用域放在 authority 位的 `file://<scope>/<id>/<path>`。** 两版更早的语法。单斜杠形态不是平台解析器接受的 URL,每个消费方都得手工解析。把作用域移到 authority 位使它成为 URL,却让每个资源协议各占一个 scheme——`file://`、将来的 `chat://`、`terminal://`——scheme 的集合随协议集合增长,`file://` 地址不再是它在别处的含义,区分资源地址与导航地址需要一张清单。单一 scheme `dsh-resource://<type>/…` 让这个判断只需一次比较,host 留给协议命名,其它所有 scheme 留给导航。
|
||||
|
||||
**手写 scheme 前缀解析代替 URL 解析器。** 第一版 `protocolOf` 用正则匹配 scheme。地址成为 URL 后被否:解析器已经决定合法性与大小写,它拒绝的字串应读作「无协议」而不是被解析一半。
|
||||
|
||||
**每 tab 一个流 hook,或框架代管的 `useTabResource(fetch)`。** 依次被否:挂在 tab 域上的流 hook 问错了拥有者——`file` 数据必须来自工作区文件服务,聊天数据来自聊天域——而框架代管的 fetch 没有好的缓存键。留下的是 tab 上框架绑定的 `useTabInfo` 加一个按地址的客户端级 `useResource`。
|
||||
|
||||
## Consequences
|
||||
|
||||
任何 slot 组件只凭地址读活数据,于是开启方只传数据,正文在撤销、正文重挂载或热替换后能从记录重建自己。显示同一地址的两个组件共享一条流,被钉住的地址在正文卸载后仍存活。一个协议的传输只住在一个提供方里,新增协议只是一个声明合并的类型加一次注册。
|
||||
|
||||
代价记录在此以免被重新发现。记录不回收:内存随读过的不同地址数增长,而非随读取次数增长。中止合规归提供方;模型会丢弃已释放的流仍产出的帧,却阻止不了忽略信号的提供方跑到下一帧。失败类型是 Remote 面的 `RemoteFailure`,来源不是 Remote 调用的提供方得自己铸一个。导航地址或畸形字串读作 `none` 而非报错,这让混合地址列表渲染起来便宜,却让拼错的协议除了缺值之外没有任何诊断。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/resources/tests/resources.client.spec.ts` 用脚本化的 feed 驱动注册表:协议归属与注销、无提供方的协议与导航地址都为 `none`、提供方在地址已被持有后到达与在持有中离开、注册随 fiber 消失、首个持有者开流末个关流、一址一源、包括已中止信号在内的 pin、重挂读到最新值且不重开、重开为新流、中止后帧丢弃且迭代器归还、流自行结束、失败帧与最后值并存。`tests/apply.client.spec.ts` 在 `SlotTestRuntime` 里挂载插件,经一个根作用域探针组件验证 `useResource` 到达 props、渲染它即打开提供方的流、dispose 插件同时撤走服务与 hook。
|
||||
|
||||
## Deferred
|
||||
|
||||
回收空闲记录、与 Remote 面解耦的资源自有失败类型、`chat` 与 `terminal` 协议都还开放;各自等待一个消费方。面向开发者的参考是 [docs/subsystems/client-resources.md](../../../../docs/subsystems/client-resources.zh.md);消费这个模型的 Sidebar 见 [docs/subsystems/sidebar-right.md](../../../../docs/subsystems/sidebar-right.zh.md)。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md
|
||||
2026-09-05-read-only-session-migration-preparation.md: ab23e7e61dcdf0762cae6185de5fd16c4070fcbf
|
||||
2026-09-05-read-only-session-migration-preparation.zh.md: 89377d4d84776bebbc6d2ca6acea92ac15f74df3
|
||||
2026-09-05-read-only-session-migration-preparation.md: 081044e34d5071ba242792c588f43a3b83adddd5
|
||||
2026-09-05-read-only-session-migration-preparation.zh.md: 7406e7cf31112df6c9f6bb0d454f38d0cf7eb6a4
|
||||
+1
-1
@@ -65,7 +65,7 @@ Completed results enter the existing bounded `coldLogMemo`. The `StoredLog` disc
|
||||
|
||||
`SessionHandle.read()` reports whether its event values are detached or shared-frozen. The JSONL backend deep-freezes each decoded event graph once before memoization and creates the `shared-frozen` result there; later reads and slices preserve that producer-established state even when the slice is empty. `readColdSessionLog()` combines those values with locally owned interrupted-turn closers and passes the `eventState` through `SessionObservationReader`; `Session.fromRestore()` validates and adopts the seed without copying or freezing. Ordinary create and fork seeds keep their defensive snapshot path.
|
||||
|
||||
Read-only restoration validates the event and settlement fields required by Session runtime behavior but does not expand every embedded Assistant stream. The publication Worker retains complete stream replay and checks content, usage, and replay-state agreement before a migrated successor is committed. Existing current-v2 files rely on their writer; consumers that expand a compact stream validate its records when they read it.
|
||||
Read-only restoration validates the event and settlement fields required by Session runtime behavior but does not expand every embedded Assistant stream. The publication Worker retains complete stream replay and checks content, usage, and replay-state agreement before a migrated successor is committed. Existing current-format files rely on their writer; consumers that expand a compact stream validate its records when they read it.
|
||||
|
||||
### Read handle transition
|
||||
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ interface MigrationPreparation {
|
||||
|
||||
`SessionHandle.read()` 会报告 event value 是 detached 还是 shared-frozen。JSONL backend 在 memo 化前只对每个已解码 event graph 深度冻结一次,并在该处构造 `shared-frozen` 结果;后续读取和 slice 即使为空也会保留生产者建立的状态。`readColdSessionLog()` 将这些 event 与本地独占的 interrupted-turn closer 组合,并通过 `SessionObservationReader` 继续传递 `eventState`;`Session.fromRestore()` 只校验和接管 seed,不再复制或冻结。普通 create 与 fork seed 继续使用 defensive snapshot 路径。
|
||||
|
||||
Read-only restoration 会校验 Session runtime 直接依赖的 event 与 settlement 字段,但不会展开每一段嵌入式 Assistant stream。Publication Worker 继续执行完整 stream replay,并在提交 migrated successor 前校验 content、usage 与 replay state 一致性。已有 current-v2 文件信任其 writer;需要展开 compact stream 的 consumer 会在读取时校验 record。
|
||||
Read-only restoration 会校验 Session runtime 直接依赖的 event 与 settlement 字段,但不会展开每一段嵌入式 Assistant stream。Publication Worker 继续执行完整 stream replay,并在提交 migrated successor 前校验 content、usage 与 replay state 一致性。已有当前格式文件信任其 writer;需要展开 compact stream 的 consumer 会在读取时校验 record。
|
||||
|
||||
### Read handle 切换
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md
|
||||
2026-09-05-sidebar-tab-types-and-navigation.md: a79292eba25828287ce43ad16b7eb917dcdddcb4
|
||||
2026-09-05-sidebar-tab-types-and-navigation.zh.md: bf661948036257366714617b192525ac35b0905f
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
# Agent Note: Right Sidebar tab types and navigation
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-05-sidebar-tab-types-and-navigation.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [docking surface](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) gives the right Sidebar panes, tabs, and floating panels, but a pane full of tabs is only useful if other plugins can put content into them. That needs three contracts the surface itself does not define: how a plugin declares a kind of tab and the addresses it can show, how any caller — a produced-file chip in the conversation, a row in a file tree, a plugin's own button — asks the Sidebar to show something, and what a tab's body may rely on at runtime. Each contract is a public face that plugins shipped from outside this repository will write against, so each has to be settled before those plugins exist: a renamed field, a changed enum value, or a different address grammar afterwards breaks every one of them.
|
||||
|
||||
Two constraints shaped the answers. Dynamic client plugins may not import runtime values from one another — a function, a constant, a class — only types, so nothing in these contracts may require a helper function or an exported constant from the Sidebar package. And the Web client already has one component model, the Slot system; a second one for tabs would be a parallel framework to learn and maintain.
|
||||
|
||||
## Decision
|
||||
|
||||
A tab type is a static registration into `ctx.sidebarRightTabs`; a tab's body and title are ordinary keyed Slot registrations; `ctx.sidebarRight` opens content in exactly two ways — a resource by address, or a page by kind — and otherwise only operates the layout; and bodies read occurrence information through the framework-injected `useTabInfo()`. The four faces are described below in the order a plugin author meets them.
|
||||
|
||||
### The type registry: `ctx.sidebarRightTabs`
|
||||
|
||||
`register(definition): () => void` records one tab type and returns the disposer the caller holds in its own `ctx.effect`, so a type lives exactly as long as the plugin that contributed it. The definition is static:
|
||||
|
||||
```ts ignore-check
|
||||
interface SidebarRightTabDefinition {
|
||||
readonly id: string // this implementation's identity in the tab system
|
||||
readonly kind: string // what the tabs of this type are; what openTab names
|
||||
readonly patterns?: readonly string[] // resource-address globs; omitted by a page type
|
||||
readonly priority?: 'extension' | 'builtin' | 'fallback' // defaults to extension
|
||||
readonly canOpen?: (address: string) => boolean // veto after a glob matched
|
||||
readonly title: (address: string) => string // chip text, captured at open time
|
||||
readonly guide?: readonly SidebarRightGuideEntry[] // entry boxes on the guide page
|
||||
}
|
||||
```
|
||||
|
||||
`id` and `kind` are different things. `kind` is the type discriminator — what a tab *is*, what `openTab` names, what tab identity is built from. `id` is the identity of one *implementation* of a kind, unique across every registration; a package name is the natural value. The two are separate because a kind is not unique: an `extension` may register the kind a `builtin` already holds, and the two implementations then coexist in the registry with the extension in force. The registry rejects a second registration of an `id`, a second registration in the same band of a kind, and any registration meeting a `fallback` of the same kind; it accepts exactly the extension-over-builtin pair, and the builtin resumes when the extension unregisters.
|
||||
|
||||
`patterns` are globs over resource addresses, matched with `picomatch` under VS Code's editor-resolver rule with one local change: a pattern containing `:` is matched against the whole address (`dsh-resource://file/**`), one without is matched against the URI's path at any depth (`*.md`), matching ignores case and does not hide dotfiles, and an address that is not a URI matches no path pattern. A page type — the guide, the file tree — recognizes no address and omits `patterns`; it is opened by kind.
|
||||
|
||||
`priority` is one of three literal bands, spelled as strings so that a type from another package needs no runtime import: `extension` is the band of a type from outside the product and the highest, so a type that declares nothing outranks every viewer shipped here; `builtin` is the ordinary band for shipped types; `fallback` is the plain-content position that anything more specific should beat, which VS Code's text editor holds implicitly and our text preview holds explicitly. `candidates(address)` returns every type whose globs match and whose `canOpen` does not veto, ranked by band, then by the length of the longest pattern that matched, then by registration order. `claim(address, kind?)` takes the best candidate, or the named kind's type in force when the caller overrides (its globs are not consulted; naming the type is the decision), and throws for an address nothing will open — a wiring mistake, not a user error. `get(kind)` returns the type in force; `entries()` and `guide()` list the types and their guide boxes in force; `subscribe` observes changes.
|
||||
|
||||
`title(address)` and `guide[].title()` are thunks read on every use, so a language change needs no re-registration. The registry itself is a plain object provided at `apply`'s top level **without** `Service.tracker`: a tracker would rebind `this.ctx` to the caller's context, and a cross-package `register()` would then add its effect to the caller's fiber while that fiber is the active scope, stalling the browser boot with no error.
|
||||
|
||||
### Bodies and titles: keyed Slot seats under the definition's `id`
|
||||
|
||||
The type registry says what a type is; the Slot system says what it looks like. A type registers its body into the keyed, session-scoped seat `sidebar.right.pane.tab` under its own `id`, and may register a title component into `sidebar.right.pane.tab.title` under the same key. The seat that draws a tab resolves the tab's `kind` to the type in force through the registry and dispatches to that type's `id`, so an extension taking over a builtin's kind is rendered without either package knowing about the other, and without any priority number crossing a package boundary. A kind with no type in force renders the owner's "nothing can view this" notice; a type with no title registration gets the `title(address)` text the registry captured when the tab opened.
|
||||
|
||||
Two further seats extend the guide and the menu: `sidebar.right.tab.guide` is a chain whose first non-declining entry replaces the shipped guide body without replacing the tab, and `sidebar.right.tab.menu.item` is a list appended after the kit's own layout actions, for actions that mean something about a tab's content. A type's controls — a reload, a wrap toggle — live inside its own body; the strip belongs to the panel and carries only the panel's controls. A type's own state is an ordinary Slot store and inject face on the body registration; the framework adds nothing to the component model.
|
||||
|
||||
### Tab occurrence information
|
||||
|
||||
[Responsive Sidebar and tab information](2026-09-07-sidebar-responsive-tab-info.md) supersedes this note's choice of flat owner props for occurrence information. Bodies, titles and guide replacements receive the framework-injected `useTabInfo()` to read `{ sidebar, panel, tab }`. The record, navigation, visibility, signal and bound actions live inside `tab`; exact fields belong to the [Sidebar reference](../../../../docs/subsystems/sidebar-right.md).
|
||||
|
||||
The Tab domain still owns one occurrence per committed record, with an `AbortController`, navigation snapshot and actions bound to its Session. It pins the address in the [resource model](2026-09-05-client-resource-model.md) for the record's lifetime; hiding and switching Sessions do not end it, while closing the record aborts and releases it. Existing framework store and navigation hooks provide live reads, without subscriptions in tab implementations.
|
||||
|
||||
### Navigation: `ctx.sidebarRight`
|
||||
|
||||
The face opens content in two ways and does nothing else with content:
|
||||
|
||||
```ts ignore-check
|
||||
openResource(address: string, options?: { kind?: string; params?: SidebarRightResourceParams; paneId?; replaceTab?: TabId; revealIfOpened?: boolean }): void
|
||||
openTab<K extends string>(kind: K, options?: { params?: SidebarRightTabParamsFor<K>; paneId?; replaceTab?: TabId; revealIfOpened?: boolean }): void
|
||||
```
|
||||
|
||||
`openResource` takes a resource address — a `dsh-resource://<type>/…` URI, the only scheme the resource model has — and asks the registry who shows it: without `kind`, every type is consulted and the ranking decides; with `kind`, that type's implementation in force opens it. An address with any other scheme fails on the same path as an address nothing claims. `openTab` opens a page type by kind and never sees an address: the Sidebar records the tab under `sidebar://<kind>`, composed in one place inside the package, so that a page tab has a `contentId` for identity and history like any other tab. The scheme is bookkeeping: no caller composes it, no business package contains the literal, and the file tree and the guide are opened as `openTab('files')` and `openTab('guide')`.
|
||||
|
||||
Both opens run the same four steps: resolve the type (by ranking or by kind), locate an existing tab by `(kind, contentId)` unless `revealIfOpened` is `false`, place the tab — in `replaceTab`'s pane and strip slot, in `paneId`, or in the active pane — and record the expansion, the open-or-focus, and the `replaceTab` close as one history entry before handing `{ address, params }` to the tab domain. Placement is the caller's business, never a type-level trait: the file tree opens into its own pane because it says so, as VS Code's Explorer passes `SIDE_GROUP` or `ACTIVE_GROUP` itself. `replaceTab` means one thing — open in that tab's place and close it in the same step — and exists for the guide's entry boxes, which hand their tab over to the page they name.
|
||||
|
||||
Parameters are typed by what is being opened, through two merge-extensible maps declared in the Sidebar package and augmented by the owners of the keys:
|
||||
|
||||
```ts
|
||||
interface SidebarRightResourceParamsMap {} // key: resource type — the text preview declares { line?: number }
|
||||
interface SidebarRightTabParamsMap {} // key: kind — a page type declares its own shape, or nothing
|
||||
```
|
||||
|
||||
`openResource` accepts the union of every declared resource shape and `openTab<K>` the shape declared for `K`; a body narrows `navigation.params` by the protocol or kind it knows it serves. Parameters belong to the resource type rather than to the viewer because a line number is a fact about a file location, not about the text preview, and any type that claims `file` addresses receives the same shape. Values must be JSON-serializable, and a record must be rebuildable from address and parameters alone, because undo, redo, reload, and HMR rebuild tabs after the opener is gone.
|
||||
|
||||
Beside the two opens, the face carries `close(tabId)`, `active()`, `isExpanded()`, `toggleExpanded()`, and four operational methods — `focus(tabId)`, `split(paneId?)` (returning the new pane, or `undefined` when the pane budget or the width rule forbids the split, recording nothing), `float(tabId, rect?)`, and `dock(paneId)` — each recording one history entry and a no-op on a missing target or one already in the requested state. There is no layout snapshot, no subscription, and no lookup by address: the face grants control over the layout, not a view of it. The seat publishes its binding — its session, its store's actions, and its surface — while mounted; a command on the public face acts on the mounted session and throws with no mounted session surface. A tab's own actions reach their session's own store instead: the slot runtime mints one store per session, the plugin adopts each as it is minted, and the controller routes by session id, so an action fired after the user switched sessions still lands, and does nothing for a session whose store was never minted.
|
||||
|
||||
### Addresses
|
||||
|
||||
Addresses come in two families that never mix. Resource addresses are the resource model's `dsh-resource://<type>/…` URIs (a workspace file is `dsh-resource://file/session/<sessionId>/<path relative to that session's workspace root>`, an arbitrary file `dsh-resource://file/absolute/<absolute path>`, both built and parsed by `dsh-util-workspace-path`); they are what `openResource` takes, what `patterns` match, and what `useResource` reads. Navigation addresses name pages rather than data; today the only one is the internal `sidebar://<kind>` a page tab is recorded under. Only the resource family is a contract: the navigation family is composed and consumed inside the Sidebar, and a fuller navigation protocol is a later decision that this one leaves room for by keeping every navigation literal in one place.
|
||||
|
||||
### Entry points
|
||||
|
||||
The conversation's `openFile(path, { line? })` — tool-row path links, produced-file chips, closing-message mentions — encodes the path as a file resource address for the Session, and calls `openResource` with `params.line` when the caller knows one; the `read` tool row passes the line its `offset` argument started from. The strip's `+` calls `openTab('guide', { paneId, revealIfOpened: false })` for the pane it sits in; a guide entry box calls `tab.actions.openTab(entry.kind, { replaceTab: true })`; a file-tree row calls `tab.actions.openResource(address)`, which lands in the tree's own pane.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A chain slot for tab dispatch, or a keyed slot alone.** A chain's `select` is not enumerable, and the guide page and the navigation face must enumerate types; a keyed slot carries a body and nothing else, so a type's title and address recognition had nowhere to live. Two stages — a definition registry plus keyed component seats — is the repository's existing pattern (`ConversationViewRegistry`).
|
||||
|
||||
**Runtime hooks or an instance object per tab.** Several forms were tried on paper — a Cordis fiber per tab, an abstract base class, an `initial`/`create` pair returning an instance with `dispose`, a set of `useTab*` hooks, a framework-managed `useTabResource(fetch)`, a `useTabStream`. Rejected in turn: a fiber per tab is far too heavy; dynamic packages cannot share a base class or an exported constant; an instance layer duplicates what a Slot store and inject face already are; per-tab hooks restate owner props; a framework-owned fetch has no good cache key; and a stream hook on the tab domain asks the wrong owner — chat data must come from the chat domain, file data from the workspace file service. What remains is owner props plus one client-wide `useResource`. `visible` was later added as a prop rather than a hook for the same reason: it is one more fact about the occurrence, and the props already carry the occurrence. The rejection of occurrence-reading hooks is superseded by the [tab information decision](2026-09-07-sidebar-responsive-tab-info.md); the independent instance, fiber and data-stream ownership rationale still applies.
|
||||
|
||||
**A per-pane tools seat for the active tab's controls (`sidebar.right.pane.tab.tools`).** Shipped for one review round, then removed: it put type-private buttons on the panel's strip beside the split and collapse controls, where they read as panel chrome. A type's controls belong in its own body.
|
||||
|
||||
**Type-level placement (`opensInto`) and a hidden sibling heuristic.** Rejected: where a tab lands is the opener's business, exactly as VS Code's Explorer decides `sideBySide` itself.
|
||||
|
||||
**Extension lists and numeric priorities.** `claims.extensions` cannot express `.d.ts`, `Dockerfile`, a directory constraint, or a whole scheme — it is a degenerate glob; numeric priorities need an exported constant that dynamic packages cannot import. Literal bands over globs. VS Code's own bands were reduced from five to three: an `option` band (listed, never chosen automatically) has no consumer until an "open with…" affordance exists, and a `default` band was renamed `extension` because the name read as the lowest tier while it is the highest.
|
||||
|
||||
**One `open(address)` for everything, with a helper that builds page addresses.** The first design opened pages by address too, so a business package needed a `sidebar://<kind>` literal or a `sidebarAddress(kind)` helper from the Sidebar package. Both are forbidden by the value-import rule and both leak a navigation scheme that is not yet designed. Splitting the face into `openResource` and `openTab` puts the only literal inside the package and lets each mode type its parameters.
|
||||
|
||||
**Naming a specific implementation when opening (`?impl=`), `find(address)`, `mode()`/`setMode()`, a layout snapshot, a `features` list.** All considered and left out. Naming an implementation belongs to a navigation protocol that does not exist yet; `find` and a snapshot would make the face a view of the layout when it is meant to be control over it; presentation mode is a UI toggle, not a plugin concern; a capability list is premature while the face is settling.
|
||||
|
||||
**Slot priorities to express an extension taking over a builtin, then registry-minted slot keys.** The first attempt had the overriding type register its body at a lower slot priority through an exported constant — a value import across dynamic plugins, and a second rule system (slot priority) standing in for the registry's. The second attempt had the registry mint a key per registration and return it from `register()`, which made registration a two-step dance whose ordering mattered. Letting the implementation declare its own `id` — required, unique, the same string it registers its seats under — needs no constant, no minting, and no ordering, and gives the registry the identity it needs to reject duplicates.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A type is one static object plus one or two keyed seat registrations; its occurrence information is read through injected `useTabInfo()`. The framework grows no per-type API surface, and a type shipped from outside this repository imports only types from the Sidebar package.
|
||||
- Two opens with two parameter maps mean a caller cannot open a page by address or a resource by kind alone, and the compiler tells it so; the cost is that every new resource type or page kind that wants typed parameters augments a map.
|
||||
- `id` and `kind` being distinct lets an extension replace a shipped type in place, per kind, with the builtin resuming when the extension unregisters; the cost is one more required field on every definition.
|
||||
- The navigation face is control-only. A plugin that needs to know the layout cannot ask for it, which keeps the layout's shape out of every plugin's contract until a navigation protocol decides what to expose.
|
||||
- The `sidebar://<kind>` literal lives in one file. Changing the navigation grammar later touches the Sidebar package and nothing else.
|
||||
- These faces are the part of the Sidebar that is fixed: addresses, registration fields and bands, the two opens and their parameter maps, seat names and injected tab information. Everything a user sees as behaviour — where a float snaps, when a split control greys out, the copy, the tree's ordering — is a product rule outside every contract here and changes without notice to any plugin.
|
||||
|
||||
## Testing
|
||||
|
||||
`ui-sidebar-right` specs cover the registry (bands, extension-over-builtin with resumption, `id` and same-band collisions, glob and path matching, `canOpen`, ranking and tiebreaks), both opens (normal, edge, and failure paths including the wrong scheme and an unregistered kind), `replaceTab` as one history entry, the seat resolving a kind to the implementation in force and back, `useTabInfo()` including `tab.visible` under collapse and floating, and the operational methods with their no-op and throw cases. The Web e2e suite drives the guide, the file tree, and a file open through the real plugin graph in Chromium. Both suites are keyless.
|
||||
|
||||
## Deferred
|
||||
|
||||
- A navigation protocol beyond `sidebar://<kind>`: sub-routes within a page, naming an implementation, and the ecosystem-facing rules for other navigation schemes.
|
||||
- Parameters for the shipped page types, which today declare none.
|
||||
- Opening into a session other than the one on screen from the public face, which acts on the mounted session only; a tab's own actions already act on their tab's session.
|
||||
- A localized message when an open fails from the conversation; the failure is currently the thrown error's text.
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
# Agent Note: 右侧 Sidebar 的 tab 类型与导航
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-05-sidebar-tab-types-and-navigation.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
[停靠面](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)给了右侧 Sidebar 分栏、tab 与浮动面板,但一格 tab 只有在别的插件能往里放内容时才有用。这需要三份停靠面自身不定义的契约:插件如何声明一种 tab 及其能展示的地址;任何调用方——会话区里的产出文件 chip、文件树里的一行、插件自己的按钮——如何请 Sidebar 展示某样东西;以及 tab 的正文在运行时能依赖什么。每一份都是仓外插件将来要对着写的公开面,所以必须在那些插件出现之前定下来:之后改一个字段名、一个枚举值或地址语法,就会同时弄坏它们全部。
|
||||
|
||||
两条约束决定了答案。动态客户端插件之间不允许引用运行时值——函数、常量、类——只能引类型,因此这些契约里不得要求从 Sidebar 包引入帮助函数或导出常量。而 Web 客户端已经有一套组件模型,即 Slot 系统;再为 tab 造一套,就是第二个要学要维护的并行框架。
|
||||
|
||||
## Decision
|
||||
|
||||
tab 类型是向 `ctx.sidebarRightTabs` 的一次静态注册;tab 的正文与标题是普通的 keyed Slot 注册;`ctx.sidebarRight` 只以两种方式打开内容——按地址开资源、按 kind 开页——其余只操作布局;正文通过框架注入的 `useTabInfo()` 读取实例信息。下面按插件作者遇到的顺序描述这四个面。
|
||||
|
||||
### 类型注册表:`ctx.sidebarRightTabs`
|
||||
|
||||
`register(definition): () => void` 记录一种 tab 类型并返回注销器,调用方把它放进自己的 `ctx.effect`,于是类型的寿命恰好等于贡献它的插件。定义是静态的:
|
||||
|
||||
```ts ignore-check
|
||||
interface SidebarRightTabDefinition {
|
||||
readonly id: string // this implementation's identity in the tab system
|
||||
readonly kind: string // what the tabs of this type are; what openTab names
|
||||
readonly patterns?: readonly string[] // resource-address globs; omitted by a page type
|
||||
readonly priority?: 'extension' | 'builtin' | 'fallback' // defaults to extension
|
||||
readonly canOpen?: (address: string) => boolean // veto after a glob matched
|
||||
readonly title: (address: string) => string // chip text, captured at open time
|
||||
readonly guide?: readonly SidebarRightGuideEntry[] // entry boxes on the guide page
|
||||
}
|
||||
```
|
||||
|
||||
`id` 与 `kind` 是两回事。`kind` 是类型判别符——tab *是什么*、`openTab` 点名什么、tab 身份由什么构成。`id` 是某个 kind 的一个*实现*的身份,在全部注册里唯一,包名是自然的取值。两者分开是因为 kind 并不唯一:`extension` 可以注册一个 `builtin` 已持有的 kind,两个实现随即在注册表里共存,生效的是 extension。注册表拒绝重复的 `id`、同一 kind 在同一档的第二次注册、以及任何与同 kind 的 `fallback` 相遇的注册;它只接受 extension 压 builtin 这一对,extension 注销后 builtin 恢复。
|
||||
|
||||
`patterns` 是资源地址上的 glob,用 `picomatch` 按 VS Code 编辑器解析器的规则匹配,只有一处本地改动:含 `:` 的 pattern 匹配整个地址(`dsh-resource://file/**`),不含的匹配 URI 的路径且任意深度(`*.md`),匹配不区分大小写、不隐藏 dotfile,不是 URI 的地址不匹配任何路径 pattern。页类型——引导页、文件树——不识别任何地址,省略 `patterns`,按 kind 打开。
|
||||
|
||||
`priority` 是三个字面量档位之一,写成字符串,好让别的包的类型不需要任何运行时引入:`extension` 是来自产品之外的类型的档位也是最高档,所以什么都不声明的类型压过这里随包交付的每个查看器;`builtin` 是随包类型的常规档;`fallback` 是任何更具体的东西都应压过的纯内容位置,VS Code 的文本编辑器隐含地占据它,我们的文本预览明确地占据它。`candidates(address)` 返回 glob 命中且 `canOpen` 未否决的每个类型,按档位、再按命中的最长 pattern 长度、再按注册顺序排序。`claim(address, kind?)` 取最佳候选,或在调用方指定时取该 kind 生效的类型(不查它的 glob;点名即决定),对无人愿开的地址抛错——这是接线错误,不是用户错误。`get(kind)` 返回生效类型;`entries()` 与 `guide()` 列出生效类型及其引导入口;`subscribe` 观察变化。
|
||||
|
||||
`title(address)` 与 `guide[].title()` 是每次使用时重读的 thunk,语言切换无需重新注册。注册表本身是 `apply` 顶层提供的普通对象,**不带** `Service.tracker`:tracker 会把 `this.ctx` 重绑到调用方上下文,跨包 `register()` 就会在调用方 fiber 仍是活动作用域时往它上加 effect,浏览器启动会无声卡死。
|
||||
|
||||
### 正文与标题:按定义 `id` keyed 的 Slot 坑位
|
||||
|
||||
类型注册表说类型是什么;Slot 系统说它长什么样。类型把正文注册进 keyed、session 作用域的坑位 `sidebar.right.pane.tab`,键是自己的 `id`,并可把标题组件注册进 `sidebar.right.pane.tab.title`,键相同。画 tab 的座位经注册表把 tab 的 `kind` 解析成生效类型,再派发到该类型的 `id`,于是 extension 接管 builtin 的 kind 时两个包互不知晓也能正确渲染,且没有任何优先级数字跨过包边界。没有生效类型的 kind 渲染属主的「没有东西能查看它」提示;没注册标题的类型得到注册表在打开时捕获的 `title(address)` 文本。
|
||||
|
||||
另有两个坑位扩展引导与菜单:`sidebar.right.tab.guide` 是 chain,第一个不拒绝的条目在不替换 tab 的前提下替换随包引导正文;`sidebar.right.tab.menu.item` 是 list,追加在库自身布局动作之后,放与 tab 内容有关的动作。类型自己的控件——重载、换行开关——住在自己正文里;tab 条属于面板,只放面板的控件。类型自己的状态是正文注册上普通的 Slot store 与 inject 面;框架不给组件模型添任何东西。
|
||||
|
||||
### 标签实例信息
|
||||
|
||||
[响应式 Sidebar 与标签信息](2026-09-07-sidebar-responsive-tab-info.zh.md)取代本记录中以平铺 owner props 传递实例信息的选择。正文、标题与引导页替换项接收框架注入的 `useTabInfo()`,以 `{ sidebar, panel, tab }` 读取所属 Sidebar、窗格与标签。实例的记录、导航、可见性、signal 与绑定动作均在 `tab` 内;精确字段见 [Sidebar 参考](../../../../docs/subsystems/sidebar-right.zh.md)。
|
||||
|
||||
标签域仍为每个已提交记录拥有一个实例,包括 `AbortController`、导航快照和绑定到所属 Session 的动作。记录存活期间,其地址被钉在[资源模型](2026-09-05-client-resource-model.zh.md)中;隐藏与切换 Session 不结束实例,关闭记录则中止并释放它。框架已有的存储与导航钩子提供实时读取,类型不自行订阅。
|
||||
|
||||
### 导航:`ctx.sidebarRight`
|
||||
|
||||
该面以两种方式打开内容,对内容不做别的事:
|
||||
|
||||
```ts ignore-check
|
||||
openResource(address: string, options?: { kind?: string; params?: SidebarRightResourceParams; paneId?; replaceTab?: TabId; revealIfOpened?: boolean }): void
|
||||
openTab<K extends string>(kind: K, options?: { params?: SidebarRightTabParamsFor<K>; paneId?; replaceTab?: TabId; revealIfOpened?: boolean }): void
|
||||
```
|
||||
|
||||
`openResource` 接一个资源地址——`dsh-resource://<type>/…` URI,资源模型仅有的 scheme——并问注册表谁来展示:不带 `kind` 时问遍所有类型由排序决定;带 `kind` 时由该类型生效的实现打开。其它 scheme 的地址与无人认领的地址走同一条失败路径。`openTab` 按 kind 打开页类型,永远见不到地址:Sidebar 把该 tab 记账在 `sidebar://<kind>` 下,这个字面量只在包内一处拼装,为的是页 tab 与其它 tab 一样有 `contentId` 供身份与历史使用。这个 scheme 只是记账:没有调用方拼它,业务包里没有这个字面量,文件树与引导页分别以 `openTab('files')`、`openTab('guide')` 打开。
|
||||
|
||||
两种打开走同样四步:解析类型(按排序或按 kind);除非 `revealIfOpened` 为 `false`,否则按 `(kind, contentId)` 定位已有 tab;落位——落在 `replaceTab` 的格与条位、`paneId`、或活跃格;把展开、打开或聚焦、以及 `replaceTab` 的关闭记为一条历史,再把 `{ address, params }` 交给 tab 域。落位是调用方的事,从不是类型级特性:文件树把文件开进自己的格是因为它自己说了,正如 VS Code 的 Explorer 自己传 `SIDE_GROUP` 或 `ACTIVE_GROUP`。`replaceTab` 只有一个含义——在那个 tab 的位置打开并在同一步关掉它——为的是引导页入口框把自己的 tab 交给所点的页。
|
||||
|
||||
参数按被打开的东西定型,经 Sidebar 包声明、由键的拥有者增补的两张可声明合并表:
|
||||
|
||||
```ts
|
||||
interface SidebarRightResourceParamsMap {} // key: resource type — the text preview declares { line?: number }
|
||||
interface SidebarRightTabParamsMap {} // key: kind — a page type declares its own shape, or nothing
|
||||
```
|
||||
|
||||
`openResource` 接受所有已声明资源形状的联合,`openTab<K>` 接受为 `K` 声明的形状;正文按自己所服务的协议或 kind 收窄 `navigation.params`。参数属于资源类型而非查看器,因为行号是关于文件位置的事实,不是关于文本预览的,任何认领 `file` 地址的类型收到同一形状。值必须可 JSON 序列化,一条记录必须只凭地址与参数就能重建,因为撤销、重做、刷新与 HMR 都在开启方已不在时重建 tab。
|
||||
|
||||
除两种打开外,该面还有 `close(tabId)`、`active()`、`isExpanded()`、`toggleExpanded()`,以及四个操作型方法——`focus(tabId)`、`split(paneId?)`(返回新格,预算或宽度规则不允许分栏时返回 `undefined` 且不记账)、`float(tabId, rect?)` 与 `dock(paneId)`——每个记一条历史,目标不存在或已在目标态时为 no-op。没有布局快照、没有订阅、没有按地址查找:该面给的是对布局的控制权,不是布局的视图。座位挂载期间发布其绑定——自己的会话、其 store 的 action 与其面;公开面上的命令作用于已挂载会话,没有已挂载会话面时抛错。tab 自己的动作则到达其会话自己的 store:slot 运行时每个会话铸一个 store,插件在铸出时逐个收养,控制器按会话 id 路由,因此用户切换会话之后触发的动作照样落地,而 store 从未铸出的会话什么也不做。
|
||||
|
||||
### 地址
|
||||
|
||||
地址分两族,永不混用。资源地址是资源模型的 `dsh-resource://<type>/…` URI(工作区文件是 `dsh-resource://file/session/<sessionId>/<相对该会话工作区根的路径>`,任意文件是 `dsh-resource://file/absolute/<绝对路径>`,都由 `dsh-util-workspace-path` 构造与解析);它们是 `openResource` 的入参、`patterns` 的匹配对象、`useResource` 的读取对象。导航地址命名的是页而非数据;今天唯一的一种是页 tab 记账用的内部 `sidebar://<kind>`。只有资源族是契约:导航族在 Sidebar 内部拼装与消费,更完整的导航协议是之后的决定,本决定通过把所有导航字面量留在一处为它预留空间。
|
||||
|
||||
### 入口
|
||||
|
||||
会话区的 `openFile(path, { line? })`——工具行路径链接、产出文件 chip、收尾消息提及——把路径编码为该 Session 的文件资源地址并调用 `openResource`,调用方知道行号时带 `params.line`;`read` 工具行传入其 `offset` 参数起始的行。tab 条的「+」为所在格调用 `openTab('guide', { paneId, revealIfOpened: false })`;引导入口框调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`;文件树的一行调用 `tab.actions.openResource(address)`,落在树自己的格里。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**用 chain 坑位派发 tab,或只用 keyed 坑位。** chain 的 `select` 不可枚举,而引导页与导航面必须枚举类型;keyed 坑位只带正文,类型的标题与地址识别无处可住。两段——定义注册表加 keyed 组件坑位——是仓库既有模式(`ConversationViewRegistry`)。
|
||||
|
||||
**运行时 hook 或每 tab 一个实例对象。** 纸面上试过多种形态——每 tab 一个 Cordis fiber、抽象基类、返回带 `dispose` 实例的 `initial`/`create` 对、一组 `useTab*` hook、框架托管的 `useTabResource(fetch)`、`useTabStream`。依次否决:每 tab 一个 fiber 太重;动态包无法共享基类或导出常量;实例层重复了 Slot store 与 inject 面已经是的东西;每 tab hook 复述 owner props;框架托管的 fetch 没有好的缓存键;tab 域上的流 hook 问错了主人——聊天数据必须来自聊天域,文件数据来自工作区文件服务。剩下的是 owner props 加一个全客户端的 `useResource`。`visible` 后来以 prop 而非 hook 加入也是同一理由:它是关于该次出现的又一个事实,而 props 已经承载了该次出现。 对实例读取钩子的否决由[标签信息决策](2026-09-07-sidebar-responsive-tab-info.zh.md)取代;对独立实例对象、fiber 与数据流所有权的理由仍适用。
|
||||
|
||||
**每格一个工具区坑位放活跃 tab 的控件(`sidebar.right.pane.tab.tools`)。** 上线一轮评审后删除:它把类型私有按钮放到面板 tab 条上、与分栏和收起控件并列,读起来像面板 chrome。类型的控件属于自己的正文。
|
||||
|
||||
**类型级落位(`opensInto`)与隐藏的相邻格启发式。** 否决:tab 落在哪是开启方的事,正如 VS Code 的 Explorer 自己决定 `sideBySide`。
|
||||
|
||||
**扩展名列表与数字优先级。** `claims.extensions` 表达不了 `.d.ts`、`Dockerfile`、目录约束或整个 scheme——它是退化的 glob;数字优先级需要动态包无法引入的导出常量。字面量档位加 glob。VS Code 自己的档位从五个收成三个:`option` 档(只列出、永不自动选中)在「用其他方式打开」存在之前没有消费者,`default` 档改名 `extension`,因为那个名字读起来像最低档而它是最高档。
|
||||
|
||||
**一个 `open(address)` 包打天下,外加拼页地址的帮助函数。** 第一版页也按地址打开,于是业务包需要 `sidebar://<kind>` 字面量或来自 Sidebar 包的 `sidebarAddress(kind)` 帮助函数。两者都被值引用规则禁止,也都泄露了尚未设计的导航 scheme。把面拆成 `openResource` 与 `openTab`,唯一的字面量留在包内,且每种模式各自定型参数。
|
||||
|
||||
**打开时点名某个实现(`?impl=`)、`find(address)`、`mode()`/`setMode()`、布局快照、`features` 清单。** 都考虑过并留在外面。点名实现属于尚不存在的导航协议;`find` 与快照会把该面变成布局的视图,而它本该是对布局的控制;呈现模式是 UI 开关不是插件关心的事;能力清单在该面尚在收敛时为时过早。
|
||||
|
||||
**用 Slot 优先级表达 extension 接管 builtin,随后是注册表铸造的坑位键。** 第一次尝试让覆盖方经一个导出常量以更低的 Slot 优先级注册正文——这是动态插件间的值引用,也是拿第二套规则(Slot 优先级)替注册表的规则站台。第二次尝试让注册表为每次注册铸一个键并从 `register()` 返回,这把注册变成了两步且顺序敏感的舞步。让实现自己声明 `id`——必填、唯一、与它注册坑位所用的同一个串——既不需要常量,也不需要铸键与顺序,还给了注册表拒绝重复所需的身份。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 一个类型 = 一个静态对象 + 一到两个 keyed 坑位注册;其实例信息通过注入的 `useTabInfo()` 读取。框架不长任何按类型的 API 面,仓外类型从 Sidebar 包只引类型。
|
||||
- 两种打开配两张参数表,意味着调用方无法只按地址开页或只按 kind 开资源,编译器会说明;代价是每个想要类型化参数的新资源类型或页 kind 都要增补一张表。
|
||||
- `id` 与 `kind` 分离让 extension 能按 kind 原位替换随包类型,extension 注销后 builtin 恢复;代价是每个定义多一个必填字段。
|
||||
- 导航面只有控制权。需要知道布局的插件无法索取,这让布局的形状在导航协议决定暴露什么之前不进任何插件的契约。
|
||||
- `sidebar://<kind>` 字面量住在一个文件里。之后改导航语法只碰 Sidebar 包。
|
||||
- 这些面是 Sidebar 里被定死的部分:地址、注册字段与档位、两种打开及其参数表、slot 名与注入的标签信息。用户看到的一切行为——浮窗贴到哪、分栏控件何时置灰、文案、树的排序——都是这里任何契约之外的产品规则,改动无需通知任何插件。
|
||||
|
||||
## Testing
|
||||
|
||||
`ui-sidebar-right` 的 spec 覆盖注册表(档位、extension 压 builtin 及恢复、`id` 与同档冲突、glob 与路径匹配、`canOpen`、排序与平局)、两种打开(正常、边界与失败路径,含错误 scheme 与未注册 kind)、`replaceTab` 记一条历史、座位把 kind 解析到生效实现并回退、`useTabInfo()` 含折叠与浮窗下的 `tab.visible`、以及操作型方法的 no-op 与抛错情形。Web e2e 套件在 Chromium 里经真实插件图驱动引导页、文件树与一次文件打开。两套均无需密钥。
|
||||
|
||||
## Deferred
|
||||
|
||||
- `sidebar://<kind>` 之外的导航协议:页内子路由、点名实现、以及面向生态的其它导航 scheme 规则。
|
||||
- 随包页类型的参数,今天未声明任何。
|
||||
- 从公开面往屏上会话之外的会话里打开;公开面只作用于已挂载的会话,而 tab 自己的动作已作用于其所在会话。
|
||||
- 从会话区打开失败时的本地化提示;目前是抛错文本本身。
|
||||
Loaded 100 of 3151 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user