dsh-zen-remote 2.0.0 → 2.0.1

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 (43) 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/lib/client.js +255 -21
  5. package/lib/fingerprint.js +4 -2
  6. package/lib/fingerprint.js.map +1 -1
  7. package/lib/intercept.js +152 -11
  8. package/lib/intercept.js.map +1 -1
  9. package/lib/merge-streams.js +11 -4
  10. package/lib/merge-streams.js.map +1 -1
  11. package/lib/relay-access.js.map +1 -1
  12. package/lib/relay-server.js +32 -11
  13. package/lib/relay-server.js.map +1 -1
  14. package/lib/types/client/effects/model-group-side.d.ts +13 -0
  15. package/lib/types/client/effects/model-group-side.d.ts.map +1 -0
  16. package/lib/types/client/index.d.ts.map +1 -1
  17. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  18. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  19. package/lib/types/client-data/model-group-side.d.ts +44 -0
  20. package/lib/types/client-data/model-group-side.d.ts.map +1 -0
  21. package/lib/types/fingerprint.d.ts +4 -2
  22. package/lib/types/fingerprint.d.ts.map +1 -1
  23. package/lib/types/intercept.d.ts +19 -9
  24. package/lib/types/intercept.d.ts.map +1 -1
  25. package/lib/types/merge-streams.d.ts +7 -2
  26. package/lib/types/merge-streams.d.ts.map +1 -1
  27. package/lib/types/relay-access.d.ts.map +1 -1
  28. package/lib/types/relay-server.d.ts.map +1 -1
  29. package/lib/types/virtual-id.d.ts +14 -0
  30. package/lib/types/virtual-id.d.ts.map +1 -1
  31. package/lib/virtual-id.js +14 -0
  32. package/lib/virtual-id.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/client/effects/model-group-side.ts +132 -0
  35. package/src/client/index.tsx +7 -0
  36. package/src/client/styles/remote-session.css.ts +43 -20
  37. package/src/client-data/model-group-side.ts +65 -0
  38. package/src/fingerprint.ts +4 -2
  39. package/src/intercept.ts +158 -10
  40. package/src/merge-streams.ts +11 -4
  41. package/src/relay-access.ts +6 -3
  42. package/src/relay-server.ts +49 -11
  43. package/src/virtual-id.ts +15 -0
@@ -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
  }
@@ -1116,11 +1139,18 @@ export function rewriteResult(endpoint: string, value: unknown, serverId: string
1116
1139
  * whole thing in `zr~<serverId>~`.
1117
1140
  * - `cancel`: `{type, eventId}` — the correlation id goes virtual so the UI
1118
1141
  * 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
1142
+ * - `emit`: dropped, with ONE exception (T75) — a well-formed
1143
+ * `api-session/status` frame (the only emit the server forwards, and only
1144
+ * for accessible sessions) has its `args[0]` session id virtualized and
1145
+ * its `args[1]` running flag passed through, so the UI's
1146
+ * `api-session/status` listener keeps the remote session's running state
1147
+ * in step with the host's (RT dsh-api-session-controller lib/client.js
1148
+ * `handleSessionStatus`).
1149
+ * - everything else — `ready`, non-status emits, non-objects, unknown
1150
+ * types, and any frame failing the client face's exact-keys shape — is
1151
+ * DROPPED (T32-fix2 mirrors the server side). `ready` and the other emits
1152
+ * carry facts the UI must never see (the local stream opened with ITS
1153
+ * ready frame; emits broadcast server-wide state); a malformed frame that
1124
1154
  * slipped through would fail `parseRemoteEventFrame` and take the UI's
1125
1155
  * whole `$events` generation down with it, failing and reconnecting in a
1126
1156
  * loop. Mirrors the server's own forwardable-shape gate, so the two ends
@@ -1130,7 +1160,10 @@ export function rewriteRemoteEventFrame(frame: unknown, serverId: string): unkno
1130
1160
  const virtualize = (id: string): string => toVirtual(serverId, id)
