dsh-zen-remote 2.3.3 → 2.3.5

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 (76) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/docs/plugin-bridge.md +14 -7
  4. package/docs/remote-access.en.md +8 -8
  5. package/docs/remote-access.md +10 -10
  6. package/lib/adapters/dsh-better-sidebar/index.js +21 -8
  7. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  8. package/lib/adapters/dsh-better-sidebar/surface.js +79 -0
  9. package/lib/adapters/dsh-better-sidebar/surface.js.map +1 -0
  10. package/lib/client.js +102 -30
  11. package/lib/index.js +103 -10
  12. package/lib/index.js.map +1 -1
  13. package/lib/intercept.js +23 -1
  14. package/lib/intercept.js.map +1 -1
  15. package/lib/merge-streams.js +29 -2
  16. package/lib/merge-streams.js.map +1 -1
  17. package/lib/plugin-adapter-core.js +69 -13
  18. package/lib/plugin-adapter-core.js.map +1 -1
  19. package/lib/plugin-bridge.js +179 -12
  20. package/lib/plugin-bridge.js.map +1 -1
  21. package/lib/plugin-route-intercept.js +631 -79
  22. package/lib/plugin-route-intercept.js.map +1 -1
  23. package/lib/plugin-tunnel.js +84 -12
  24. package/lib/plugin-tunnel.js.map +1 -1
  25. package/lib/relay-server.js +65 -47
  26. package/lib/relay-server.js.map +1 -1
  27. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  28. package/lib/types/adapters/dsh-better-sidebar/surface.d.ts +23 -0
  29. package/lib/types/adapters/dsh-better-sidebar/surface.d.ts.map +1 -0
  30. package/lib/types/adapters/types.d.ts +14 -3
  31. package/lib/types/adapters/types.d.ts.map +1 -1
  32. package/lib/types/client/effects/turn-fold.d.ts +42 -0
  33. package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
  34. package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -1
  35. package/lib/types/index.d.ts +2 -2
  36. package/lib/types/index.d.ts.map +1 -1
  37. package/lib/types/intercept.d.ts +19 -0
  38. package/lib/types/intercept.d.ts.map +1 -1
  39. package/lib/types/merge-streams.d.ts +12 -0
  40. package/lib/types/merge-streams.d.ts.map +1 -1
  41. package/lib/types/plugin-adapter-core.d.ts +24 -11
  42. package/lib/types/plugin-adapter-core.d.ts.map +1 -1
  43. package/lib/types/plugin-bridge.d.ts +93 -5
  44. package/lib/types/plugin-bridge.d.ts.map +1 -1
  45. package/lib/types/plugin-route-intercept.d.ts +52 -3
  46. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  47. package/lib/types/plugin-tunnel.d.ts +27 -3
  48. package/lib/types/plugin-tunnel.d.ts.map +1 -1
  49. package/lib/types/relay-server.d.ts +2 -25
  50. package/lib/types/relay-server.d.ts.map +1 -1
  51. package/lib/types/upload-limits.d.ts +67 -0
  52. package/lib/types/upload-limits.d.ts.map +1 -0
  53. package/lib/types/wizard/proxy-snippets.d.ts +4 -3
  54. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
  55. package/lib/upload-limits.js +67 -0
  56. package/lib/upload-limits.js.map +1 -0
  57. package/lib/wizard/proxy-snippets.js +5 -4
  58. package/lib/wizard/proxy-snippets.js.map +1 -1
  59. package/package.json +38 -38
  60. package/scripts/check-upload-limits.mjs +167 -0
  61. package/src/adapters/dsh-better-sidebar/index.ts +21 -9
  62. package/src/adapters/dsh-better-sidebar/surface.ts +90 -0
  63. package/src/adapters/types.ts +14 -3
  64. package/src/client/effects/turn-fold.ts +97 -26
  65. package/src/client/styles/turn-fold.css.ts +9 -0
  66. package/src/client-data/wizard.ts +1 -1
  67. package/src/index.ts +99 -12
  68. package/src/intercept.ts +47 -1
  69. package/src/merge-streams.ts +41 -1
  70. package/src/plugin-adapter-core.ts +69 -14
  71. package/src/plugin-bridge.ts +228 -14
  72. package/src/plugin-route-intercept.ts +677 -82
  73. package/src/plugin-tunnel.ts +95 -13
  74. package/src/relay-server.ts +60 -48
  75. package/src/upload-limits.ts +69 -0
  76. package/src/wizard/proxy-snippets.ts +5 -4
@@ -111,39 +111,97 @@ const ACTIVE_ATTR = 'data-mnav-fold-on'
111
111
  /** Selector of the one row kind that can fold whole — see {@link foldsWholeRow}. */
112
112
  const STEP_SELECTOR = '[data-chat-flow-kind="assistant-step"]'
113
113
 
