dsh-zen-remote 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/remote-access.md +2 -2
- package/lan-gate.mjs +15 -1
- package/lib/client.js +271 -30
- package/lib/fingerprint.js +4 -2
- package/lib/fingerprint.js.map +1 -1
- package/lib/intercept.js +161 -14
- package/lib/intercept.js.map +1 -1
- package/lib/merge-streams.js +11 -4
- package/lib/merge-streams.js.map +1 -1
- package/lib/relay-access.js.map +1 -1
- package/lib/relay-server.js +32 -11
- package/lib/relay-server.js.map +1 -1
- package/lib/types/client/effects/model-group-side.d.ts +13 -0
- package/lib/types/client/effects/model-group-side.d.ts.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/styles/compat.css.d.ts +1 -1
- package/lib/types/client/styles/compat.css.d.ts.map +1 -1
- package/lib/types/client/styles/composer.css.d.ts +1 -1
- package/lib/types/client/styles/composer.css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client-data/model-group-side.d.ts +44 -0
- package/lib/types/client-data/model-group-side.d.ts.map +1 -0
- package/lib/types/fingerprint.d.ts +4 -2
- package/lib/types/fingerprint.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +19 -9
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts +7 -2
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/relay-access.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts.map +1 -1
- package/lib/types/virtual-id.d.ts +14 -0
- package/lib/types/virtual-id.d.ts.map +1 -1
- package/lib/virtual-id.js +14 -0
- package/lib/virtual-id.js.map +1 -1
- package/package.json +48 -49
- package/src/client/effects/model-group-side.ts +132 -0
- package/src/client/index.tsx +7 -0
- package/src/client/styles/compat.css.ts +14 -8
- package/src/client/styles/composer.css.ts +2 -1
- package/src/client/styles/remote-session.css.ts +43 -20
- package/src/client-data/model-group-side.ts +65 -0
- package/src/fingerprint.ts +4 -2
- package/src/intercept.ts +167 -12
- package/src/merge-streams.ts +11 -4
- package/src/relay-access.ts +6 -3
- package/src/relay-server.ts +49 -11
- package/src/virtual-id.ts +15 -0
package/src/client/index.tsx
CHANGED
|
@@ -40,6 +40,7 @@ import { mountCurrentSessionReporter } from './current-session-report.ts'
|
|
|
40
40
|
import { mountRemoteGroupExpansionKeeper } from './remote-group-expansion.ts'
|
|
41
41
|
import { installRemoteApiFetch } from './remote-fetch.ts'
|
|
42
42
|
import { installRemoteSessionGuard } from './effects/remote-session.ts'
|
|
43
|
+
import { installModelGroupSide } from './effects/model-group-side.ts'
|
|
43
44
|
|
|
44
45
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
45
46
|
interface LocaleNamespaceMap {
|
|
@@ -148,6 +149,12 @@ export function apply(ctx: ClientContext): void {
|
|
|
148
149
|
// 逐字节原样放行;标记属性只在虚拟会话打开时出现,卸载时还原。
|
|
149
150
|
ctx.effect(() => installRemoteApiFetch(window), 'dsh-zen-remote: remote api fetch')
|
|
150
151
|
installRemoteSessionGuard(ctx)
|
|
152
|
+
// T74 模型选单分组按会话归属显示:0.2.0 的 MenuGroup 的 aria-labelledby 只剩
|
|
153
|
+
// useId(分组 id 不再出现在标记里),旧的 zr~ 前缀选择器失效——改为 effect 给
|
|
154
|
+
// 分组打 data-zr-group 标(分类依据是虚拟分组名开头的不可见标记,见
|
|
155
|
+
// client-data/model-group-side.ts),CSS 按标隐藏。同样必须在桌面门之前挂载:
|
|
156
|
+
// 桌面端子客户端的模型选单一样要分侧,桌面无手机外壳不影响它。
|
|
157
|
+
installModelGroupSide(ctx)
|
|
151
158
|
|
|
152
159
|
// Desktop gate (DSH 0.1.7): the official Electron shell can be dragged
|
|
153
160
|
// down to ~520px wide, where every width-based gate would flip the phone
|
|
@@ -440,14 +440,20 @@ export const COMPAT_CSS = ` /* ---------- dsh-web-ui family compatibility -----
|
|
|
440
440
|
cache) is long. It is the single entry the official StatsLine puts in
|
|
441
441
|
conversation.composer.dock, so the structural anchor below reaches it
|
|
442
442
|
without any DOM marking (S3 deleted the text-matching effect that used
|
|
443
|
-
to set
|
|
443
|
+
to set the dock-entry marker). Layout: ONE fixed-height (28px) flex
|
|
444
444
|
strip that scrolls horizontally — the full metrics stream stays
|
|
445
445
|
reachable by swiping, the row never grows vertically, no ellipsis or
|
|
446
446
|
fade, 12px gaps between metric groups, a 2px scrollbar as the swipe
|
|
447
447
|
affordance. The phone breakpoint hides the strip outright instead
|
|
448
448
|
(styles/composer.css.ts — its data moves into the session info card). */
|
|
449
449
|
|
|
450
|
-
|
|
450
|
+
/* Marker set is deliberately dual: 0.2.0-rc.2 renders the strip root with
|
|
451
|
+
data-composer-stats (plural, dsh-client-ui-chat rc.2 lib), 0.2.1-alpha.2
|
|
452
|
+
dropped the strip wrapper and registers activity/usage as separate dock
|
|
453
|
+
entries whose pill spans carry data-composer-stat (singular). Keeping
|
|
454
|
+
both lets this plugin style the strip on a host that has NOT upgraded to
|
|
455
|
+
alpha.2 — verified against both npm tarballs 2026-10-09. */
|
|
456
|
+
[data-slot="conversation.composer.dock"] > :is([data-composer-stat], [data-composer-stats]) {
|
|
451
457
|
display: flex !important;
|
|
452
458
|
flex-flow: row nowrap !important;
|
|
453
459
|
align-items: center !important;
|
|
@@ -469,17 +475,17 @@ export const COMPAT_CSS = ` /* ---------- dsh-web-ui family compatibility -----
|
|
|
469
475
|
line-height: 20px !important;
|
|
470
476
|
font-size: 12px !important;
|
|
471
477
|
}
|
|
472
|
-
[data-slot="conversation.composer.dock"] > [
|
|
478
|
+
[data-slot="conversation.composer.dock"] > :is([data-composer-stat], [data-composer-stats])::-webkit-scrollbar {
|
|
473
479
|
height: 2px !important;
|
|
474
480
|
}
|
|
475
|
-
[data-slot="conversation.composer.dock"] > [
|
|
481
|
+
[data-slot="conversation.composer.dock"] > :is([data-composer-stat], [data-composer-stats])::-webkit-scrollbar-thumb {
|
|
476
482
|
background: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, .3)) !important;
|
|
477
483
|
border-radius: 2px !important;
|
|
478
484
|
}
|
|
479
|
-
[data-slot="conversation.composer.dock"] > [
|
|
485
|
+
[data-slot="conversation.composer.dock"] > :is([data-composer-stat], [data-composer-stats])::-webkit-scrollbar-track {
|
|
480
486
|
background: transparent !important;
|
|
481
487
|
}
|
|
482
|
-
[data-slot="conversation.composer.dock"] > [
|
|
488
|
+
[data-slot="conversation.composer.dock"] > :is([data-composer-stat], [data-composer-stats]) > * {
|
|
483
489
|
display: flex !important;
|
|
484
490
|
flex: 0 0 auto !important;
|
|
485
491
|
flex-flow: row nowrap !important;
|
|
@@ -491,10 +497,10 @@ export const COMPAT_CSS = ` /* ---------- dsh-web-ui family compatibility -----
|
|
|
491
497
|
margin-right: 12px !important;
|
|
492
498
|
padding: 0 !important;
|
|
493
499
|
}
|
|
494
|
-
[data-slot="conversation.composer.dock"] > [
|
|
500
|
+
[data-slot="conversation.composer.dock"] > :is([data-composer-stat], [data-composer-stats]) > *:last-child {
|
|
495
501
|
margin-right: 0 !important;
|
|
496
502
|
}
|
|
497
|
-
[data-slot="conversation.composer.dock"] > [
|
|
503
|
+
[data-slot="conversation.composer.dock"] > :is([data-composer-stat], [data-composer-stats]) * {
|
|
498
504
|
white-space: nowrap !important;
|
|
499
505
|
}
|
|
500
506
|
|
|
@@ -803,7 +803,8 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
|
|
|
803
803
|
/* --- 8. the official stats strip leaves the composer ---
|
|
804
804
|
Its data moves into the session info card (S4). The row is the composer
|
|
805
805
|
dock's own \`_root\` entry; the slot itself stays live for later entries. */
|
|
806
|
-
[data-slot="conversation.composer.dock"] > [class$="_root"]
|
|
806
|
+
[data-slot="conversation.composer.dock"] > [class$="_root"],
|
|
807
|
+
[data-slot="conversation.composer.dock"] > [data-composer-stat] {
|
|
807
808
|
display: none !important;
|
|
808
809
|
}
|
|
809
810
|
|
|
@@ -163,31 +163,54 @@ html[data-zr-remote-session="1"] [data-sidebar-right-guide-entry="files"]:not([a
|
|
|
163
163
|
display: none !important;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
/* ---------- T52: model-menu groups follow the session's side ----------
|
|
166
|
+
/* ---------- T52/T74: model-menu groups follow the session's side ----------
|
|
167
167
|
The model catalog is GLOBAL (one shared load per page), so the merged
|
|
168
168
|
catalog shows the server's groups everywhere; but a selection is only
|
|
169
169
|
runnable on the side the open session lives on — the interceptor refuses
|
|
170
|
-
the other side with a clear message. Hide what cannot run
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
170
|
+
the other side with a clear message. Hide what cannot run.
|
|
171
|
+
|
|
172
|
+
What changed and why the mechanism is a STAMP now (T74): through 0.1.7
|
|
173
|
+
each menu group rendered aria-labelledby="<useId>-<group.id>" (RT017
|
|
174
|
+
dsh-client-ui-model-selection lib/client.js ~824-830) — the group id was
|
|
175
|
+
IN the attribute, and rules could key on a virtual group's "zr~" prefix
|
|
176
|
+
directly. 0.2.0 renders the groups with the primitives' MenuGroup
|
|
177
|
+
instead (RT dsh-client-ui-primitives lib/index.js ~4283-4304): the
|
|
178
|
+
section's aria-labelledby carries ONLY the useId, the heading is a child
|
|
179
|
+
div[data-menu-group-heading], and the menu container is a div whose id
|
|
180
|
+
ends in "-models" with role="menu" (RT dsh-client-ui-model-selection
|
|
181
|
+
lib/client.js ~974-980). Two consequences killed the old rules: a group
|
|
182
|
+
section no longer says anywhere in its markup which group it is, and the
|
|
183
|
+
same MenuGroup renders OTHER menus' groups too — the old
|
|
184
|
+
[role="menu"] section[role="group"] scope alone was too wide and would
|
|
185
|
+
have hidden every unmarked group of every menu in a remote session
|
|
186
|
+
(leaving just the search box), while missing the server's groups in
|
|
187
|
+
local ones.
|
|
188
|
+
|
|
189
|
+
The new keys on what EVERY version actually renders, the heading text:
|
|
190
|
+
each virtual group's name is prefixed with the invisible WORD JOINER
|
|
191
|
+
(VIRTUAL_GROUP_NAME_MARK, virtual-id.ts; written by mergeModelCatalogs).
|
|
192
|
+
The browser effect (effects/model-group-side.ts) reads each group's
|
|
193
|
+
heading and stamps data-zr-group="remote|local" on the section — but
|
|
194
|
+
only inside a CONTAINER (the sections' shared parent element) that holds
|
|
195
|
+
at least one marked heading. That container rule is what scopes these
|
|
196
|
+
rules to the model menu alone WITHOUT assuming menu structure: an
|
|
197
|
+
unpaired client's model menu, every other menu, and the whole rest of
|
|
198
|
+
the page hold no marked heading, receive no stamp, and match neither
|
|
199
|
+
rule. And since the merged catalog only ever APPENDS virtual groups to
|
|
200
|
+
the local list, within a decided container the unmarked groups are
|
|
201
|
+
exactly the local ones — mixed containers (the norm) decide each group
|
|
202
|
+
separately. The menu is a portal under <body>, but <html> is still its
|
|
203
|
+
ancestor, so the html-level session attribute scopes both rules. If the
|
|
204
|
+
effect is not running (uninstalled, or a host-role page that never had
|
|
205
|
+
it), no stamp exists and both rules go inert — nothing stays hidden.
|
|
206
|
+
|
|
207
|
+
KNOWN BOUNDARY (accepted, not fixed): in a remote session, searching the
|
|
208
|
+
model menu and pressing Enter can commit a HIDDEN local model — the
|
|
209
|
+
interceptor refuses it with its usual message. */
|
|
210
|
+
html[data-zr-remote-session="1"] section[role="group"][data-zr-group="local"] {
|
|
188
211
|
display: none !important;
|
|
189
212
|
}
|
|
190
|
-
html:not([data-zr-remote-session])
|
|
213
|
+
html:not([data-zr-remote-session]) section[role="group"][data-zr-group="remote"] {
|
|
191
214
|
display: none !important;
|
|
192
215
|
}
|
|
193
216
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T74: which side of the pairing a model-menu GROUP belongs to, as pure
|
|
3
|
+
* functions — the readable core of the browser effect
|
|
4
|
+
* (src/client/effects/model-group-side.ts).
|
|
5
|
+
*
|
|
6
|
+
* WHY: the merged catalog (merge-streams.ts mergeModelCatalogs) appends the
|
|
7
|
+
* server's provider groups everywhere, but a selection only runs on the side
|
|
8
|
+
* the open session lives on — the interceptor refuses the other side. Through
|
|
9
|
+
* DSH 0.1.7 every menu group carried its id in `aria-labelledby`
|
|
10
|
+
* (`<useId>-<group.id>`), so CSS keyed the hiding on the `zr~` prefix (T52).
|
|
11
|
+
* DSH 0.2.0 renders the groups with the primitives' MenuGroup, whose
|
|
12
|
+
* `aria-labelledby` is a bare useId — the id is gone — and the same component
|
|
13
|
+
* renders OTHER menus' groups too, so attribute scoping misfires in both
|
|
14
|
+
* directions. Classification therefore moved to the one mark every version
|
|
15
|
+
* renders: virtual group NAMES start with VIRTUAL_GROUP_NAME_MARK
|
|
16
|
+
* (virtual-id.ts), read off each group's heading text.
|
|
17
|
+
*
|
|
18
|
+
* The container rule is what keeps the effect inert wherever it cannot be
|
|
19
|
+
* sure: a container with not ONE marked heading — the model menu of a
|
|
20
|
+
* sub-client with no shared models, every other menu on the page, the whole
|
|
21
|
+
* rest of the page — classifies as `undefined` for every group, which the
|
|
22
|
+
* effect reads as "leave the DOM alone". Only a container holding at least
|
|
23
|
+
* one marked heading is decided, and inside it the unmarked groups are
|
|
24
|
+
* exactly the LOCAL ones: the merged catalog only ever APPENDS virtual
|
|
25
|
+
* groups to the local list, never mixes them the other way.
|
|
26
|
+
*
|
|
27
|
+
* Defensive like the rest of client-data: an empty or missing heading never
|
|
28
|
+
* counts as a mark, so it can never decide a container by accident.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { VIRTUAL_GROUP_NAME_MARK } from '../virtual-id.ts'
|
|
32
|
+
|
|
33
|
+
/** One group's display description, as the DOM shows it. */
|
|
34
|
+
export interface ModelGroupHeading {
|
|
35
|
+
/** Groups sharing one container share one key (the effect numbers the
|
|
36
|
+
* parent elements it scans). */
|
|
37
|
+
containerKey: number
|
|
38
|
+
/** The group's heading text ('' when no heading could be found). */
|
|
39
|
+
headingText: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Per-group verdict, in input order: `remote` for marked headings, `local`
|
|
43
|
+
* for the rest of a container that has at least one, and `undefined` —
|
|
44
|
+
* "stamp nothing" — for every group of a container that holds no mark at
|
|
45
|
+
* all. */
|
|
46
|
+
export type ModelGroupSide = 'remote' | 'local' | undefined
|
|
47
|
+
|
|
48
|
+
export function classifyModelGroups(groups: readonly ModelGroupHeading[]): ModelGroupSide[] {
|
|
49
|
+
// Per container: did any heading carry the mark? Containers are decided
|
|
50
|
+
// independently — one mixed model menu must not turn an all-local
|
|
51
|
+
// container (a second menu elsewhere on the page) into "hide everything
|
|
52
|
+
// local" there.
|
|
53
|
+
const marked = new Map<number, boolean>()
|
|
54
|
+
for (const group of groups) {
|
|
55
|
+
const had = marked.get(group.containerKey) ?? false
|
|
56
|
+
marked.set(group.containerKey, had || group.headingText.startsWith(VIRTUAL_GROUP_NAME_MARK))
|
|
57
|
+
}
|
|
58
|
+
return groups.map((group) =>
|
|
59
|
+
marked.get(group.containerKey)
|
|
60
|
+
? group.headingText.startsWith(VIRTUAL_GROUP_NAME_MARK)
|
|
61
|
+
? 'remote'
|
|
62
|
+
: 'local'
|
|
63
|
+
: undefined,
|
|
64
|
+
)
|
|
65
|
+
}
|
package/src/fingerprint.ts
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
* definitions, the server puts its map into the handshake, the client
|
|
9
9
|
* compares group by group, and the settings page lists the differing groups.
|
|
10
10
|
* Identical maps mean fully compatible — the DSH version numbers themselves
|
|
11
|
-
* are deliberately NOT compared (docs/spike-relay.md §2.3: 0.1.7 and
|
|
12
|
-
* share byte-identical remote interfaces, so a version gate would
|
|
11
|
+
* are deliberately NOT compared (docs/spike-relay.md §2.3: 0.1.7-rc.2 and
|
|
12
|
+
* 0.2.0-rc.1 share byte-identical remote interfaces, so a version gate would
|
|
13
|
+
* misreject; 0.2.0-rc.2 then changed the session group within one minor —
|
|
14
|
+
* an additive optional field — which a version gate would have missed).
|
|
13
15
|
*
|
|
14
16
|
* What is hashed, per group, in priority order:
|
|
15
17
|
*
|
package/src/intercept.ts
CHANGED
|
@@ -57,11 +57,14 @@
|
|
|
57
57
|
* `agentId` and `eventId` become `zr~<serverId>~…` virtual ids (those are
|
|
58
58
|
* the ONLY session ids a forwarded waterfall carries — the request bodies
|
|
59
59
|
* are tool/question data, verified against dsh-tools/dsh-user-questions),
|
|
60
|
-
* and the remote `ready`/`emit` frames are DROPPED
|
|
60
|
+
* and the remote `ready`/`emit` frames are DROPPED — except, since T75, a
|
|
61
|
+
* shared session's `api-session/status` emit, which crosses with its
|
|
62
|
+
* session id virtualized (the one share-scoped emit; every other emit
|
|
63
|
+
* broadcasts server-wide state the UI must not mistake for local
|
|
64
|
+
* sessions). The client face of the
|
|
61
65
|
* gateway (dsh-api-gateway lib/client.js) accepts a ready frame only as
|
|
62
|
-
* the FIRST frame of the stream and would fail the stream on a second one
|
|
63
|
-
*
|
|
64
|
-
* local sessions. Prompts this leg showed are closed when the leg dies —
|
|
66
|
+
* the FIRST frame of the stream and would fail the stream on a second one.
|
|
67
|
+
* Prompts this leg showed are closed when the leg dies —
|
|
65
68
|
* each gets a synthesized `cancel` — so a disconnect cannot leave an
|
|
66
69
|
* approval on screen that no server can settle anymore (T32-fix).
|
|
67
70
|
* - `dispatchRpc('$events/result', …)` splits on the eventId: a virtual id
|
|
@@ -668,6 +671,14 @@ interface ForwardableCancelFrame extends Record<string, unknown> {
|
|
|
668
671
|
eventId: string
|
|
669
672
|
}
|
|
670
673
|
|
|
674
|
+
/** T75: the one emit this client shows the UI (T75 — the server forwards no
|
|
675
|
+
* other). Same shape the server's own gate proved before forwarding. */
|
|
676
|
+
interface ForwardableStatusEmitFrame extends Record<string, unknown> {
|
|
677
|
+
type: 'emit'
|
|
678
|
+
event: 'api-session/status'
|
|
679
|
+
args: [string, boolean]
|
|
680
|
+
}
|
|
681
|
+
|
|
671
682
|
function isForwardableWaterfall(frame: Record<string, unknown>): frame is ForwardableWaterfallFrame {
|
|
672
683
|
return (
|
|
673
684
|
Reflect.ownKeys(frame).length === 5 &&
|
|
@@ -690,6 +701,18 @@ function isForwardableCancel(frame: Record<string, unknown>): frame is Forwardab
|
|
|
690
701
|
)
|
|
691
702
|
}
|
|
692
703
|
|
|
704
|
+
function isForwardableStatusEmit(frame: Record<string, unknown>): frame is ForwardableStatusEmitFrame {
|
|
705
|
+
return (
|
|
706
|
+
Reflect.ownKeys(frame).length === 3 &&
|
|
707
|
+
['type', 'event', 'args'].every((key) => Object.hasOwn(frame, key)) &&
|
|
708
|
+
frame.event === 'api-session/status' &&
|
|
709
|
+
Array.isArray(frame.args) &&
|
|
710
|
+
frame.args.length === 2 &&
|
|
711
|
+
isEventId(frame.args[0]) &&
|
|
712
|
+
typeof frame.args[1] === 'boolean'
|
|
713
|
+
)
|
|
714
|
+
}
|
|
715
|
+
|
|
693
716
|
function messageOf(error: unknown): string {
|
|
694
717
|
return error instanceof Error ? error.message : String(error)
|
|
695
718
|
}
|
|
@@ -1077,9 +1100,16 @@ export function rewriteResult(endpoint: string, value: unknown, serverId: string
|
|
|
1077
1100
|
// mergeSessionList rewrite instead, merge-streams.ts.)
|
|
1078
1101
|
if (!isPlainObject(value) || !isPlainObject(value.values)) return value
|
|
1079
1102
|
const values: Record<string, unknown> = { ...value.values }
|
|
1080
|
-
|
|
1103
|
+
// alpha.2 clients validate list/control projections with kind:'cached'|'sequenced'.
|
|
1104
|
+
// Older hosts (rc.2) omit it on the session/projections control-key result — synthesize.
|
|
1105
|
+
const withKind = typeof value.kind === 'string'
|
|
1106
|
+
? value
|
|
1107
|
+
: (typeof value.asOfSeq === 'number' ? { ...value, kind: 'sequenced' as const } : value)
|
|
1108
|
+
if (!Object.hasOwn(values, 'modelSelection')) {
|
|
1109
|
+
return withKind === value ? value : { ...withKind, values }
|
|
1110
|
+
}
|
|
1081
1111
|
values.modelSelection = virtualizeModelSelectionValue(values.modelSelection, serverId)
|
|
1082
|
-
return { ...
|
|
1112
|
+
return { ...withKind, values }
|
|
1083
1113
|
}
|
|
1084
1114
|
if (endpoint === 'session/selectModel') {
|
|
1085
1115
|
// T52: the result echoes the selection it accepted (`{selected:
|
|
@@ -1116,11 +1146,18 @@ export function rewriteResult(endpoint: string, value: unknown, serverId: string
|
|
|
1116
1146
|
* whole thing in `zr~<serverId>~`.
|
|
1117
1147
|
* - `cancel`: `{type, eventId}` — the correlation id goes virtual so the UI
|
|
1118
1148
|
* can match it against the waterfall it showed and close the prompt.
|
|
1119
|
-
* -
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1122
|
-
*
|
|
1123
|
-
*
|
|
1149
|
+
* - `emit`: dropped, with ONE exception (T75) — a well-formed
|
|
1150
|
+
* `api-session/status` frame (the only emit the server forwards, and only
|
|
1151
|
+
* for accessible sessions) has its `args[0]` session id virtualized and
|
|
1152
|
+
* its `args[1]` running flag passed through, so the UI's
|
|
1153
|
+
* `api-session/status` listener keeps the remote session's running state
|
|
1154
|
+
* in step with the host's (RT dsh-api-session-controller lib/client.js
|
|
1155
|
+
* `handleSessionStatus`).
|
|
1156
|
+
* - everything else — `ready`, non-status emits, non-objects, unknown
|
|
1157
|
+
* types, and any frame failing the client face's exact-keys shape — is
|
|
1158
|
+
* DROPPED (T32-fix2 mirrors the server side). `ready` and the other emits
|
|
1159
|
+
* carry facts the UI must never see (the local stream opened with ITS
|
|
1160
|
+
* ready frame; emits broadcast server-wide state); a malformed frame that
|
|
1124
1161
|
* slipped through would fail `parseRemoteEventFrame` and take the UI's
|
|
1125
1162
|
* whole `$events` generation down with it, failing and reconnecting in a
|
|
1126
1163
|
* loop. Mirrors the server's own forwardable-shape gate, so the two ends
|
|
@@ -1130,7 +1167,10 @@ export function rewriteRemoteEventFrame(frame: unknown, serverId: string): unkno
|
|
|
1130
1167
|
const virtualize = (id: string): string => toVirtual(serverId, id)
|
|
1131
1168
|
if (!isPlainObject(frame)) return null
|
|
1132
1169
|
if (frame.type === 'ready') return null
|
|
1133
|
-
if (frame.type === 'emit')
|
|
1170
|
+
if (frame.type === 'emit') {
|
|
1171
|
+
if (!isForwardableStatusEmit(frame)) return null
|
|
1172
|
+
return { type: 'emit', event: frame.event, args: [virtualize(frame.args[0]), frame.args[1]] }
|
|
1173
|
+
}
|
|
1134
1174
|
if (frame.type === 'waterfall') {
|
|
1135
1175
|
if (!isForwardableWaterfall(frame)) return null
|
|
1136
1176
|
return { ...frame, eventId: virtualize(frame.eventId), agentId: virtualize(frame.agentId) }
|
|
@@ -1791,6 +1831,17 @@ interface MergedEventsStreamDeps {
|
|
|
1791
1831
|
* id the server did not return is retried on a 2s/10s backoff at most
|
|
1792
1832
|
* twice before it counts as asked for good (a tombstone triggers at most
|
|
1793
1833
|
* three syncs), and a fresh serving period clears the bookkeeping.
|
|
1834
|
+
*
|
|
1835
|
+
* T75 adds the serving-period STATUS sync: the UI's `session.running` for a
|
|
1836
|
+
* remote session updates only on the forwarded `api-session/status` emit
|
|
1837
|
+
* (RT dsh-api-session-controller lib/client.js `handleSessionStatus`) and
|
|
1838
|
+
* the list summaries — so every flip inside an offline window was lost, and
|
|
1839
|
+
* a session that ended (or started) while the link was down kept its stale
|
|
1840
|
+
* badge until the next page load. Every new serving period now re-pulls the
|
|
1841
|
+
* list once and pushes one synthesized status frame per row (see
|
|
1842
|
+
* {@link runStatusSync}), guarded against overwriting a real-time frame
|
|
1843
|
+
* that landed mid-fetch, and gated on the local `ready` like every
|
|
1844
|
+
* synthesized frame.
|
|
1794
1845
|
*/
|
|
1795
1846
|
async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator<unknown, void, undefined> {
|
|
1796
1847
|
const { local, relay, signal, recordFailure, log, clock } = deps
|
|
@@ -1969,6 +2020,90 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
|
|
|
1969
2020
|
if (!ids.some((id) => !announced.has(id) && !askedForGood.has(id) && !retryAt.has(id))) return
|
|
1970
2021
|
runSummarySync(ids)
|
|
1971
2022
|
}
|
|
2023
|
+
// T75 status-sync bookkeeping. `statusSeq` is a monotonic counter over
|
|
2024
|
+
// status facts; `liveStatusSeq` maps session id → the seq of the newest
|
|
2025
|
+
// REAL-TIME status frame the leg delivered since the in-flight fetch began
|
|
2026
|
+
// (cleared at each fetch start, so it only ever holds frames NEWER than
|
|
2027
|
+
// the snapshot being fetched — exactly the ids the snapshot must not
|
|
2028
|
+
// overwrite). `deferred*`/`pending*` mirror the summary sync's gates: the
|
|
2029
|
+
// local `ready` must come first, and a serving period that began while a
|
|
2030
|
+
// fetch ran gets its own round once that fetch lands.
|
|
2031
|
+
let statusSeq = 0
|
|
2032
|
+
const liveStatusSeq = new Map<string, number>()
|
|
2033
|
+
let statusSyncInFlight = false
|
|
2034
|
+
let pendingStatusSync = false
|
|
2035
|
+
let deferredStatusSync = false
|
|
2036
|
+
/**
|
|
2037
|
+
* T75: fetch the server's `session/list` once and push one synthesized
|
|
2038
|
+
* `api-session/status` emit per row (args `[virtualId, row.running]`) — a
|
|
2039
|
+
* NEW serving period's re-sync, because the status flips that happened
|
|
2040
|
+
* while the relay was offline never traveled (the forwarded status emit
|
|
2041
|
+
* only exists while the leg is up, and `runSummarySync`'s one-time row
|
|
2042
|
+
* announcement is deduped by `announced`, so the running flags the UI
|
|
2043
|
+
* holds would stay frozen at page-load state). Runs beside the summary
|
|
2044
|
+
* sync per serving period (its own fetch: the summary round is nudge-
|
|
2045
|
+
* driven and announce-deduped, and bolting a status mode onto it would
|
|
2046
|
+
* tangle both features' bookkeeping for one saved request), gated on the
|
|
2047
|
+
* local `ready` like every synthesized frame, and a failure is
|
|
2048
|
+
* diagnostics only — the next serving period retries.
|
|
2049
|
+
*/
|
|
2050
|
+
const runStatusSync = (): void => {
|
|
2051
|
+
if (!alive) return
|
|
2052
|
+
if (!localOpened) {
|
|
2053
|
+
deferredStatusSync = true
|
|
2054
|
+
return
|
|
2055
|
+
}
|
|
2056
|
+
if (statusSyncInFlight) {
|
|
2057
|
+
pendingStatusSync = true
|
|
2058
|
+
return
|
|
2059
|
+
}
|
|
2060
|
+
const identity = relayIdentityOf(relay)
|
|
2061
|
+
if (identity === undefined || relay.state !== 'online') return
|
|
2062
|
+
statusSyncInFlight = true
|
|
2063
|
+
// The race guard arms HERE: only frames arriving after this point
|
|
2064
|
+
// outrank the snapshot (anything older predates the fetch and is stale
|
|
2065
|
+
// relative to it).
|
|
2066
|
+
liveStatusSeq.clear()
|
|
2067
|
+
const startSeq = ++statusSeq
|
|
2068
|
+
// The wire shape is the host descriptor's (see runSummarySync — one
|
|
2069
|
+
// strict `{ _request: {} }` parameter).
|
|
2070
|
+
void relay.invoke('session', 'list', { _request: {} }, undefined).then(
|
|
2071
|
+
(value) => {
|
|
2072
|
+
statusSyncInFlight = false
|
|
2073
|
+
if (!alive) return
|
|
2074
|
+
const rows = isPlainObject(value) && Array.isArray(value.items) ? value.items : []
|
|
2075
|
+
for (const row of rows) {
|
|
2076
|
+
if (!isPlainObject(row) || typeof row.sessionId !== 'string' || row.sessionId === '') continue
|
|
2077
|
+
// A real-time frame that landed while this fetch ran is fresher
|
|
2078
|
+
// than the snapshot — never overwrite it (T75 race guard).
|
|
2079
|
+
if ((liveStatusSeq.get(row.sessionId) ?? 0) > startSeq) continue
|
|
2080
|
+
channel.push({
|
|
2081
|
+
type: 'emit',
|
|
2082
|
+
event: 'api-session/status',
|
|
2083
|
+
args: [toVirtual(identity.serverId, row.sessionId), row.running === true],
|
|
2084
|
+
})
|
|
2085
|
+
}
|
|
2086
|
+
// The serving period that queued mid-fetch is still unserved by THIS
|
|
2087
|
+
// round (its snapshot predates it) — its own round runs now.
|
|
2088
|
+
if (pendingStatusSync) {
|
|
2089
|
+
pendingStatusSync = false
|
|
2090
|
+
runStatusSync()
|
|
2091
|
+
}
|
|
2092
|
+
},
|
|
2093
|
+
(error: unknown) => {
|
|
2094
|
+
statusSyncInFlight = false
|
|
2095
|
+
if (!alive) return
|
|
2096
|
+
recordFailure('session/list', error instanceof RelayError ? error.code : 'internal')
|
|
2097
|
+
// The queued serving period survives a failed fetch: it is still
|
|
2098
|
+
// unserved, and one retry now beats waiting for the next period
|
|
2099
|
+
// (bounded — no fetch runs while the relay is offline).
|
|
2100
|
+
if (pendingStatusSync) {
|
|
2101
|
+
pendingStatusSync = false
|
|
2102
|
+
runStatusSync()
|
|
2103
|
+
}
|
|
2104
|
+
},
|
|
2105
|
+
)
|
|
2106
|
+
}
|
|
1972
2107
|
const noteOnlineIdentity = (identity: MergerIdentity, firstOnline: boolean): void => {
|
|
1973
2108
|
const key = `${identity.serverId}\u0000${identity.serverName}`
|
|
1974
2109
|
if (!firstOnline && key === refreshKey) return
|
|
@@ -1986,6 +2121,10 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
|
|
|
1986
2121
|
clock.clearTimeout(retryTimer)
|
|
1987
2122
|
retryTimer = undefined
|
|
1988
2123
|
}
|
|
2124
|
+
// T75: a new serving period re-syncs RUNNING state too — the flips
|
|
2125
|
+
// that happened inside the offline window never traveled, so every
|
|
2126
|
+
// row's running flag is re-pushed from a fresh snapshot.
|
|
2127
|
+
runStatusSync()
|
|
1989
2128
|
}
|
|
1990
2129
|
// T63: a NEW serving period is the page-load hole closing — the UI's
|
|
1991
2130
|
// one list pull already ran (relay offline), so announce the rows now.
|
|
@@ -2079,6 +2218,12 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
|
|
|
2079
2218
|
deferredSummarySync = false
|
|
2080
2219
|
runSummarySync(pendingBatch)
|
|
2081
2220
|
}
|
|
2221
|
+
// T75: same rule for the status sync — a serving period that began
|
|
2222
|
+
// before the ready runs its re-sync now, never before the ready.
|
|
2223
|
+
if (deferredStatusSync) {
|
|
2224
|
+
deferredStatusSync = false
|
|
2225
|
+
runStatusSync()
|
|
2226
|
+
}
|
|
2082
2227
|
}
|
|
2083
2228
|
}
|
|
2084
2229
|
} catch (error) {
|
|
@@ -2127,6 +2272,16 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
|
|
|
2127
2272
|
}
|
|
2128
2273
|
const rewritten = rewriteRemoteEventFrame(frame, identity.serverId)
|
|
2129
2274
|
if (rewritten === null) continue
|
|
2275
|
+
// T75: a REAL-TIME status frame outranks any list snapshot a
|
|
2276
|
+
// status sync may still be fetching — record it (keyed by the
|
|
2277
|
+
// ORIGINAL id, the same ids the list rows carry) so the sync's
|
|
2278
|
+
// answer cannot overwrite it with stale data. Status frames
|
|
2279
|
+
// carry no eventId, so the orphan-close bookkeeping below is
|
|
2280
|
+
// untouched.
|
|
2281
|
+
if (isPlainObject(frame) && frame.type === 'emit' && frame.event === 'api-session/status') {
|
|
2282
|
+
const rowId = Array.isArray(frame.args) ? frame.args[0] : undefined
|
|
2283
|
+
if (typeof rowId === 'string' && rowId !== '') liveStatusSeq.set(rowId, ++statusSeq)
|
|
2284
|
+
}
|
|
2130
2285
|
// The orphan-close bookkeeping rides the two id-carrying frame
|
|
2131
2286
|
// types: waterfalls enter the record (once per leg — a repeat
|
|
2132
2287
|
// is not re-shown), cancels leave it.
|
package/src/merge-streams.ts
CHANGED
|
@@ -142,7 +142,7 @@
|
|
|
142
142
|
* `zr~<serverId>~provider/model` fallback string (the pre-fix2 display).
|
|
143
143
|
*/
|
|
144
144
|
|
|
145
|
-
import { fromVirtual, isVirtual, toVirtual } from './virtual-id.js'
|
|
145
|
+
import { fromVirtual, isVirtual, toVirtual, VIRTUAL_GROUP_NAME_MARK } from './virtual-id.js'
|
|
146
146
|
|
|
147
147
|
/** The (serverId, serverName) pair a merger virtualizes with. A re-handshake
|
|
148
148
|
* with a different server retargets the SAME merger ({@link retarget}) so the
|
|
@@ -1119,8 +1119,13 @@ export function mergeSessionList(localResult: unknown, remoteResult: unknown, se
|
|
|
1119
1119
|
* order, RT dsh-client-ui-model-selection lib/client.js:510 — appended
|
|
1120
1120
|
* groups render last); each group id becomes the virtual group id
|
|
1121
1121
|
* `zr~<serverId>~<original>` and its name is prefixed
|
|
1122
|
-
* `${serverName} · ${name}` — the same workspace
|
|
1123
|
-
* merger uses
|
|
1122
|
+
* `${VIRTUAL_GROUP_NAME_MARK}${serverName} · ${name}` — the same workspace
|
|
1123
|
+
* title discipline the merger uses, PLUS the invisible WORD JOINER (T74):
|
|
1124
|
+
* the model menu's groups stopped carrying their id in the markup (0.2.0's
|
|
1125
|
+
* MenuGroup renders `aria-labelledby={useId()}` only), so the browser
|
|
1126
|
+
* classifies menu groups by this heading prefix instead
|
|
1127
|
+
* (client-data/model-group-side.ts). The sidebar's workspace TITLES stay
|
|
1128
|
+
* unmarked — those groups are keyed by their `zr~` row ids;
|
|
1124
1129
|
* - `default` and `failures` stay LOCAL untouched: the default drives what a
|
|
1125
1130
|
* blank LOCAL session would run (a remote session's current model comes
|
|
1126
1131
|
* from its projection instead), and the UI renders every failure as a
|
|
@@ -1159,7 +1164,9 @@ export function mergeModelCatalogs(localValue: unknown, remoteValue: unknown, id
|
|
|
1159
1164
|
virtualGroups.push({
|
|
1160
1165
|
...group,
|
|
1161
1166
|
id,
|
|
1162
|
-
...(typeof group.name === 'string'
|
|
1167
|
+
...(typeof group.name === 'string'
|
|
1168
|
+
? { name: `${VIRTUAL_GROUP_NAME_MARK}${identity.serverName} · ${group.name}` }
|
|
1169
|
+
: {}),
|
|
1163
1170
|
})
|
|
1164
1171
|
}
|
|
1165
1172
|
if (virtualGroups.length === 0) return localValue
|
package/src/relay-access.ts
CHANGED
|
@@ -176,9 +176,12 @@ interface RelayMethod {
|
|
|
176
176
|
resultFilter?: InvokeFilter
|
|
177
177
|
/** The one EVENT-FORWARDING entry (T32, `$zr/events`): the stream route
|
|
178
178
|
* opens the gateway's `$events` wire stream (never `gw.stream`, spike §2.1
|
|
179
|
-
* 坑 1) and filters frames by the
|
|
180
|
-
*
|
|
181
|
-
*
|
|
179
|
+
* 坑 1) and filters frames per frame — waterfalls by the `agentId`, and
|
|
180
|
+
* since T75 the one forwardable emit (`api-session/status`) by the
|
|
181
|
+
* session id in `args[0]`; every other emit is server-wide state and is
|
|
182
|
+
* dropped. The answer route matches `eventId`s against what was
|
|
183
|
+
* forwarded. Like the global reads it claims no session field — access is
|
|
184
|
+
* judged per frame / per answer. */
|
|
182
185
|
events?: true
|
|
183
186
|
}
|
|
184
187
|
|