1131
1161
  if (!isPlainObject(frame)) return null
1132
1162
  if (frame.type === 'ready') return null
1133
- if (frame.type === 'emit') return null
1163
+ if (frame.type === 'emit') {
1164
+ if (!isForwardableStatusEmit(frame)) return null
1165
+ return { type: 'emit', event: frame.event, args: [virtualize(frame.args[0]), frame.args[1]] }
1166
+ }
1134
1167
  if (frame.type === 'waterfall') {
1135
1168
  if (!isForwardableWaterfall(frame)) return null
1136
1169
  return { ...frame, eventId: virtualize(frame.eventId), agentId: virtualize(frame.agentId) }
@@ -1791,6 +1824,17 @@ interface MergedEventsStreamDeps {
1791
1824
  * id the server did not return is retried on a 2s/10s backoff at most
1792
1825
  * twice before it counts as asked for good (a tombstone triggers at most
1793
1826
  * three syncs), and a fresh serving period clears the bookkeeping.
1827
+ *
1828
+ * T75 adds the serving-period STATUS sync: the UI's `session.running` for a
1829
+ * remote session updates only on the forwarded `api-session/status` emit
1830
+ * (RT dsh-api-session-controller lib/client.js `handleSessionStatus`) and
1831
+ * the list summaries — so every flip inside an offline window was lost, and
1832
+ * a session that ended (or started) while the link was down kept its stale
1833
+ * badge until the next page load. Every new serving period now re-pulls the
1834
+ * list once and pushes one synthesized status frame per row (see
1835
+ * {@link runStatusSync}), guarded against overwriting a real-time frame
1836
+ * that landed mid-fetch, and gated on the local `ready` like every
1837
+ * synthesized frame.
1794
1838
  */
1795
1839
  async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator<unknown, void, undefined> {
1796
1840
  const { local, relay, signal, recordFailure, log, clock } = deps
@@ -1969,6 +2013,90 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
1969
2013
  if (!ids.some((id) => !announced.has(id) && !askedForGood.has(id) && !retryAt.has(id))) return
1970
2014
  runSummarySync(ids)
1971
2015
  }
2016
+ // T75 status-sync bookkeeping. `statusSeq` is a monotonic counter over
2017
+ // status facts; `liveStatusSeq` maps session id → the seq of the newest
2018
+ // REAL-TIME status frame the leg delivered since the in-flight fetch began
2019
+ // (cleared at each fetch start, so it only ever holds frames NEWER than
2020
+ // the snapshot being fetched — exactly the ids the snapshot must not
2021
+ // overwrite). `deferred*`/`pending*` mirror the summary sync's gates: the
2022
+ // local `ready` must come first, and a serving period that began while a
2023
+ // fetch ran gets its own round once that fetch lands.
2024
+ let statusSeq = 0
2025
+ const liveStatusSeq = new Map<string, number>()
2026
+ let statusSyncInFlight = false
2027
+ let pendingStatusSync = false
2028
+ let deferredStatusSync = false
2029
+ /**
2030
+ * T75: fetch the server's `session/list` once and push one synthesized
2031
+ * `api-session/status` emit per row (args `[virtualId, row.running]`) — a
2032
+ * NEW serving period's re-sync, because the status flips that happened
2033
+ * while the relay was offline never traveled (the forwarded status emit
2034
+ * only exists while the leg is up, and `runSummarySync`'s one-time row
2035
+ * announcement is deduped by `announced`, so the running flags the UI
2036
+ * holds would stay frozen at page-load state). Runs beside the summary
2037
+ * sync per serving period (its own fetch: the summary round is nudge-
2038
+ * driven and announce-deduped, and bolting a status mode onto it would
2039
+ * tangle both features' bookkeeping for one saved request), gated on the
2040
+ * local `ready` like every synthesized frame, and a failure is
2041
+ * diagnostics only — the next serving period retries.
2042
+ */
2043
+ const runStatusSync = (): void => {
2044
+ if (!alive) return
2045
+ if (!localOpened) {
2046
+ deferredStatusSync = true
2047
+ return
2048
+ }
2049
+ if (statusSyncInFlight) {
2050
+ pendingStatusSync = true
2051
+ return
2052
+ }
2053
+ const identity = relayIdentityOf(relay)
2054
+ if (identity === undefined || relay.state !== 'online') return
2055
+ statusSyncInFlight = true
2056
+ // The race guard arms HERE: only frames arriving after this point
2057
+ // outrank the snapshot (anything older predates the fetch and is stale
2058
+ // relative to it).
2059
+ liveStatusSeq.clear()
2060
+ const startSeq = ++statusSeq
2061
+ // The wire shape is the host descriptor's (see runSummarySync — one
2062
+ // strict `{ _request: {} }` parameter).
2063
+ void relay.invoke('session', 'list', { _request: {} }, undefined).then(
2064
+ (value) => {
2065
+ statusSyncInFlight = false
2066
+ if (!alive) return
2067
+ const rows = isPlainObject(value) && Array.isArray(value.items) ? value.items : []
2068
+ for (const row of rows) {
2069
+ if (!isPlainObject(row) || typeof row.sessionId !== 'string' || row.sessionId === '') continue
2070
+ // A real-time frame that landed while this fetch ran is fresher
2071
+ // than the snapshot — never overwrite it (T75 race guard).
2072
+ if ((liveStatusSeq.get(row.sessionId) ?? 0) > startSeq) continue
2073
+ channel.push({
2074
+ type: 'emit',
2075
+ event: 'api-session/status',
2076
+ args: [toVirtual(identity.serverId, row.sessionId), row.running === true],
2077
+ })
2078
+ }
2079
+ // The serving period that queued mid-fetch is still unserved by THIS
2080
+ // round (its snapshot predates it) — its own round runs now.
2081
+ if (pendingStatusSync) {
2082
+ pendingStatusSync = false
2083
+ runStatusSync()
2084
+ }
2085
+ },
2086
+ (error: unknown) => {
2087
+ statusSyncInFlight = false
2088
+ if (!alive) return
2089
+ recordFailure('session/list', error instanceof RelayError ? error.code : 'internal')
2090
+ // The queued serving period survives a failed fetch: it is still
2091
+ // unserved, and one retry now beats waiting for the next period
2092
+ // (bounded — no fetch runs while the relay is offline).
2093
+ if (pendingStatusSync) {
2094
+ pendingStatusSync = false
2095
+ runStatusSync()
2096
+ }
2097
+ },
2098
+ )
2099
+ }
1972
2100
  const noteOnlineIdentity = (identity: MergerIdentity, firstOnline: boolean): void => {
1973
2101
  const key = `${identity.serverId}\u0000${identity.serverName}`
1974
2102
  if (!firstOnline && key === refreshKey) return
@@ -1986,6 +2114,10 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
1986
2114
  clock.clearTimeout(retryTimer)
1987
2115
  retryTimer = undefined
1988
2116
  }
2117
+ // T75: a new serving period re-syncs RUNNING state too — the flips
2118
+ // that happened inside the offline window never traveled, so every
2119
+ // row's running flag is re-pushed from a fresh snapshot.
2120
+ runStatusSync()
1989
2121
  }
1990
2122
  // T63: a NEW serving period is the page-load hole closing — the UI's
1991
2123
  // one list pull already ran (relay offline), so announce the rows now.
@@ -2079,6 +2211,12 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
2079
2211
  deferredSummarySync = false
2080
2212
  runSummarySync(pendingBatch)
2081
2213
  }