114
+ /** Elements that show something without a text node of their own. Every other
115
+ * element is a wrapper: it is content only if something inside it is. */
116
+ const SELF_VISIBLE = new Set([
117
+ 'AUDIO', 'CANVAS', 'EMBED', 'HR', 'IFRAME', 'IMG', 'INPUT', 'OBJECT',
118
+ 'PICTURE', 'SELECT', 'SVG', 'TEXTAREA', 'VIDEO',
119
+ ])
120
+
121
+ /** The DOM surface {@link hasContentOutside} reads — kept this narrow so the
122
+ * verdict is testable off-DOM (test/turn-fold-blank-step.test.cjs). */
123
+ export interface ContentNode {
124
+ readonly nodeType: number
125
+ readonly textContent: string | null
126
+ readonly childNodes: ArrayLike<ContentNode>
127
+ readonly tagName?: string
128
+ matches?(selector: string): boolean
129
+ }
130
+
131
+ /**
132
+ * True when `node` renders anything a reader could see outside the subtrees
133
+ * that match `skip`: a non-blank text node, or a {@link SELF_VISIBLE} element.
134
+ * Structural, no class names — same discipline as the rest of this effect.
135
+ */
136
+ export function hasContentOutside(node: ContentNode, skip: string | null): boolean {
137
+ if (node.nodeType === 3) return (node.textContent ?? '').trim() !== ''
138
+ if (node.nodeType !== 1) return false
139
+ if (skip !== null && node.matches?.(skip) === true) return false
140
+ if (SELF_VISIBLE.has((node.tagName ?? '').toUpperCase())) return true
141
+ const children = node.childNodes
142
+ for (let i = 0; i < children.length; i++) {
143
+ const child = children[i]
144
+ if (child !== undefined && hasContentOutside(child, skip)) return true
145
+ }
146
+ return false
147
+ }
148
+
114
149
  /**
115
150
  * True when the reasoning rows are the ONLY thing this row renders, so the
116
151
  * whole flow item has to go: the flow column is `display: flex` with
117
152
  * `gap: 16px`, and an emptied-out flex item still claims its gap — the
118
153
  * reader would see the folded steps as a column of blank bands.
119
- * Structural test, no class names: every ancestor from the reasoning rows'
120
- * shared parent up to the flow item must be an only child.
154
+ * Structural test, no class names: outside the reasoning rows the row holds
155
+ * nothing visible — only wrappers. Each reasoning block sits in its own
156
+ * ProcessReasoning wrapper, and since DSH 0.2.1-rc.5 a block whose display
157
+ * text is empty once a leading `<signature>…</signature>` is stripped renders
158
+ * its ReasoningRow as null and leaves that wrapper empty; an empty wrapper
159
+ * beside a Think must not keep the row unfolded.
121
160
  */
