dsh-zen-remote 2.2.7 → 2.2.9

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 (34) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/docs/interface.md +6 -4
  4. package/docs/remote-access.md +1 -0
  5. package/lib/client-routes.js +52 -0
  6. package/lib/client-routes.js.map +1 -1
  7. package/lib/client.js +506 -280
  8. package/lib/relay-client.js +71 -0
  9. package/lib/relay-client.js.map +1 -1
  10. package/lib/types/client/MobileAttachButton.d.ts +15 -7
  11. package/lib/types/client/MobileAttachButton.d.ts.map +1 -1
  12. package/lib/types/client/MobileAttachChips.d.ts.map +1 -1
  13. package/lib/types/client/effects/model-sheet-extras.d.ts +29 -25
  14. package/lib/types/client/effects/model-sheet-extras.d.ts.map +1 -1
  15. package/lib/types/client/styles/composer.css.d.ts +1 -1
  16. package/lib/types/client/styles/composer.css.d.ts.map +1 -1
  17. package/lib/types/client-data/remote-status.d.ts +39 -2
  18. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  19. package/lib/types/client-routes.d.ts +19 -0
  20. package/lib/types/client-routes.d.ts.map +1 -1
  21. package/lib/types/relay-client.d.ts +10 -0
  22. package/lib/types/relay-client.d.ts.map +1 -1
  23. package/package.json +1 -1
  24. package/scripts/check-attach-upload.mjs +42 -0
  25. package/src/client/MobileAttachButton.tsx +23 -12
  26. package/src/client/MobileAttachChips.tsx +0 -7
  27. package/src/client/effects/model-sheet-extras.ts +233 -111
  28. package/src/client/styles/composer.css.ts +142 -83
  29. package/src/client-data/remote-status.ts +128 -2
  30. package/src/client-routes.ts +53 -0
  31. package/src/relay-client.ts +76 -0
  32. package/lib/types/client/host-attach.d.ts +0 -38
  33. package/lib/types/client/host-attach.d.ts.map +0 -1
  34. package/src/client/host-attach.ts +0 -54
@@ -6,11 +6,16 @@
6
6
  //
7
7
  // Official structure this file reshapes (verified live at 390px, see the S3
8
8
  // report): InputBar renders
9
- // _card > _row > [_tools > (_add, _modes > PermissionSelect, input.left)]
9
+ // _card > _row > [_tools > (_add, input[type=file] hidden, _modes >
10
+ // PermissionSelect, input.left)]
10
11
  // [_trailing > (_standardControls > (input.right, input.model), _activity, _primary)]
11
12
  // with `_tools` / `_trailing` / `_standardControls` as inner flex groups
12
13
  // (0.1.7 added `_standardControls` and moved the ContextMeter out of the row
13
- // into the dock under the card). Flattening all three to
14
+ // into the dock under the card). That hidden file input is the stand-down
15
+ // probe host-attach.ts USED to key on — since #31 the phone's own attach
16
+ // button is a permanent resident instead, and DSH 0.1.5's second `_add`
17
+ // button (the standalone paperclip 0.2.x removed) is hidden by section 2.
18
+ // Flattening all three to
14
19
  // `display: contents` turns every control into a direct flex item of `_row`,
15
20
  // which is what makes a pure `order` reflow possible without touching a
16
21
  // single official component.
@@ -23,8 +28,21 @@ const ROOT = '[data-slot="conversation.composer.bar"] > [class$="_root"]'
23
28
  const RING_W = '30px'
24
29
  /** DSH 0.1.7's wrapper around input.right + input.model inside `_trailing`. */
25
30
  const STD = `${ROW} > [class$="_trailing"] > [class$="_standardControls"]`