2214
+ // T75: same rule for the status sync — a serving period that began
2215
+ // before the ready runs its re-sync now, never before the ready.
2216
+ if (deferredStatusSync) {
2217
+ deferredStatusSync = false
2218
+ runStatusSync()
2219
+ }
2082
2220
  }
2083
2221
  }
2084
2222
  } catch (error) {
@@ -2127,6 +2265,16 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
2127
2265
  }
2128
2266
  const rewritten = rewriteRemoteEventFrame(frame, identity.serverId)
2129
2267
  if (rewritten === null) continue
2268
+ // T75: a REAL-TIME status frame outranks any list snapshot a
2269
+ // status sync may still be fetching — record it (keyed by the
2270
+ // ORIGINAL id, the same ids the list rows carry) so the sync's
2271
+ // answer cannot overwrite it with stale data. Status frames
2272
+ // carry no eventId, so the orphan-close bookkeeping below is
2273
+ // untouched.
2274
+ if (isPlainObject(frame) && frame.type === 'emit' && frame.event === 'api-session/status') {
2275
+ const rowId = Array.isArray(frame.args) ? frame.args[0] : undefined
2276
+ if (typeof rowId === 'string' && rowId !== '') liveStatusSeq.set(rowId, ++statusSeq)
2277
+ }
2130
2278
  // The orphan-close bookkeeping rides the two id-carrying frame