122
- function foldsWholeRow(row: Element, thinks: readonly Element[]): boolean {
123
- const first = thinks[0]
124
- if (first === undefined) return false
125
- const body = first.parentElement
126
- if (body === null || body.childElementCount !== thinks.length) return false
127
- let node: Element = body
128
- while (node !== row) {
129
- const parent = node.parentElement
130
- if (parent === null) return false
131
- if (parent.childElementCount !== 1) return false
132
- node = parent
133
- }
134
- return true
161
+ export function foldsWholeRow(row: ContentNode, thinks: readonly unknown[]): boolean {
162
+ if (thinks.length === 0) return false
163
+ return !hasContentOutside(row, THINK)
135
164
  }
136
165
 
137
- /** The process items one flow row contributes: the row itself, its reasoning
138
- * rows, or nothing at all. */
139
- function processItems(row: Element): readonly Element[] {
166
+ /**
167
+ * True for an assistant-step row that renders nothing at all — no Think, no
168
+ * prose, no image, no notice. DSH 0.2.1-rc.5 produces one for a step whose
169
+ * only reasoning is a `<signature>…</signature>` (ReasoningRow hides itself,
170
+ * the wrappers stay). It is not process to count in the chip, and it is not
171
+ * content either: left alone it is a blank band in the flow (the gap again),
172
+ * so it is hidden on its own marker ({@link BLANK}) that the chip's open
173
+ * override never releases. On 0.2.1-rc.2, which renders such a block as an
174
+ * ordinary Think, the row is not blank and folds exactly as before.
175
+ */
176
+ export function isBlankStep(row: ContentNode): boolean {
177
+ return !hasContentOutside(row, null)
178
+ }
179
+
180
+ /** Marker of a {@link isBlankStep} row; the stylesheet hides it while the
181
+ * fold is live. Never counted, never opened. */
182
+ export const BLANK = 'data-mnav-blank'
183
+
184
+ /** Hold {@link BLANK} on an assistant-step row exactly while it is
185
+ * {@link isBlankStep} — set the moment it empties, dropped the moment
186
+ * anything (a Think, prose, an image) arrives in it. */
187
+ function markBlank(row: Element, thinks: readonly Element[]): void {
188
+ if (thinks.length === 0 && isBlankStep(row)) row.setAttribute(BLANK, '')
189
+ else row.removeAttribute(BLANK)
190
+ }
191
+
192
+ /** The process items one flow row contributes — the row itself, its reasoning
193
+ * rows, or nothing at all — and how many steps they are on the chip: a row
194
+ * folded whole still counts each reasoning block it holds, so the count does
195
+ * not depend on how the row had to be hidden. */
196
+ function processItems(row: Element): { readonly items: readonly Element[], readonly steps: number } {
140
197
  const kind = row.getAttribute('data-chat-flow-kind')
141
- if (kind === null) return []
142
- if (PROCESS_KIND_SET.has(kind)) return [row]
143
- if (kind !== 'assistant-step') return []
198
+ if (kind === null) return { items: [], steps: 0 }
199
+ if (PROCESS_KIND_SET.has(kind)) return { items: [row], steps: 1 }
200
+ if (kind !== 'assistant-step') return { items: [], steps: 0 }
144
201
  const thinks = [...row.querySelectorAll(THINK)]
145
- if (thinks.length === 0) return []
146
- return foldsWholeRow(row, thinks) ? [row] : thinks
202
+ markBlank(row, thinks)
203
+ if (thinks.length === 0) return { items: [], steps: 0 }
204
+ return { items: foldsWholeRow(row, thinks) ? [row] : thinks, steps: thinks.length }
147
205
  }
148
206
 
149
207
  /**
@@ -170,6 +228,8 @@ interface Group {
170
228
  readonly key: string
171
229
  /** Every process element of this turn, in flow order. */
172
230
  readonly items: Element[]
231
+ /** Steps the chip announces (see {@link processItems}). */
232
+ steps: number
173
233
  }
174
234
 
175
235
  /** Split the flow column into turns: a group runs from one user/steering row
@@ -183,10 +243,12 @@ function groupsOf(flow: Element): readonly Group[] {
183
243
  const kind = row.getAttribute('data-chat-flow-kind')
184
244
  if (kind === null) continue
185
245
  if (current === undefined || TURN_HEAD_KINDS.has(kind)) {
186
- current = { key: row.getAttribute('data-chat-flow-key') ?? `pos${groups.length}`, items: [] }
246
+ current = { key: row.getAttribute('data-chat-flow-key') ?? `pos${groups.length}`, items: [], steps: 0 }
187
247
  groups.push(current)
188
248
  }
189
- current.items.push(...processItems(row))
249
+ const { items, steps } = processItems(row)
250
+ current.items.push(...items)
251
+ current.steps += steps
190
252
  }
191
253
  return groups
192
254
  }
@@ -279,7 +341,7 @@ export function installTurnFold(ctx: ClientContext): void {
279
341
  const groupRunning = running && index === groups.length - 1
280
342
  if (groupRunning) chip.dataset['running'] = ''
281
343
  else delete chip.dataset['running']
282
- label(chip, group.items.length, groupRunning)
344
+ label(chip, group.steps, groupRunning)
283
345
  live.add(group.key)
284
346
  })
285
347
 
@@ -302,6 +364,11 @@ export function installTurnFold(ctx: ClientContext): void {
302
364
  stale.removeAttribute(FOLD)
303
365
  stale.removeAttribute(OPEN)
304
366
  }
367
+ // {@link BLANK} is held by groupsOf on every assistant-step row of this
368
+ // column; anything else carrying it left the column (or changed kind).
369
+ for (const stale of flow.querySelectorAll(`[${BLANK}]`)) {
370
+ if (stale.parentElement !== flow || !stale.matches(STEP_SELECTOR)) stale.removeAttribute(BLANK)
371
+ }
305
372
  }
306
373
 
307
374
  /**
@@ -359,6 +426,9 @@ export function installTurnFold(ctx: ClientContext): void {
359
426
  const thinks = [...row.querySelectorAll(THINK)]
360
427
  if (thinks.length > 0 && foldsWholeRow(row, thinks)) row.setAttribute(FOLD, '')
361
428
  else row.removeAttribute(FOLD)
429
+ // Same frame for the blank row (see {@link isBlankStep}): hidden
430
+ // before it paints its band, shown before its first content paints.
431
+ markBlank(row, thinks)
362
432
  }
363
433
  }
364
434
 
@@ -396,6 +466,7 @@ export function installTurnFold(ctx: ClientContext): void {
396
466
  item.removeAttribute(FOLD)
397
467
  item.removeAttribute(OPEN)
398
468
  }
469
+ for (const item of document.querySelectorAll(`[${BLANK}]`)) item.removeAttribute(BLANK)
399
470
  }
400
471
 
401
472
  const attach = (): void => {
@@ -83,6 +83,15 @@ const rules = (scope: string): string => `
83
83
  display: none !important;
84
84
  }
85
85
 
86
+ /* An assistant-step row that renders nothing at all (effects/turn-fold.ts
87
+ isBlankStep — DSH 0.2.1-rc.5 leaves one behind for a step whose only
88
+ reasoning is a <signature>…</signature>). Not a step: never counted on
89
+ the chip and never released by the open override, it would only ever
90
+ paint the flow column's 16px gap as a blank band. Literal mirror of BLANK in effects/turn-fold.ts. */
91
+ ${scope} [data-chat-flow] > [data-mnav-blank] {
92
+ display: none !important;
93
+ }
94
+
86
95
  /* Compact chip, sized like the composer's own pills rather than a button:
87
96
  it is a reading affordance in the middle of the message flow, so it has
88
97
  to stay quieter than the content around it. The flow column is a flex
@@ -401,7 +401,7 @@ export function nginxSnippetText(input: SnippetInput): string | null {
401
401
  ' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;',
402
402
  ' proxy_set_header X-Forwarded-Proto $scheme;',
403
403
  ' proxy_buffering off;',
404
- ' client_max_body_size 128m;',
404
+ ' client_max_body_size 512m;',
405
405
  ' proxy_read_timeout 3600s;',
406
406
  ' proxy_send_timeout 3600s;',
407
407
  ' }',
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { createOwnerPeerSource, createSharedApiFetchGetter } from './owner-peer.js'
19
+ import { readFileSync } from 'node:fs'
19
20
  import { lstat, mkdir, open, realpath, rename, rm, writeFile } from 'node:fs/promises'
20
21
  import type { FileHandle } from 'node:fs/promises'
21
22
  import type { IncomingMessage, ServerResponse } from 'node:http'
@@ -55,7 +56,7 @@ import { createRelayClient, RelayError, volatileUpdateTouchesServerName } from '
55
56
  import type { RelayClient } from './relay-client.js'
56
57
  import { computeFingerprints } from './fingerprint.js'
57
58
  import { runSelfCheck, installIntercept } from './intercept.js'
58
- import type { CurrentSessionRead, InterceptDiagnostics, InterceptHandle } from './intercept.js'
59
+ import type { CurrentSessionRead, InterceptDiagnostics, InterceptHandle, SessionInventorySlot } from './intercept.js'
59
60
  import { checkGatewayShape } from './intercept-shape.js'
60
61
  import type { GatewayShapeCheck } from './intercept-shape.js'
61
62
  import { checkFetchRouteShape, installFetchRouteIntercept } from './fetch-route-intercept.js'
@@ -65,6 +66,12 @@ import type { PluginRouteInterceptDiagnostics, PluginRouteInterceptHandle, WebSe
65
66
  import { normalizeAdapter, adapterOwnsRegistryEntry } from './plugin-adapter-core.js'
66
67
  import type { NormalizedAdapter } from './plugin-adapter-core.js'
67
68
  import { ADAPTERS } from './adapters/index.js'
69
+ // PB-19: the better-sidebar surface extractor, a PURE utility inside the
70
+ // adapters directory — the one subdirectory import the wiring makes besides
71
+ // the ADAPTERS array (test/adapters-pluggable.test.cjs pins both to this
72
+ // file). The fs read that feeds it also lives HERE: nothing inside
73
+ // src/adapters/ touches the disk.
74
+ import { betterSidebarSurfaceDigest, extractBetterSidebarSurface, surfaceLooksHealthy } from './adapters/dsh-better-sidebar/surface.js'
68
75
  import { createRelayHandler, loadServerId, RELAY_PREFIX, resolveDshVersion } from './relay-server.js'
69
76
  import type { RelayGateway, RelayHandler } from './relay-server.js'
70
77
  import { createBridgeRegistry, compareBridges } from './plugin-bridge.js'
@@ -80,6 +87,7 @@ import { createVerificationManager } from './wizard/verify.js'
80
87
  import type { BridgeDeclaration, BridgeRegistry, BridgeStatus, ServerPluginEntry, ZenRemoteService } from './plugin-bridge.js'
81
88
  import { createPluginLoopback } from './plugin-loopback.js'
82
89
  import { createPluginTunnel } from './plugin-tunnel.js'
90
+ import { DEFAULT_MAX_UPLOAD_BYTES } from './upload-limits.js'
83
91
 
84
92
  // The plugin bridge's public types (PB-1), re-exported so another plugin's
85
93
  // author can `import type { … } from 'dsh-zen-remote'` without reaching into
@@ -127,8 +135,11 @@ export const CLIENT_CONFIG_ROUTE = '/_dsh/mobile-nav/client-config'
127
135
  /** Workspace-relative directory uploads land in (also the `@` prefix the composer inserts). */
128
136
  export const UPLOAD_DIR = '.dsh-uploads'
129
137
 
130
- /** Body cap when the plugin row sets no `maxUploadBytes`. */
131
- export const DEFAULT_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
138
+ // The phone composer's default body cap lives in upload-limits.ts with the
139
+ // plugin's other upload numbers (the comment there spells out why this must
140
+ // stay phone-only and never size the relay); re-exported so the main entry's
141
+ // public surface keeps the name.
142
+ export { DEFAULT_MAX_UPLOAD_BYTES }
132
143
 
133
144
  /** Longest filename, in bytes, that survives sanitization (ext4/APFS leaf limit is 255). */
134
145
  const MAX_NAME_BYTES = 180
@@ -588,12 +599,18 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
588
599
  // (`packageOf(name, ctx.baseUrl)` — verified on 0.2.0-rc.2 to answer
589
600
  // `{name, version, dir, …}` or undefined; any other shape, a missing
590
601
  // service or a probe failure reads as "not installed" and the adapter
591
- // stays out). A plugin that registers its OWN cooperative declaration
592
- // always wins: the adapter yields at registration time (logged), and the
593
- // runtime re-checks the registry digest on every request. Both roles run
594
- // this loop — the registry entries drive the handshake table, the bridge
595
- // verdicts and the settings listing on either side, while the returned
596
- // array feeds the two request halves.
602
+ // stays out). PB-19 exception: a version OUTSIDE the list still registers
603
+ // when the installed build's wire-surface digest (extracted here from its
604
+ // lib/index.js, the wiring being the only place the feature touches the
605
+ // disk) is whitelisted in the adapter's `surfaces` table — a wire-identical
606
+ // patch release bridges without a zen-remote release, and every failure of
607
+ // the extraction is logged apart from the digest-mismatch refusal. A plugin
608
+ // that registers its OWN cooperative declaration always wins: the adapter
609
+ // yields at registration time (logged), and the runtime re-checks the
610
+ // registry digest on every request. Both roles run this loop — the registry
611
+ // entries drive the handshake table, the bridge verdicts and the settings
612
+ // listing on either side, while the returned array feeds the two request
613
+ // halves.
597
614
  const adapters: NormalizedAdapter[] = []
598
615
  {
599
616
  const adapterWarn = (text: string): void => { ctx.logger?.warn('dsh-zen-remote adapter: %s', text) }
@@ -601,7 +618,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
601
618
  try {
602
619
  const holder = ctx as unknown as { get?: (name: string) => unknown; baseUrl?: string }
603
620
  const packages = holder.get?.('pluginPackages') as { packageOf?: (specifier: string, parentURL: string) => unknown } | undefined
604
- const found = packages?.packageOf?.(adapter.plugin, holder.baseUrl ?? '') as { version?: unknown } | undefined
621
+ const found = packages?.packageOf?.(adapter.plugin, holder.baseUrl ?? '') as { version?: unknown; dir?: unknown } | undefined
605
622
  const installedVersion = found !== null && typeof found === 'object' && typeof (found as { version?: unknown }).version === 'string'
606
623
  ? (found as { version: string }).version
607
624
  : undefined
@@ -609,14 +626,44 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
609
626
  adapterWarn(`${adapter.plugin}: target package (or its version) not found; not registered`)
610
627
  continue
611
628
  }
612
- const normalized = normalizeAdapter(adapter, installedVersion)
629
+ // PB-19: a version outside the adapter's list gets ONE more chance —
630
+ // its wire surface. Extract it, hash it, and let normalizeAdapter
631
+ // judge the digest against the adapter's `surfaces` table. Anything
632
+ // that goes wrong here leaves the digest undefined — the core then
633
+ // refuses as a plain unlisted version, and the warns below name WHY
634
+ // no digest was offered (no dir, unreadable file, lost dispatch
635
+ // table), distinct from a digest that simply missed.
636
+ const unlisted = !adapter.versions.includes(installedVersion)
637
+ let surfaceDigest: string | undefined
638
+ if (unlisted) {
639
+ const dir = typeof found?.dir === 'string' && found.dir !== '' ? found.dir : undefined
640
+ if (dir === undefined) {
641
+ adapterWarn(`${adapter.plugin}: version ${installedVersion} is unlisted and packageOf gave no dir; no surface digest to offer`)
642
+ } else {
643
+ try {
644
+ const surface = extractBetterSidebarSurface(readFileSync(join(dir, 'lib/index.js'), 'utf8'))
645
+ if (!surfaceLooksHealthy(surface)) {
646
+ adapterWarn(`${adapter.plugin}: surface extractor lost the dispatch table (${surface.methods.length} methods) — treating as unknown surface`)
647
+ } else {
648
+ surfaceDigest = betterSidebarSurfaceDigest(surface)
649
+ }
650
+ } catch (error) {
651
+ adapterWarn(`${adapter.plugin}: cannot read ${join(dir, 'lib/index.js')} for a surface digest: ${error instanceof Error ? error.message : String(error)}`)
652
+ }
653
+ }
654
+ }
655
+ const normalized = normalizeAdapter(adapter, installedVersion, surfaceDigest === undefined ? undefined : { surfaceDigest })
613
656
  if (registry.get(normalized.plugin) !== undefined) {
614
657
  adapterWarn(`${normalized.plugin}: the plugin registers its own bridge declaration; the adapter yields`)
615
658
  continue
616
659
  }
617
660
  registry.registerNormalized(normalized.declaration)
618
661
  adapters.push(normalized)
619
- ctx.logger?.info('dsh-zen-remote adapter: %s adapted at version %s', normalized.plugin, installedVersion)
662
+ if (unlisted && surfaceDigest !== undefined) {
663
+ ctx.logger?.info('dsh-zen-remote adapter: %s bridged by surface digest %s (version %s unlisted)', normalized.plugin, surfaceDigest, installedVersion)
664
+ } else {
665
+ ctx.logger?.info('dsh-zen-remote adapter: %s adapted at version %s', normalized.plugin, installedVersion)
666
+ }
620
667
  } catch (error) {
621
668
  adapterWarn(`${adapter.plugin} refused: ${error instanceof Error ? error.message : String(error)}`)
622
669
  }
@@ -1301,6 +1348,39 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1301
1348
  // carries nothing new costs one compare.
1302
1349
  interceptHandle?.currentSessionChanged()
1303
1350
  }