26
- /** The model seat's own root inside the trailing group (long form so it beats the <=1023px pill rules in layout.css.ts). */
27
- const MODEL = `${STD} > [data-slot="conversation.input.model"] > [class$="_root"]`
31
+ /**
32
+ * The model seat's content, whichever plugin owns it: officially ModelSelect's
33
+ * `_root`, but a third party can replace the whole `conversation.input.model`
34
+ * slot (…codex-ui mounts its own selector there), and a subagent session has
35
+ * nothing matching `_root` at all. Spelled `*:not(…)` rather than bare `> *`
36
+ * on purpose: `:not([data-mobile-nav])` carries the same (0,1,0) the old
37
+ * `[class$="_root"]` did, keeping every rule below a full attribute CLASS
38
+ * ahead of the ≤1023px pill rules in layout.css.ts — their `[data-phase] …
39
+ * :has(…)` forms measure (0,6,1) on the trigger and (0,7,1) on the label
40
+ * with `!important`, so a bare `> *` would leave the phone winning by
41
+ * exactly one class on every MODEL-derived rule, one upstream attr away
42
+ * from losing. The :not() also keeps this plugin's own nodes from ever
43
+ * reading as the model seat, should one ever be mounted there.
44
+ */
45
+ const MODEL = `${STD} > [data-slot="conversation.input.model"] > *:not([data-mobile-nav])`
28
46
  /** The permission select's trigger button (structure only — the PermissionSelect hashed classes are never named). */
29
47
  const PERM = `${ROW} > [class$="_tools"] > [class$="_modes"]`
30
48
  /**
@@ -60,6 +78,12 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
60
78
  [data-mobile-nav="attach-picker"] {
61
79
  display: none !important;
62
80
  }
81
+ /* The composer-chips strip (effects/model-sheet-extras.ts) — same
82
+ every-width default as the picker above: the phone block re-shows it
83
+ inside the dock rail; the desktop shell keeps none. */
84
+ [data-mobile-nav="composer-chips"] {
85
+ display: none !important;
86
+ }
63
87
 
