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.
Files changed (50) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/docs/remote-access.md +2 -2
  4. package/lan-gate.mjs +15 -1
  5. package/lib/client.js +271 -30
  6. package/lib/fingerprint.js +4 -2
  7. package/lib/fingerprint.js.map +1 -1
  8. package/lib/intercept.js +161 -14
  9. package/lib/intercept.js.map +1 -1
  10. package/lib/merge-streams.js +11 -4
  11. package/lib/merge-streams.js.map +1 -1
  12. package/lib/relay-access.js.map +1 -1
  13. package/lib/relay-server.js +32 -11
  14. package/lib/relay-server.js.map +1 -1
  15. package/lib/types/client/effects/model-group-side.d.ts +13 -0
  16. package/lib/types/client/effects/model-group-side.d.ts.map +1 -0
  17. package/lib/types/client/index.d.ts.map +1 -1
  18. package/lib/types/client/styles/compat.css.d.ts +1 -1
  19. package/lib/types/client/styles/compat.css.d.ts.map +1 -1
  20. package/lib/types/client/styles/composer.css.d.ts +1 -1
  21. package/lib/types/client/styles/composer.css.d.ts.map +1 -1
  22. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  23. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  24. package/lib/types/client-data/model-group-side.d.ts +44 -0
  25. package/lib/types/client-data/model-group-side.d.ts.map +1 -0
  26. package/lib/types/fingerprint.d.ts +4 -2
  27. package/lib/types/fingerprint.d.ts.map +1 -1
  28. package/lib/types/intercept.d.ts +19 -9
  29. package/lib/types/intercept.d.ts.map +1 -1
  30. package/lib/types/merge-streams.d.ts +7 -2
  31. package/lib/types/merge-streams.d.ts.map +1 -1
  32. package/lib/types/relay-access.d.ts.map +1 -1
  33. package/lib/types/relay-server.d.ts.map +1 -1
  34. package/lib/types/virtual-id.d.ts +14 -0
  35. package/lib/types/virtual-id.d.ts.map +1 -1
  36. package/lib/virtual-id.js +14 -0
  37. package/lib/virtual-id.js.map +1 -1
  38. package/package.json +48 -49
  39. package/src/client/effects/model-group-side.ts +132 -0
  40. package/src/client/index.tsx +7 -0
  41. package/src/client/styles/compat.css.ts +14 -8
  42. package/src/client/styles/composer.css.ts +2 -1
  43. package/src/client/styles/remote-session.css.ts +43 -20
  44. package/src/client-data/model-group-side.ts +65 -0
  45. package/src/fingerprint.ts +4 -2
  46. package/src/intercept.ts +167 -12
  47. package/src/merge-streams.ts +11 -4
  48. package/src/relay-access.ts +6 -3
  49. package/src/relay-server.ts +49 -11
  50. package/src/virtual-id.ts +15 -0
@@ -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 [data-slot="conversation.composer.dock"] > [class$="_root"]). Layout: ONE fixed-height (28px) flex
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
- [data-slot="conversation.composer.dock"] > [class$="_root"] {
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"] > [class$="_root"]::-webkit-scrollbar {
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"] > [class$="_root"]::-webkit-scrollbar-thumb {
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"] > [class$="_root"]::-webkit-scrollbar-track {
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"] > [class$="_root"] > * {
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"] > [class$="_root"] > *:last-child {
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"] > [class$="_root"] * {
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, by the one
171
- stable mark the menu carries:
172
- - the model menu renders one section[role="group"] per provider group
173
- with aria-labelledby="<useId>-<group.id>" (RT dsh-client-ui-model-
174
- selection lib/client.js:830-835), so a VIRTUAL group's section carries
175
- the "zr~" prefix in that attribute — an attribute the component itself
176
- writes, like data-open-target above;
177
- - the menu is a portal under <body>, but <html> is still its ancestor,
178
- so the html-level session attribute scopes it with no host-depth
179
- assumption and no :has();
180
- - the [role="menu"] scope keeps the rule inside dropdown menus (where the
181
- model picker lives); if DSH ever drops the aria mark the rule simply
182
- stops matching — the interceptor's refusals remain the backstop.
183
- In a remote session only the server's groups stay; otherwise only the
184
- local ones (the home page and dialogs read as local, which is right:
185
- the model picker only mounts inside a session, and a blank local
186
- session cannot run server models either). */
187
- html[data-zr-remote-session="1"] [role="menu"] section[role="group"]:not([aria-labelledby*="zr~"]) {
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]) [role="menu"] section[role="group"][aria-labelledby*="zr~"] {
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
+ }
@@ -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 0.2.0
12
- * share byte-identical remote interfaces, so a version gate would misreject).
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: the client face of the
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
- * and emit events broadcast server-wide state the UI must not mistake for
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
- if (!Object.hasOwn(values, 'modelSelection')) return value
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 { ...value, values }
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
- * - everything else — `ready`, `emit`, non-objects, unknown types, and any
1120
- * waterfall/cancel failing the client face's exact-keys shape — is
1121
- * DROPPED (T32-fix2 mirrors the server side). `ready` and `emit` carry
1122
- * facts the UI must never see (the local stream opened with ITS ready
1123
- * frame; emit broadcasts server-wide state); a malformed frame that
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') return null
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.
@@ -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 title discipline the
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' ? { name: `${identity.serverName} · ${group.name}` } : {}),
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
@@ -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 waterfall's `agentId`; the answer route
180
- * matches `eventId`s against what was forwarded. Like the global reads it
181
- * claims no session field — access is judged per frame / per answer. */
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