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.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/plugin-bridge.md +14 -7
- package/docs/remote-access.en.md +8 -8
- package/docs/remote-access.md +10 -10
- package/lib/adapters/dsh-better-sidebar/index.js +21 -8
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/adapters/dsh-better-sidebar/surface.js +79 -0
- package/lib/adapters/dsh-better-sidebar/surface.js.map +1 -0
- package/lib/client.js +102 -30
- package/lib/index.js +103 -10
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +23 -1
- package/lib/intercept.js.map +1 -1
- package/lib/merge-streams.js +29 -2
- package/lib/merge-streams.js.map +1 -1
- package/lib/plugin-adapter-core.js +69 -13
- package/lib/plugin-adapter-core.js.map +1 -1
- package/lib/plugin-bridge.js +179 -12
- package/lib/plugin-bridge.js.map +1 -1
- package/lib/plugin-route-intercept.js +631 -79
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/plugin-tunnel.js +84 -12
- package/lib/plugin-tunnel.js.map +1 -1
- package/lib/relay-server.js +65 -47
- package/lib/relay-server.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/surface.d.ts +23 -0
- package/lib/types/adapters/dsh-better-sidebar/surface.d.ts.map +1 -0
- package/lib/types/adapters/types.d.ts +14 -3
- package/lib/types/adapters/types.d.ts.map +1 -1
- package/lib/types/client/effects/turn-fold.d.ts +42 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
- package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -1
- package/lib/types/index.d.ts +2 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +19 -0
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts +12 -0
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/plugin-adapter-core.d.ts +24 -11
- package/lib/types/plugin-adapter-core.d.ts.map +1 -1
- package/lib/types/plugin-bridge.d.ts +93 -5
- package/lib/types/plugin-bridge.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +52 -3
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/plugin-tunnel.d.ts +27 -3
- package/lib/types/plugin-tunnel.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts +2 -25
- package/lib/types/relay-server.d.ts.map +1 -1
- package/lib/types/upload-limits.d.ts +67 -0
- package/lib/types/upload-limits.d.ts.map +1 -0
- package/lib/types/wizard/proxy-snippets.d.ts +4 -3
- package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
- package/lib/upload-limits.js +67 -0
- package/lib/upload-limits.js.map +1 -0
- package/lib/wizard/proxy-snippets.js +5 -4
- package/lib/wizard/proxy-snippets.js.map +1 -1
- package/package.json +38 -38
- package/scripts/check-upload-limits.mjs +167 -0
- package/src/adapters/dsh-better-sidebar/index.ts +21 -9
- package/src/adapters/dsh-better-sidebar/surface.ts +90 -0
- package/src/adapters/types.ts +14 -3
- package/src/client/effects/turn-fold.ts +97 -26
- package/src/client/styles/turn-fold.css.ts +9 -0
- package/src/client-data/wizard.ts +1 -1
- package/src/index.ts +99 -12
- package/src/intercept.ts +47 -1
- package/src/merge-streams.ts +41 -1
- package/src/plugin-adapter-core.ts +69 -14
- package/src/plugin-bridge.ts +228 -14
- package/src/plugin-route-intercept.ts +677 -82
- package/src/plugin-tunnel.ts +95 -13
- package/src/relay-server.ts +60 -48
- package/src/upload-limits.ts +69 -0
- 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:
|
|
120
|
-
*
|
|
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:
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
/**
|
|
138
|
-
*
|
|
139
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
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).
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
}
|
package/src/merge-streams.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|