1351
+ // PB-25b: the KNOWN-real-session inventory — the union of the merged
1352
+ // global streams' session snapshots (the workspace merger's sessionHome:
1353
+ // every real id the sidebar currently holds as a `zr~` row). Each merged
1354
+ // stream owns one slot; a report REPLACES that stream's share (never a
1355
+ // delta — merge-streams' contract) and the union rebuilds wholesale on
1356
+ // every report and dispose (stream count is single digits). The
1357
+ // plugin-route rewrite reads the union LIVE per answer through the
1358
+ // getter — the same live-read discipline as getServerId/getStatus: a
1359
+ // snapshot captured at install time would be permanently empty (the
1360
+ // streams start reporting after the install), and an id that appears
1361
+ // between two stream refreshes waits for the next refresh by design
1362
+ // (the documented ceiling in merge-streams.ts). Read-only consumption:
1363
+ // nothing below ever mutates what a merger handed over.
1364
+ const knownSessionShares = new Map<SessionInventorySlot, ReadonlySet<string>>()
1365
+ const knownRealIds = new Set<string>()
1366
+ const rebuildKnownRealIds = (): void => {
1367
+ knownRealIds.clear()
1368
+ for (const share of knownSessionShares.values()) {
1369
+ for (const id of share) knownRealIds.add(id)
1370
+ }
1371
+ }
1372
+ const createSessionInventorySlot = (): SessionInventorySlot => {
1373
+ const slot: SessionInventorySlot = {
1374
+ replace(ids) {
1375
+ knownSessionShares.set(slot, ids)
1376
+ rebuildKnownRealIds()
1377
+ },
1378
+ dispose() {
1379
+ if (knownSessionShares.delete(slot)) rebuildKnownRealIds()
1380
+ },
1381
+ }
1382
+ return slot
1383
+ }
1304
1384
  const interceptDiagnostics = (): InterceptDiagnostics | undefined => {
1305
1385
  if (interceptHandle !== undefined) return interceptHandle.diagnostics()
1306
1386
  if (interceptRefused !== undefined) {
@@ -1334,6 +1414,9 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1334
1414
  relay: relayClient,
1335
1415
  getServerId: () => relayClient.handshakeInfo?.serverId,
1336
1416
  getCurrentSession: () => reportedCurrentSession,
1417
+ // PB-25b: each merged stream reports its real-session inventory
1418
+ // share into the union above.
1419
+ createSessionInventorySlot,
1337
1420
  log: (format, ...args) => { ctx.logger?.info(`dsh-zen-remote ${format}`, ...args) },
1338
1421
  })