2131
2279
  // types: waterfalls enter the record (once per leg — a repeat
2132
2280
  // 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
 
@@ -399,6 +399,21 @@ interface ForwardableCancelFrame extends Record<string, unknown> {
399
399
  eventId: string
400
400
  }
401
401
 
402
+ /**
403
+ * T75: the ONE emit shape the relay forwards — a single session's
404
+ * running-state flip (`api-session/status`), exactly what the host emits on
405
+ * `agent/status` (RT dsh-api-session-controller lib/index.js:2884-2886:
406
+ * `ctx.emit("api-session/status", agent.id, status === "running")`), carried
407
+ * on the wire as the client face's exact-keys emit with a JSON-array `args`
408
+ * (RT dsh-api-gateway lib/index.js:846-853 builds `{type, event, args}`;
409
+ * lib/client.js `parseRemoteEventFrame` demands that shape back).
410
+ */
411
+ interface ForwardableStatusEmitFrame extends Record<string, unknown> {
412
+ type: 'emit'
413
+ event: 'api-session/status'
414
+ args: [string, boolean]
415
+ }
416
+
402
417
  function isForwardableWaterfall(frame: Record<string, unknown>): frame is ForwardableWaterfallFrame {
403
418
  return (
404
419
  Reflect.ownKeys(frame).length === 5 &&
@@ -421,6 +436,18 @@ function isForwardableCancel(frame: Record<string, unknown>): frame is Forwardab
421
436
  )
422
437
  }
423
438
 