64
88
  @media (max-width: 767px) {
65
89
  /* --- 1. flatten the two official groups ---
@@ -83,8 +107,12 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
83
107
  }
84
108
 
85
109
  /* --- 2. the running order ---
86
- The attachment button (S3 placeholder, S7 wires it up) sits leftmost;
87
- the official "+" command menu keeps its seat right next to it, where
110
+ The attachment button (S3 placeholder, S7 wires it up) sits leftmost and
111
+ is a PERMANENT resident now (#31): on DSH 0.2.x the standalone paperclip
112
+ moved into the "+" command menu while its hidden <input type="file">
113
+ stayed in _tools, so the old host-attach stand-down probe matched on
114
+ every host and the phone composer was left with no attach affordance at
115
+ all. The official "+" command menu keeps its seat right next to it, where
88
116
  the two "insert something" affordances read as one cluster. */
89
117
  ${ROW} [data-mobile-nav="attach"] {
90
118
  order: 1 !important;
@@ -92,6 +120,14 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
92
120
  ${ROW} > [class$="_tools"] > [class$="_add"] {
93
121
  order: 2 !important;
94
122
  }
123
+ /* DSH ≤ 0.1.5 still renders that second _add button (the standalone
124
+ paperclip) as a direct _tools child; hide it below 768px so the row
125
+ keeps exactly one attach control — ours, which uploads into the session
126
+ workspace the phone can actually reach. On 0.2.x the only button in
127
+ _tools is the first of its type, so this never matches. */
128
+ ${ROW} > [class$="_tools"] > button[class$="_add"]:nth-of-type(2) {
129
+ display: none !important;
130
+ }
95
131
  ${ROW} > [class$="_tools"] > [class$="_modes"] {
96
132
  order: 3 !important;
97
133
  /* Rigid on purpose. This seat holds the permission chip, whose own
@@ -243,7 +279,8 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
243
279
  caps the pill so the row's elastic gap survives extreme model ids;
244
280
  the label rules below override the <=1023px grow rules in
245
281
  layout.css.ts (flex 1 1 auto) that would otherwise pad the pill. */
246
- ${MODEL} > [class$="_trigger"] {
282
+ ${MODEL} > [class$="_trigger"],
283
+ ${MODEL} > button {
247
284
  background: var(--dsw-specific-selector, rgba(127, 127, 127, .12)) !important;
248
285
  height: 30px !important;
249
286
  width: auto !important;
@@ -269,6 +306,18 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
269
306
  flex: none !important;
270
307
  white-space: nowrap !important;
271
308
  }
309
+ /* Third-party model selectors (…codex-ui) render a bare <button> with no
310
+ _trigger classes and text straight inside it; give that button the same
311
+ shrink+truncate contract the official label gets, so a long model id
312
+ ellipsizes instead of stretching the row. The official trigger also
313
+ matches this (harmless: it already ellipsizes its own label). */
314
+ ${MODEL} > button {
315
+ max-width: 100% !important;
316
+ min-width: 0 !important;
317
+ overflow: hidden !important;
318
+ text-overflow: ellipsis !important;
319
+ white-space: nowrap !important;
320
+ }
272
321
  /* PermissionSelect wraps its trigger in the Menu primitive's root span,
273
322
  which must shrink to the icon button's fixed width. */
274
323
  ${PERM} > span:has(> button[class$="_trigger"]) {
@@ -336,90 +385,97 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
336
385
  border-radius: 12px !important;
337
386
  font-size: 15px !important;
338
387
  }
339
- /* --- 4a. third-party composer entries live in the model sheet ---
340
- effects/model-sheet-extras.ts parks the \`conversation.input.right\`
341
- container inside the model menu while that menu is open and puts it back
342
- when it closes; these rules are the two halves of that trip.
388
+ /* --- 4a. third-party composer entries live in the dock's chip strip ---
389
+ effects/model-sheet-extras.ts collects the composer's secondary controls
390
+ (input.right, input.left, and anything a third party parks directly into
391
+ _tools / _standardControls / _trailing / _row) and moves the REAL nodes
392
+ into [data-mobile-nav="composer-chips"] — a 26px horizontally
393
+ scrollable strip the effect mounts at the head of the dock rail above
394
+ the card. Always visible, one tap, no sheet in between.
343
395
 
344
- In the row: hidden, but ONLY once the effect has marked the container as
345
- one it manages. Keying the hide off the marker rather than off "not
346
- currently parked" is what makes the failure direction safe — if the
347
- effect declines to act (nothing in the slot but the vision toggle, so
348
- nothing worth moving) or never runs at all, the marker is absent, this
349
- rule does not match, and the controls stay exactly where the host put
350
- them. The inverse spelling hid them in the row whenever they were not in
351
- the sheet, which in those same cases left them reachable from neither
352
- place (caught in review, 2026-09-06).
353
- Hiding by ancestor rather than by a display rule on the container itself
354
- is what lets the parked copy style itself freely — same element, two
355
- homes, two rule sets. \`!important\` because the slot container carries an
356
- inline \`display: contents\`. */
357
- ${STD} > [data-slot="conversation.input.right"][data-zen-sheet-extras] {
396
+ In the row: hidden, but ONLY once the effect has marked them. Two opt-in
397
+ markers, both written by the effect: the input.right CONTAINER gets
398
+ data-zen-sheet-extras (so an emptied wrapper leaves no flex gap), and
399
+ every collected node gets data-zen-composer-extra. Visibility comes back
400
+ the moment a node is parked (="open") or the effect releases it (marker
401
+ removed) — a version of the effect that declines to act, or never runs
402
+ at all, leaves every control exactly where the host put it; nothing can
403
+ end up hidden from both places at once (the 2026-09-06 review's rule). */
404
+ [data-slot="conversation.input.right"][data-zen-sheet-extras],
405
+ [data-zen-composer-extra]:not([data-zen-composer-extra="open"]) {
358
406
  display: none !important;
359
407
  }
360
- /* Parked in the sheet: a column of full-width rows in the sheet's own
361
- language, so a relocated control reads as a sibling of 模型 and 推理等级
362
- rather than a chip that wandered in. */
363
- ${MODEL_MENU} > [data-zen-sheet-extras] {
408
+ /* The strip itself: one 26px line at the rail's head (order 0, document
409
+ order first via the effect's prepend), scrolling sideways when the
410
+ collected controls overflow. Scoped to the dock CHILD — the generic
411
+ marker also sits on the emptied input.right container in the row, where
412
+ nothing but the opt-in hide above may touch it. This specificity (0,2,0)
413
+ is what beats the section 7 \`> *\` cage wherever they overlap (overflow,
414
+ max-width, radius are all !important there too). */
415
+ [data-slot="conversation.input.dock"] > [data-mobile-nav="composer-chips"] {
364
416
  display: flex !important;
365
- flex-direction: column !important;
366
- gap: 2px !important;
367
- width: 100% !important;
368
- }
369
- /* Undo the row's compaction on every parked control: back to full width and
370
- the 48px the sheet's own cells use. \`> * > *\` reaches both shapes without
371
- naming either plugin — subscriptions wraps its trigger in a positioning
372
- div, vision-router puts its button straight in the slot. */
373
- ${MODEL_MENU} > [data-zen-sheet-extras] > *,
374
- ${MODEL_MENU} > [data-zen-sheet-extras] > * > button {
375
- width: 100% !important;
376
- max-width: none !important;
417
+ flex-flow: row nowrap !important;
418
+ align-items: center !important;
419
+ gap: 6px !important;
420
+ order: 0 !important;
421
+ flex: 0 1 auto !important;
377
422
  min-width: 0 !important;
378
- justify-content: flex-start !important;
423
+ max-width: 100% !important;
424
+ min-height: 26px !important;
425
+ max-height: 26px !important;
426
+ border-radius: 0 !important;
427
+ overflow-x: auto !important;
428
+ overflow-y: hidden !important;
429
+ scrollbar-width: none !important;
379
430
  }
380
- ${MODEL_MENU} > [data-zen-sheet-extras] button {
381
- box-sizing: border-box !important;
382
- min-height: 48px !important;
383
- height: auto !important;
384
- padding: 0 12px !important;
385
- gap: 10px !important;
386
- border-radius: 12px !important;
387
- border: none !important;
388
- background: transparent !important;
389
- font-size: 15px !important;
390
- /* Both plugins center their chip label, which is right for a pill in the
391
- row and wrong for a full-width row: the sheet's own cells start their
392
- label at the left edge. justify-content covers the flex triggers,
393
- text-align the ones that are not flex containers. */
394
- text-align: left !important;
395
- justify-content: flex-start !important;
431
+ [data-slot="conversation.input.dock"] > [data-mobile-nav="composer-chips"]::-webkit-scrollbar {
432
+ display: none !important;
396
433
  }
397
- /* Order comes from the row (compat.css.ts puts the vision toggle at 5 so it
398
- parks beside the context ring), and that rule still matches in here on
399
- ≤ 0.1.2 — the inline sheet is a DOM descendant of the composer bar even
400
- though it paints as a fixed layer (the 0.1.5 body portal is not, where
401
- this is a harmless no-op). Reset it so the sheet follows DOM order
402
- instead of inheriting a decision that was about a different layout. */
403
- ${MODEL_MENU} > [data-zen-sheet-extras] > * {
434
+ /* Parked controls shrink from whatever the row allowed them to the rail's
435
+ 26px pill scale: auto width again (no full-width rows here), tight
436
+ height, and the chip font the rest of the rail uses. */
437
+ [data-mobile-nav="composer-chips"] > * {
438
+ flex: 0 0 auto !important;
404
439
  order: 0 !important;
440
+ display: inline-flex !important;
441
+ align-items: center !important;
442
+ min-height: 26px !important;
443
+ max-height: 26px !important;
444
+ width: auto !important;
445
+ max-width: none !important;
446
+ }
447
+ [data-mobile-nav="composer-chips"] button {
448
+ box-sizing: border-box !important;
449
+ height: 26px !important;
450
+ min-height: 26px !important;
451
+ max-height: 26px !important;
452
+ width: auto !important;
453
+ padding: 0 10px !important;
454
+ gap: 5px !important;
455
+ border-radius: 999px !important;
456
+ font-size: 11.5px !important;
457
+ line-height: 18px !important;
458
+ white-space: nowrap !important;
405
459
  }
406
- /* The vision toggle is squeezed to a 28px icon in the row (compat.css.ts);
407
- in the sheet it is a row, so its label comes back and the icon leads. */
408
- ${MODEL_MENU} > [data-zen-sheet-extras] [data-vision-router-mode-toggle] > span {
460
+ /* The vision toggle is squeezed to a 28px icon-only button in the row
461
+ (compat.css.ts); in the strip it is a 14px icon + text pill, so its
462
+ label comes back. */
463
+ [data-mobile-nav="composer-chips"] [data-vision-router-mode-toggle] > span {
409
464
  display: inline !important;
410
465
  }
411
- ${MODEL_MENU} > [data-zen-sheet-extras] [data-vision-router-mode-toggle] > svg:first-of-type {
412
- width: 18px !important;
413
- height: 18px !important;
466
+ [data-mobile-nav="composer-chips"] [data-vision-router-mode-toggle] > svg:first-of-type {
467
+ width: 14px !important;
468
+ height: 14px !important;
414
469
  }
415
470
  /* The speed chip opens a menu of its own, anchored \`bottom: 100%\` to its
416
- trigger. Inside the sheet — which is \`overflow: hidden\` so its rounded
417
- top clips its content — that menu would be cut off. Same escape section 4
418
- uses for the host's two menus: position: fixed leaves the clip behind
419
- (the sheet sets \`transform: none\`, so nothing between here and the
420
- viewport captures fixed positioning) and it lands as its own sheet on top
421
- of this one. */
422
- ${MODEL_MENU} > [data-zen-sheet-extras] [role="menu"] {
471
+ trigger. Inside the strip — which is \`overflow-x: auto\`, so its content
472
+ clips at the strip's box — that menu would be cut off. Same escape
473
+ section 4 uses for the host's two menus: position: fixed leaves the clip
474
+ behind and it lands as its own bottom sheet over the composer. */
475
+ [class$="_composerSeat"]:has([data-zen-sheet-extras] [role="menu"]) {
476
+ z-index: 60 !important;
477
+ }
478
+ [data-zen-sheet-extras] [role="menu"] {
423
479
  position: fixed !important;
424
480
  left: 0 !important;
425
481
  right: 0 !important;
@@ -434,13 +490,13 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
434
490
  border-radius: 16px 16px 0 0 !important;
435
491
  border-bottom: none !important;
436
492
  padding: 8px 8px calc(8px + var(--mnav-sab)) !important;
437
- /* Above the model sheet it sits on (60, section 4), below the session-info
438
- sheet's 70 (info.css.ts) — it is a child of the model sheet, not a peer
439
- of the page's own layers. */
493
+ /* Above the page content behind the seat (the seat is raised to 60 by
494
+ the rule above while a parked menu is open), below the session-info
495
+ sheet's 70 (info.css.ts). */
440
496
  z-index: 61 !important;
441
497
  box-shadow: 0 -8px 32px rgba(0, 0, 0, .18) !important;
442
498
  }
443
- ${MODEL_MENU} > [data-zen-sheet-extras] [role="menu"] [role="menuitemradio"] {
499
+ [data-zen-sheet-extras] [role="menu"] [role="menuitemradio"] {
444
500
  min-height: 48px !important;
445
501
  border-radius: 12px !important;
446
502
  font-size: 15px !important;
@@ -468,7 +524,10 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
468
524
  body:has(${MODEL} > [class$="_menu"]) [data-chat-flow] + div,
469
525
  body:has(> [id$="-menu"][class$="_menu"]:is([role="menu"], [role="group"])) [data-chat-flow] + div,
470
526
  /* T70: the portaled permission menu (marked by effects/permission-sheet.ts). */
471
- body:has(> div[role="menu"][data-zen-sheet="perm"]) [data-chat-flow] + div {
527
+ body:has(> div[role="menu"][data-zen-sheet="perm"]) [data-chat-flow] + div,
528
+ /* T-issue30: an inline menu a parked chip opened (escaped to a fixed
529
+ sheet by 4a). */
530
+ body:has([data-zen-sheet-extras] [role="menu"]) [data-chat-flow] + div {
472
531
  display: none !important;
473
532
  }
474
533
  /* --- 5. input box: two lines minimum, five lines maximum ---
@@ -35,6 +35,8 @@ import { zh } from '../client/locales.ts'
35
35
  /** Same-origin client route feeding this store (host half: T34,
36
36
  * src/client-routes.ts). */
37
37
  export const REMOTE_STATUS_ROUTE = '/_dsh/zen-remote/client/remote-status'
38
+ /** the browser's network-restored hint (POST). */
39
+ export const NETWORK_HINT_ROUTE = '/_dsh/zen-remote/client/network-hint'
38
40
 
39
41
  /** Same-origin client route closing one remote session from this machine. */
40
42
  export const CLIENT_UNSHARE_ROUTE = '/_dsh/zen-remote/client/unshare'
@@ -224,6 +226,47 @@ export const documentVisibility: VisibilitySource = {
224
226
  },
225
227
  }
226
228
 
229
+ // --- the NETWORK-RESTORED hint source ---------------
230
+
231
+ /**
232
+ * the browser-side signal that a previously broken
233
+ * link MAY be back. Two triggers, both cheap and event-driven (never polled):
234
+ *
235
+ * - `window`'s `online` event — the browser's own reachability flag going
236
+ * true. Note this is a HINT, never proof: `navigator.onLine === true`
237
+ * means "an interface is up", not "the host is reachable". The host
238
+ * resolves the question with an authenticated `/ping` before anything
239
+ * reconnects.
240
+ * - `visibilitychange` → visible: a backgrounded tab (throttled timers, a
241
+ * possibly-changed network) coming back is the other moment a stale
242
+ * `offline` is most likely, and it costs one hint at most.
243
+ *
244
+ * Deliberately NOT wired: `window`'s `offline` event. Going offline needs no
245
+ * action (the poll and the streams already notice), and firing a hint there
246
+ * would only produce refused round-trips.
247
+ */
248
+ export interface NetworkHintSource {
249
+ /** Attach; the returned function detaches. */
250
+ subscribe(listener: () => void): () => void
251
+ }
252
+
253
+ /** The real source (Node / SSR: never notifying — a host has no navigator). */
254
+ export const windowNetworkHint: NetworkHintSource = {
255
+ subscribe: (listener) => {
256
+ if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return () => {}
257
+ const onOnline = (): void => { listener() }
258
+ const onVisibility = (): void => {
259
+ if (typeof document !== 'undefined' && document.visibilityState === 'visible') listener()
260
+ }
261
+ window.addEventListener('online', onOnline)
262
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility)
263
+ return () => {
264
+ window.removeEventListener('online', onOnline)
265
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility)
266
+ }
267
+ },
268
+ }
269
+
227
270
  /** How one `client/unshare` POST ended. A refused action carries the
228
271
  * backend's error `code` when it sent one (`not-shared`, `remote-mismatch`,
229
272
  * …). */
@@ -243,24 +286,42 @@ export interface RemoteStatusStore {
243
286
  /** POST client/unshare for one VIRTUAL session id, then refresh.
244
287
  * @returns the action's outcome. */
245
288
  unshare(sessionId: string): Promise<RemoteUnshareOutcome>
289
+
290
+ /** fire one network-restored hint now (the same
291
+ * controlled round the window `online` event drives). Exposed for tests
292
+ * and for a host-side manual probe; never bypasses the host's probe. */
293
+ notifyNetworkRestored(): void
246
294
  }
247
295
 
248
296
  /** Poll cadence while the page is visible and at least one part is mounted
249
297
  * (T34: 15 秒). */
250
298
  export const REMOTE_STATUS_POLL_MS = 15_000
251
299
 
300
+ /** The minimum gap between two client-side network hints — the same 3s the
301
+ * host's `networkRestored()` enforces, so the two never fight. `hintGapMs`
302
+ * injects a smaller one (tests). */
303
+ const NETWORK_HINT_CLIENT_GAP_MS = 3_000
304
+
252
305
  /**
253
306
  * Build one store over an injectable fetch and visibility source — the
254
307
  * shares-store discipline: latest-wins (an earlier request answering late
255
308
  * never overwrites a newer table), every GET failure fails its own round,
256
309
  * the timer is real while visible and gone while hidden, and becoming
257
310
  * visible pulls once immediately. `pollMs` exists for tests: the shipped
258
- * cadence is far too slow to observe.
311
+ * cadence is far too slow to observe. `hintGapMs` exists for the same
312
+ * reason: the shipped hint gap (default 3000) is far too slow to observe.
259
313
  */
260
314
  export function createRemoteStatusStore(
261
315
  fetchImpl: typeof fetch,
262
316
  visibility: VisibilitySource = documentVisibility,
263
317
  pollMs: number = REMOTE_STATUS_POLL_MS,
318
+ /** the network-restored hint source. Omitted → no
319
+ * hint wiring (a host mount, or a test). */
320
+ networkHint?: NetworkHintSource,
321
+ /** the minimum gap between two client-side hints, ms; default 3000 (the
322
+ * same anti-storm gap the host enforces). Exists for tests, like
323
+ * `pollMs`: the shipped gap is far too slow to observe. */
324
+ hintGapMs: number = NETWORK_HINT_CLIENT_GAP_MS,
264
325
  ): RemoteStatusStore {
265
326
  const gate = createLatestGate()
266
327
  const listeners = new Set<() => void>()
@@ -366,18 +427,71 @@ export function createRemoteStatusStore(
366
427
  }
367
428
  }
368
429
 
430
+ // ONE controlled round per hint.
431
+ //
432
+ // The chain is: hint → POST client/network-hint → host runs probeHealth()
433
+ // (authenticated GET /ping) → only a reachable host advances to
434
+ // networkRestored() (which is itself offline-only + 3s-gapped) → the next
435
+ // status poll reflects the new state, and the intercept layer's $zr/events
436
+ // leg reopens on its own once the relay reads online.
437
+ //
438
+ // THREE client-side guards keep a flapping machine from turning the hint
439
+ // into a poll: a healthy-link gate (the last status read said `online` —
440
+ // nothing to retire), an in-flight slot (one hint at a time), and a
441
+ // minimum gap (the same 3s the host enforces, so the two never fight).
442
+ let hintInFlight = false
443
+ let lastHintAt = 0
444
+
445
+ const sendNetworkHint = async (): Promise<void> => {
446
+ // The healthy-link gate: a hint exists to retire a stale `offline`. While
447
+ // the last status read says `online`, a tab switch or a browser `online`
448
+ // event must not spend the hint→ping→refresh triple. `view` undefined
449
+ // (no GET answered yet) passes — conservative before the first read.
450
+ if (snapshot.view?.state === 'online') return
451
+ if (hintInFlight) return
452
+ const now = Date.now()
453
+ if (now - lastHintAt < hintGapMs) return
454
+ lastHintAt = now
455
+ hintInFlight = true
456
+ try {
457
+ await fetchImpl(NETWORK_HINT_ROUTE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
458
+ } catch {
459
+ // A hint that cannot be delivered is simply dropped — the poll and the
460
+ // streams are still the truth, and the next event tries again.
461
+ } finally {
462
+ hintInFlight = false
463
+ // Whatever the hint achieved, re-read the status so the UI stops
464
+ // showing a stale `offline` the moment the host is back.
465
+ void refresh()
466
+ }
467
+ }
468
+
469
+ let offNetworkHint: (() => void) | undefined
470
+ const startHintWiring = (): void => {
471
+ if (networkHint === undefined || offNetworkHint !== undefined) return
472
+ offNetworkHint = networkHint.subscribe(() => { void sendNetworkHint() })
473
+ }
474
+
369
475
  return {
370
476
  getSnapshot: () => snapshot,
371
477
  subscribe(listener: () => void): () => void {
372
478
  listeners.add(listener)
373
479
  syncPolling()
480
+ startHintWiring()
374
481
  return () => {
375
482
  listeners.delete(listener)
376
483
  syncPolling()
484
+ // The hint listener follows the subscribers: no page, no hints.
485
+ if (listeners.size === 0 && offNetworkHint !== undefined) {
486
+ offNetworkHint()
487
+ offNetworkHint = undefined
488
+ }
377
489
  }
378
490
  },
379
491
  refresh,
380
492
  unshare,
493
+ /** explicit hint trigger (tests / manual probe). */
494
+ notifyNetworkRestored: () => { void sendNetworkHint() },
381
495
  }
382
496
  }
383
497
 
@@ -399,6 +513,18 @@ export function subscribeIfVirtual(store: RemoteStatusStore, sessionId: string,
399
513
  * matter how many of the icon and banner parts are mounted at once. Created
400
514
  * lazily over the real fetch, so check scripts never construct it. */
401
515
  export function getRemoteStatusStore(): RemoteStatusStore {
402
- singleton ??= createRemoteStatusStore((input, init) => fetch(input, init))
516
+ // the REAL entry wiring. The network-hint source is
517
+ // passed here and nowhere else, so every mounted remote part (icon + banner)
518
+ // shares ONE store and therefore ONE hint subscription — no per-component
519
+ // listeners, no duplicate POSTs.
520
+ //
521
+ // Node / SSR: `windowNetworkHint.subscribe` returns a no-op detacher and
522
+ // never fires (there is no `window`), so a host mount is unaffected.
523
+ singleton ??= createRemoteStatusStore(
524
+ (input, init) => fetch(input, init),
525
+ documentVisibility,
526
+ REMOTE_STATUS_POLL_MS,
527
+ windowNetworkHint,
528
+ )
403
529
  return singleton
404
530
  }
@@ -52,6 +52,26 @@ export const CLIENT_STATUS_ROUTE = `${CLIENT_ROUTE_PREFIX}/status`
52
52
  * answered 409 unless the relay client is currently `offline`. */
53
53
  export const CLIENT_RECONNECT_ROUTE = `${CLIENT_ROUTE_PREFIX}/reconnect`
54
54
 
55
+ /** POST: the browser's NETWORK-RESTORED hint.
56
+ *
57
+ * Why this route exists: the host half runs in a plain Node process with no
58
+ * OS network events, and the browser half cannot touch the relay client's
59
+ * memory across the HTTP boundary. So the browser — the only place that can
60
+ * observe `online`/`visibilitychange` — reports the hint here, and the host
61
+ * turns it into ONE controlled probe + reconnect.
62
+ *
63
+ * Deliberately narrower than `reconnect`:
64
+ * - it first runs `probeHealth()` (authenticated GET /ping); a hint NEVER
65
+ * alone proves the link is back (`navigator.onLine === true` is not
66
+ * evidence);
67
+ * - only a successful probe advances to `networkRestored()`, which itself
68
+ * refuses outside `offline` and inside its 3s anti-storm gap;
69
+ * - it answers the probe outcome so the page can log/annotate it.
70
+ *
71
+ * It NEVER changes state by itself: `probeHealth` is read-only, and
72
+ * `networkRestored` is the same controlled path the settings button uses. */
73
+ export const CLIENT_NETWORK_HINT_ROUTE = `${CLIENT_ROUTE_PREFIX}/network-hint`
74
+
55
75
  /** GET: the remote-session status the T34 client parts render from (T34) —
56
76
  * the relay's serving state, the interface-compatibility verdict, the
57
77
  * handshake's server name, and the closed-session map. The prefix is
@@ -589,6 +609,39 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
589
609
  responseJson(res, 200, { ok: true })
590
610
  return
591
611
  }
612
+ if (route === CLIENT_NETWORK_HINT_ROUTE) {
613
+ if (method !== 'POST') {
614
+ res.setHeader('Allow', 'POST')
615
+ responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use POST' } })
616
+ return
617
+ }
618
+ if (!sameOriginPost(req)) {
619
+ responseJson(res, 403, {
620
+ ok: false,
621
+ error: { code: 'origin-rejected', message: 'The request must originate from this DSH Web application' },
622
+ })
623
+ return
624
+ }
625
+ const relay = options.getRelayClient?.()
626
+ if (relay === undefined) {
627
+ responseJson(res, 503, { ok: false, error: { code: 'unavailable', message: 'No relay client is running' } })
628
+ return
629
+ }
630
+ // a hint is a QUESTION, not a verdict.
631
+ // `probeHealth` is one authenticated GET /ping: it proves the HOST is
632
+ // reachable with the current token — nothing about the long streams.
633
+ // Only a reachable host earns the controlled reconnect, and
634
+ // `networkRestored()` is what enforces "offline only" + the 3s gap.
635
+ const reachable = await relay.probeHealth()
636
+ if (!reachable) {
637
+ // Honest answer: the network may be back, the HOST is not.
638
+ responseJson(res, 200, { ok: true, reachable: false, reconnected: false })
639
+ return
640
+ }
641
+ const reconnected = relay.networkRestored()
642
+ responseJson(res, 200, { ok: true, reachable: true, reconnected })
643
+ return
644
+ }
592
645
  if (route === CLIENT_REMOTE_STATUS_ROUTE) {
593
646
  if (method !== 'GET') {
594
647
  res.setHeader('Allow', 'GET')