1339
1422
  interceptHandle = handle
@@ -1491,6 +1574,10 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1491
1574
  // routes' deny hooks read (better-sidebar's open.external carries
1492
1575
  // no session field of its own).
1493
1576
  getCurrentSession: () => (reportedCurrentSession.kind === 'open' ? reportedCurrentSession.sessionId : undefined),
1577
+ // PB-25b: the known-real-session union, read LIVE per rewriteIds
1578
+ // answer — the rewriteIds deep scan rewrites an id that already sits
1579
+ // in the browser's view even when this request never carried it.
1580
+ getKnownRealIds: () => knownRealIds,
1494
1581
  log: (format, ...args) => { ctx.logger?.info(`dsh-zen-remote ${format}`, ...args) },
1495
1582
  })
1496
1583
  pluginRouteHandle = handle
package/src/intercept.ts CHANGED
@@ -605,6 +605,19 @@ export interface InterceptDiagnostics {
605
605
  closedSessions: ClosedSessionRecord[]
606
606
  }
607
607
 
608
+ /** PB-25b: one merged global stream's share of the KNOWN-real-session
609
+ * inventory. REPLACE semantics: each `replace` call carries that stream's
610
+ * FULL current set (the workspace merger's sessionHome snapshot — every real
611
+ * id the sidebar currently holds as a `zr~` id), never a delta; `dispose`
612
+ * drops the share (the stream ended — its merger is gone, its ids may no
613
+ * longer be in the UI's view). The wiring unions the live shares into the
614
+ * read-only set the plugin-route rewrite consults; nothing here hands out a
615
+ * mutable reference to merge state. */
616
+ export interface SessionInventorySlot {
617
+ replace(ids: ReadonlySet<string>): void
618
+ dispose(): void
619
+ }
620
+
608
621
  export interface InstallInterceptOptions {
609
622
  /** The RAW gateway instance (`ctx.typertGateway[symbols.original]`), not
610
623
  * the cordis proxy — own properties on the proxy are ignored by everyone. */
@@ -629,6 +642,13 @@ export interface InstallInterceptOptions {
629
642
  * read got wrong — so the value must be carried in. Default: permanently
630
643
  * `unavailable` (the conservative fallback — tombstones stay). */
631
644
  getCurrentSession?: () => CurrentSessionRead
645
+ /** PB-25b: the inventory slot factory — one slot per merged global stream
646
+ * (only the workspace merger ever reports; the control merger has no
647
+ * session inventory). The wiring unions the live slots' shares into the
648
+ * set the plugin-route rewrite reads. Absent: no shares are ever reported
649
+ * and the plugin-route value scan stays exactly the request-local
650
+ * behavior. */
651
+ createSessionInventorySlot?: () => SessionInventorySlot
632
652
  }
633
653
 
634
654
  export interface InterceptHandle {
@@ -1339,6 +1359,10 @@ interface MergedGlobalStreamDeps {
1339
1359
  * sync can announce list rows the UI has no summary for; the control
1340
1360
  * route carries no session lists and never calls it. */
1341
1361
  summarySync: SessionSummarySync
1362
+ /** PB-25b: the inventory slot factory (the wiring's union feed — see
1363
+ * {@link SessionInventorySlot}). One slot per merged stream, disposed with
1364
+ * it; absent when the wiring passed no factory. */
1365
+ createSessionInventorySlot?: () => SessionInventorySlot
1342
1366
  }
1343
1367
 
1344
1368
  /**
@@ -1366,13 +1390,25 @@ async function* mergedGlobalStream(deps: MergedGlobalStreamDeps): AsyncGenerator
1366
1390
  log?.('client merge dropped a frame on %s: %s', endpoint, message)
1367
1391
  }
1368
1392
  const initial = relayIdentityOf(relay)
1393
+ // PB-25b: this stream's inventory share — the workspace merger reports its
1394
+ // full real-id set through it (replace-style), and the share dies with the
1395
+ // stream (both exits below: the degraded local-only path and the main
1396
+ // teardown). The control merger has no session inventory and never
1397
+ // reports.
1398
+ const sessionSlot = deps.createSessionInventorySlot?.()
1369
1399
  // Created eagerly so the local leg is observed from its first frame on.
1370
1400
  // The empty identity is a placeholder: the remote pump retargets before
1371
1401
  // any remote frame is ever fed (it only runs under a real handshake).
1372
1402
  let merger: WorkspaceMerger | ControlMerger =
1373
1403
  endpoint === 'session/control'
1374
1404
  ? createControlMerger({ serverId: initial?.serverId ?? '', serverName: initial?.serverName ?? '', onDiagnostic })
1375
- : createWorkspaceMerger({ serverId: initial?.serverId ?? '', serverName: initial?.serverName ?? '', onDiagnostic, now: clock.now })
1405
+ : createWorkspaceMerger({
1406
+ serverId: initial?.serverId ?? '',
1407
+ serverName: initial?.serverName ?? '',
1408
+ onDiagnostic,
1409
+ now: clock.now,
1410
+ ...(sessionSlot !== undefined ? { onKnownSessions: (ids: ReadonlySet<string>) => sessionSlot.replace(ids) } : {}),
1411
+ })
1376
1412
 
1377
1413
  /**
1378
1414
  * The status annotation (T34) the CURRENT relay state maps onto, applied
@@ -1563,6 +1599,9 @@ async function* mergedGlobalStream(deps: MergedGlobalStreamDeps): AsyncGenerator
1563
1599
  // Remote-side setup failed: degrade to a pure local passthrough so the
1564
1600
  // UI's own stream is untouched and the remote leg never starts.
1565
1601
  log?.('client merge on %s degraded to local-only: %s', endpoint, messageOf(error))
1602
+ // PB-25b: this stream never reports (no remote leg) — drop its share
1603
+ // before the degraded passthrough takes over.
1604
+ sessionSlot?.dispose()
1566
1605
  yield* localOnly(local)
1567
1606
  return
1568
1607
  }
@@ -1740,6 +1779,9 @@ async function* mergedGlobalStream(deps: MergedGlobalStreamDeps): AsyncGenerator
1740
1779
  if (signal !== undefined) signal.removeEventListener('abort', onExternalAbort)
1741
1780
  currentController?.abort()
1742
1781
  flushWaiters()
1782
+ // PB-25b: the stream is over — its inventory share leaves the wiring's
1783
+ // union (its ids may no longer be in the UI's view).
1784
+ sessionSlot?.dispose()
1743
1785
  // Best-effort half-close of the local stream the consumer walked away
1744
1786
  // from — never awaited: an upstream that ignores return() must not
1745
1787
  // hang the teardown.
@@ -2425,6 +2467,9 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
2425
2467
  // outlives individual stream generations so a nudge from a workspace leg
2426
2468
  // reaches whichever $events generation is open.
2427
2469
  const summarySync = createSessionSummarySync()
2470
+ // PB-25b: the inventory slot factory (the wiring's union feed) — passed
2471
+ // down to every merged stream, which owns one slot for its lifetime.
2472
+ const createSessionInventorySlot = options.createSessionInventorySlot
2428
2473
  // T62: the current-session getter and the live-merged-stream listener
2429
2474
  // registry behind handle.currentSessionChanged().
2430
2475
  const getCurrentSession = options.getCurrentSession ?? (() => ({ kind: 'unavailable' }) as const)
@@ -3341,6 +3386,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3341
3386
  watchCurrentSession,
3342
3387
  watchUninstall,
3343
3388
  summarySync,
3389
+ createSessionInventorySlot,
3344
3390
  })
3345
3391
  })()
3346
3392
  }
@@ -220,6 +220,18 @@ export interface WorkspaceMergerOptions extends MergerIdentity {
220
220
  /** T65: the clock the tombstone hide-grace runs on. Default Date.now;
221
221
  * tests advance an injectable fake to cross the grace. */
222
222
  now?: () => number
223
+ /** PB-25b: the known-real-session inventory report — every time the
224
+ * session registry changes, the FULL current set of real ids this merger
225
+ * knows (the sessionHome keys: live ids and tombstones alike, each one the
226
+ * UI holds as a `zr~` id under this identity) is handed over as a fresh
227
+ * snapshot. REPLACE semantics, never a delta: the consumer's share is
228
+ * redefined wholesale by every report, so no invalidation bookkeeping
229
+ * exists on either side. Documented ceiling: an id that appears (or
230
+ * leaves) between two registry changes is invisible to the consumer until
231
+ * the next change reports — dumb but correct, and the wiring owns the
232
+ * other half of the contract (read-only consumption; the plugin-route
233
+ * rewrite never mutates merge state). */
234
+ onKnownSessions?: (ids: ReadonlySet<string>) => void
223
235
  }
224
236
 
225
237
  export interface WorkspaceMerger {
@@ -285,6 +297,7 @@ export interface WorkspaceMerger {
285
297
  */
286
298
  export function createWorkspaceMerger(options: WorkspaceMergerOptions): WorkspaceMerger {
287
299
  const onDiagnostic = options.onDiagnostic
300
+ const onKnownSessions = options.onKnownSessions
288
301
  let identity: MergerIdentity = { serverId: options.serverId, serverName: options.serverName }
289
302
 
290
303
  // The locally observed truth, updated from every local baseline / order /
@@ -359,19 +372,43 @@ export function createWorkspaceMerger(options: WorkspaceMergerOptions): Workspac
359
372
  * remembered under THIS workspace as its last home (originals, cumulative). */
360
373
  function learnSessions(record: Record<string, unknown>, workspaceId: string): void {
361
374
  if (!Array.isArray(record.sessionIds)) return
362
- for (const id of record.sessionIds) if (typeof id === 'string') sessionHome.set(id, workspaceId)
375
+ let learned = false
376
+ for (const id of record.sessionIds) {
377
+ if (typeof id === 'string') {
378
+ sessionHome.set(id, workspaceId)
379
+ learned = true
380
+ }
381
+ }
382
+ // PB-25b: the inventory grew — re-report the full set (replace-style;
383
+ // see onKnownSessions). Once per record with string ids, never per id.
384
+ if (learned) reportKnownSessions()
363
385
  }
364
386
 
365
387
  /** Drop the ids whose last home was this workspace — tombstones die with
366
388
  * their group (a server remove / a workspace diffed away; a session that
367
389
  * moved on lives under its NEW home and survives this). */
368
390
  function forgetWorkspace(workspaceId: string): void {
391
+ let forgot = false
369
392
  for (const [id, home] of sessionHome) {
370
393
  if (home === workspaceId) {
371
394
  sessionHome.delete(id)
372
395
  closedAt.delete(id)
396
+ forgot = true
373
397
  }
374
398
  }
399
+ // PB-25b: the inventory shrank — the gone ids must leave the consumer's
400
+ // share with this refresh, or a session that left the sub-client's view
401
+ // would keep being rewritten.
402
+ if (forgot) reportKnownSessions()
403
+ }
404
+
405
+ /** PB-25b: the inventory report — the FULL sessionHome key set as a fresh
406
+ * snapshot, handed to the callback replace-style. Fired at the three
407
+ * registry mutation points (learn / forget / the identity end) rather than
408
+ * once per frame: a frame that changes nothing costs nothing, and any
409
+ * future frame type that touches the registry reports automatically. */
410
+ function reportKnownSessions(): void {
411
+ onKnownSessions?.(new Set(sessionHome.keys()))
375
412
  }
376
413
 
377
414
  /** T65: stamp the ids that JUST left a workspace's live list (their
@@ -885,6 +922,9 @@ export function createWorkspaceMerger(options: WorkspaceMergerOptions): Workspac
885
922
  // So does the shown set: a NEW server may reuse original workspace ids,
886
923
  // and a stale entry would forward the new server's empty groups.
887
924
  shown.clear()
925
+ // PB-25b: the inventory died with the identity — the empty report is
926
+ // what empties the consumer's share (a NEW server mints new ids).
927
+ reportKnownSessions()
888
928
  return out
889
929
  },
890
930
  }