439
+ function isForwardableStatusEmit(frame: Record<string, unknown>): frame is ForwardableStatusEmitFrame {
440
+ return (
441
+ Reflect.ownKeys(frame).length === 3 &&
442
+ ['type', 'event', 'args'].every((key) => Object.hasOwn(frame, key)) &&
443
+ frame.event === 'api-session/status' &&
444
+ Array.isArray(frame.args) &&
445
+ frame.args.length === 2 &&
446
+ isEventId(frame.args[0]) &&
447
+ typeof frame.args[1] === 'boolean'
448
+ )
449
+ }
450
+
424
451
  /**
425
452
  * Constant-time secret comparison. Different lengths are trivially unequal
426
453
  * (timingSafeEqual itself throws on that); an empty configured secret refuses
@@ -867,17 +894,22 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
867
894
  * - CANCEL frames are forwarded only for events this subscription
868
895
  * forwarded (an unshared session's activity timeline must not leak
869
896
  * through cancels), and the entry goes with the forward.
870
- * - EMIT frames are dropped entirely (T32-fix): they carry server-wide
871
- * state (`api-session/added` summaries and titles, account expirations,
872
- * cordis chatter) that is not share-scoped — forwarding them would leak
873
- * unshared sessions and feed the sub-client ids it would treat as
874
- * local. Everything a sub-client needs about a session's state travels
875
- * the workspace/control streams, which ARE share-filtered.
876
- * - Anything not shaped exactly like a forwardable waterfall or cancel
877
- * (T32-fix2, mirroring the client face's `parseRemoteEventFrame`
878
- * exact-keys validation) is dropped too: a frame that slipped through
879
- * would fail the sub-client's whole `$events` generation and leave it
880
- * failing and reconnecting in a loop.
897
+ * - EMIT frames are dropped, with ONE exception (T75): a
898
+ * `api-session/status` emit whose `args[0]` names an accessible session
899
+ * crosses as a fresh `{type, event, args:[id, running]}` — it is the
900
+ * only share-SCOPED emit the host produces (one session's running flip,
901
+ * judged per frame by `isAccessible` like a waterfall's `agentId`).
902
+ * Every other emit is server-wide state (`api-session/added` summaries
903
+ * and titles, account expirations, cordis chatter) that is not
904
+ * share-scoped: forwarding it would leak unshared sessions and feed the
905
+ * sub-client ids it would treat as local. Everything else a sub-client
906
+ * needs about a session's state travels the workspace/control streams,
907
+ * which ARE share-filtered.
908
+ * - Anything not shaped exactly like a forwardable waterfall, cancel, or
909
+ * status emit (T32-fix2, mirroring the client face's
910
+ * `parseRemoteEventFrame` exact-keys validation) is dropped too: a frame
911
+ * that slipped through would fail the sub-client's whole `$events`
912
+ * generation and leave it failing and reconnecting in a loop.
881
913
  *
882
914
  * DROPPED DELIVERIES STAY PENDING (T32-fix2): a dropped waterfall used to
883
915
  * be abstained `next` on behalf. That is gone on purpose. DSH's `next`
@@ -917,6 +949,12 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
917
949
  sub.events.delete(frame.eventId)
918
950
  return { ...frame, eventId: `${sub.token}.${frame.eventId}` }
919
951
  }
952
+ if (frame.type === 'emit') {
953
+ // T75: the one share-scoped emit. A NEW frame with a fresh args array
954
+ // leaves — never the host's object itself.
955
+ if (!isForwardableStatusEmit(frame) || !isAccessible(frame.args[0])) return null
956
+ return { type: 'emit', event: frame.event, args: [frame.args[0], frame.args[1]] }
957
+ }
920
958
  return null
921
959
  }
922
960
 
package/src/virtual-id.ts CHANGED
@@ -20,6 +20,21 @@
20
20
  /** The fixed prefix every virtual id starts with. */
21
21
  export const VIRTUAL_ID_PREFIX = 'zr~'
22
22
 
23
+ /**
24
+ * The invisible WORD JOINER (U+2060) every virtual GROUP NAME starts with
25
+ * (T74). DSH 0.2.0 renders the model menu's provider groups with the
26
+ * primitives' MenuGroup, whose `aria-labelledby` carries only a useId — the
27
+ * group id is gone from the markup — so the browser half can no longer tell
28
+ * a virtual group from a local one by attribute. The merged catalog's group
29
+ * NAME (merge-streams.ts mergeModelCatalogs) is the one mark that survives
30
+ * in both 0.1.7 and 0.2.0, and effects/model-group-side.ts classifies each
31
+ * group container by which headings start with it. Zero-width: headings
32
+ * read exactly as before. Sidebar workspace TITLES deliberately do NOT
33
+ * carry it — those groups are told apart by their `zr~` row ids, not by
34
+ * text.
35
+ */
36
+ export const VIRTUAL_GROUP_NAME_MARK = '\u2060'
37
+
23
38
  /** The server id half: exactly the 8 lowercase hex chars relay-server.ts
24
39
  * mints (`randomBytes(4).toString('hex')`). */
25
40
  const SERVER_ID_PATTERN = /^[0-9a-f]{8}$/