dsh-zen-remote 2.3.5 → 2.3.7

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 (100) hide show
  1. package/AGENTS.md +15 -5
  2. package/CHANGELOG.md +71 -0
  3. package/README.md +2 -2
  4. package/README.zh-CN.md +2 -2
  5. package/docs/interface.md +12 -6
  6. package/docs/plugin-bridge.md +2 -2
  7. package/docs/remote-access.en.md +9 -3
  8. package/docs/remote-access.md +9 -3
  9. package/lan-gate.mjs +7 -0
  10. package/lib/adapters/dsh-better-sidebar/index.js +31 -2
  11. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  12. package/lib/client-pairing.js +10 -0
  13. package/lib/client-pairing.js.map +1 -1
  14. package/lib/client-routes.js +183 -94
  15. package/lib/client-routes.js.map +1 -1
  16. package/lib/client.js +589 -97
  17. package/lib/debug-redact.js +253 -0
  18. package/lib/debug-redact.js.map +1 -0
  19. package/lib/debug-report.js +60 -0
  20. package/lib/debug-report.js.map +1 -0
  21. package/lib/fetch-route-intercept.js +23 -0
  22. package/lib/fetch-route-intercept.js.map +1 -1
  23. package/lib/index.js +306 -45
  24. package/lib/index.js.map +1 -1
  25. package/lib/intercept.js +43 -1
  26. package/lib/intercept.js.map +1 -1
  27. package/lib/log-ring.js +76 -0
  28. package/lib/log-ring.js.map +1 -0
  29. package/lib/plugin-adapter-core.js +30 -1
  30. package/lib/plugin-adapter-core.js.map +1 -1
  31. package/lib/plugin-route-intercept.js +506 -11
  32. package/lib/plugin-route-intercept.js.map +1 -1
  33. package/lib/plugin-tunnel.js +210 -18
  34. package/lib/plugin-tunnel.js.map +1 -1
  35. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +5 -2
  36. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  37. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  38. package/lib/types/client/effects/remote-drop.d.ts +20 -8
  39. package/lib/types/client/effects/remote-drop.d.ts.map +1 -1
  40. package/lib/types/client/locales.d.ts +10 -1
  41. package/lib/types/client/locales.d.ts.map +1 -1
  42. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  43. package/lib/types/client/styles/content.css.d.ts +1 -1
  44. package/lib/types/client/styles/content.css.d.ts.map +1 -1
  45. package/lib/types/client/styles/layout.css.d.ts +1 -1
  46. package/lib/types/client/styles/layout.css.d.ts.map +1 -1
  47. package/lib/types/client/styles/misc.css.d.ts +1 -1
  48. package/lib/types/client/styles/misc.css.d.ts.map +1 -1
  49. package/lib/types/client-data/remote-drop.d.ts +64 -0
  50. package/lib/types/client-data/remote-drop.d.ts.map +1 -1
  51. package/lib/types/client-data/settings-form.d.ts +3 -0
  52. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  53. package/lib/types/client-pairing.d.ts +8 -0
  54. package/lib/types/client-pairing.d.ts.map +1 -1
  55. package/lib/types/client-routes.d.ts +23 -0
  56. package/lib/types/client-routes.d.ts.map +1 -1
  57. package/lib/types/debug-redact.d.ts +68 -0
  58. package/lib/types/debug-redact.d.ts.map +1 -0
  59. package/lib/types/debug-report.d.ts +58 -0
  60. package/lib/types/debug-report.d.ts.map +1 -0
  61. package/lib/types/fetch-route-intercept.d.ts +26 -0
  62. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  63. package/lib/types/index.d.ts +7 -1
  64. package/lib/types/index.d.ts.map +1 -1
  65. package/lib/types/intercept.d.ts +6 -0
  66. package/lib/types/intercept.d.ts.map +1 -1
  67. package/lib/types/log-ring.d.ts +57 -0
  68. package/lib/types/log-ring.d.ts.map +1 -0
  69. package/lib/types/plugin-adapter-core.d.ts.map +1 -1
  70. package/lib/types/plugin-route-intercept.d.ts +8 -0
  71. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  72. package/lib/types/plugin-tunnel.d.ts +37 -2
  73. package/lib/types/plugin-tunnel.d.ts.map +1 -1
  74. package/package.json +3 -2
  75. package/pwa/app.css +13 -2
  76. package/scripts/check-debug-report.mjs +277 -0
  77. package/scripts/check-info-sheet-stacking.mjs +85 -0
  78. package/scripts/check-remote-drop.mjs +130 -0
  79. package/scripts/check-tablet-safe-area.mjs +49 -0
  80. package/src/adapters/dsh-better-sidebar/index.ts +31 -2
  81. package/src/client/MobileSessionInfo.tsx +29 -2
  82. package/src/client/effects/remote-drop.ts +271 -24
  83. package/src/client/locales.ts +24 -4
  84. package/src/client/settings/SettingsSection.tsx +120 -3
  85. package/src/client/styles/content.css.ts +44 -14
  86. package/src/client/styles/layout.css.ts +9 -3
  87. package/src/client/styles/misc.css.ts +38 -0
  88. package/src/client-data/remote-drop.ts +103 -0
  89. package/src/client-data/settings-form.ts +3 -0
  90. package/src/client-pairing.ts +11 -0
  91. package/src/client-routes.ts +196 -90
  92. package/src/debug-redact.ts +290 -0
  93. package/src/debug-report.ts +93 -0
  94. package/src/fetch-route-intercept.ts +29 -0
  95. package/src/index.ts +295 -43
  96. package/src/intercept.ts +46 -0
  97. package/src/log-ring.ts +101 -0
  98. package/src/plugin-adapter-core.ts +29 -2
  99. package/src/plugin-route-intercept.ts +516 -12
  100. package/src/plugin-tunnel.ts +230 -20
package/lib/index.js CHANGED
@@ -34,7 +34,7 @@ import { createRelayClient, RelayError, volatileUpdateTouchesServerName } from '
34
34
  import { computeFingerprints } from './fingerprint.js';
35
35
  import { runSelfCheck, installIntercept } from './intercept.js';
36
36
  import { checkGatewayShape } from './intercept-shape.js';
37
- import { checkFetchRouteShape, installFetchRouteIntercept } from './fetch-route-intercept.js';
37
+ import { checkFetchRouteShape, installFetchRouteIntercept, shouldFallbackInstall } from './fetch-route-intercept.js';
38
38
  import { checkWebServerShape, installPluginRouteIntercept } from './plugin-route-intercept.js';
39
39
  import { normalizeAdapter, adapterOwnsRegistryEntry } from './plugin-adapter-core.js';
40
40
  import { ADAPTERS } from './adapters/index.js';
@@ -56,6 +56,7 @@ import { detectForwardedSource, parseProxyList } from './wizard/source-detect.js
56
56
  import { createVerificationManager } from './wizard/verify.js';
57
57
  import { createPluginLoopback } from './plugin-loopback.js';
58
58
  import { createPluginTunnel } from './plugin-tunnel.js';
59
+ import { createLogRing, createPluginLog } from './log-ring.js';
59
60
  import { DEFAULT_MAX_UPLOAD_BYTES } from './upload-limits.js';
60
61
  // The loader's config schema (settings form) and the role normalizer live in
61
62
  // src/config.ts next to the resolution they describe; re-exported so the
@@ -72,6 +73,20 @@ import * as gateway from '../lan-gate.mjs';
72
73
  import * as push from '../dsh-push.mjs';
73
74
  /** Exact route the phone composer POSTs one file body to. */
74
75
  export const UPLOAD_ROUTE = '/_dsh/mobile-nav/upload';
76
+ /**
77
+ * This package's own version, read once off the package root (`../package.json`
78
+ * resolves there both from src/ under the type-stripping scripts and from the
79
+ * built lib/ in production). The debug report's `_meta` plate carries it —
80
+ * issue triage starts at "which build is this" — and the row-apply log line
81
+ * stamps it. Unreadable file degrades to `'unknown'`, never throws.
82
+ */
83
+ let pluginVersion = 'unknown';
84
+ try {
85
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
86
+ if (typeof pkg?.version === 'string' && pkg.version !== '')
87
+ pluginVersion = pkg.version;
88
+ }
89
+ catch { /* keep 'unknown' */ }
75
90
  /**
76
91
  * The one piece of cordis the typert interception needs at RUNTIME: the
77
92
  * registered symbol under which every cordis proxy answers with its raw
@@ -357,8 +372,14 @@ async function writeUpload(req, directory, filename, maxBytes) {
357
372
  * request must ask the connection service itself, its refusal relayed
358
373
  * verbatim — same first wall as the admin and client routes. Optional only
359
374
  * for the direct-drive check scripts; production always passes it.
375
+ * @param warn - where the rejection line goes. apply() passes its double-
376
+ * writer (context logger + the CU-1 log ring), so a rejected upload — the
377
+ * very path issue #49 is triaged on — shows up in the debug report. The
378
+ * ring is per-apply on purpose (a row reload clears it), which is why it
379
+ * arrives as a parameter instead of a module singleton. Defaults to the
380
+ * context logger for the direct-drive check scripts.
360
381
  */
361
- export async function handleUpload(ctx, maxBytes, req, res, admit) {
382
+ export async function handleUpload(ctx, maxBytes, req, res, admit, warn) {
362
383
  if (admit !== undefined) {
363
384
  const admission = admit(req);
364
385
  if ('rejection' in admission) {
@@ -397,7 +418,10 @@ export async function handleUpload(ctx, maxBytes, req, res, admit) {
397
418
  catch (error) {
398
419
  const status = error instanceof UploadError ? error.status : 400;
399
420
  const code = error instanceof UploadError ? error.code : 'upload-rejected';
400
- ctx.logger.warn('dsh-mobile-nav upload rejected: %s', message(error));
421
+ if (warn !== undefined)
422
+ warn('dsh-mobile-nav upload rejected: %s', message(error));
423
+ else
424
+ ctx.logger.warn('dsh-mobile-nav upload rejected: %s', message(error));
401
425
  responseJson(res, status, { ok: false, error: { code, message: message(error) } });
402
426
  }
403
427
  }
@@ -423,7 +447,20 @@ export async function handleUpload(ctx, maxBytes, req, res, admit) {
423
447
  * request.
424
448
  */
425
449
  export function apply(ctx, config = {}) {
450
+ // CU-1: the plugin's own log ring (SPEC 方案 C) — rebuilt per apply, so a
451
+ // row reload clears it (expected: the report describes THIS process
452
+ // generation). `logWarn` / `logInfo` are the branch-shared double-writers:
453
+ // every line below reaches the context logger exactly as before and the
454
+ // ring besides, so the debug report (CU-2) can answer "what did the plugin
455
+ // actually log" without a log file (the desktop app writes none).
456
+ const ring = createLogRing();
457
+ const pluginLog = createPluginLog(ring, ctx.logger);
458
+ const logWarn = pluginLog.warn;
459
+ const logInfo = pluginLog.info;
426
460
  const effective = resolveConfig(config, readFileConfig(), process.env);
461
+ // Lifecycle: the row came up. Role and both versions are the first things
462
+ // an issue report needs.
463
+ ring.push('info', 'row apply: role=%s plugin=%s dsh=%s', effective.values.role, pluginVersion, resolveDshVersion());
427
464
  // RD-6 probe fact: whether the fetch-route inject callback (below, in the
428
465
  // client-half wiring) ever fired. On a composition where `connection` or
429
466
  // `fileUploads` never becomes available the callback stays silently
@@ -431,31 +468,45 @@ export function apply(ctx, config = {}) {
431
468
  // diagnostic stays undefined — this flag is the one honest signal of that
432
469
  // state. Declared this early because probes on BOTH role branches read it.
433
470
  let fetchRouteInjectSeen = false;
471
+ /** BU-1: how the fetch-route wrap got installed — `'inject'` via the
472
+ * primary `ctx.inject` callback, `'fallback'` via the bounded lazy timer
473
+ * below, `undefined` while not installed. Read live by the service probe. */
474
+ let fetchRouteInstalledVia;
475
+ /** BU-1: ISO timestamp of the moment a fallback attempt FAILED (shape
476
+ * refused, original unresolvable) — the tick stops after one, so this is
477
+ * also "the fallback gave up here". An install success does not set it. */
478
+ let fetchRouteFallbackTriedAt;
434
479
  /** The client branch's fetch-route diagnostics getter, assigned once that
435
480
  * branch declares it; the probe reads it lazily through this slot so both
436
481
  * role mounts can share one probe factory. */
437
482
  let fetchRouteDiagnosticsRef;
438
483
  /** RD-6: build the lazy service probe against a context whose own inject
439
484
  * already resolved (the client-routes mounts qualify on both roles). One
440
- * reflection per process: `connection` / `fileUploads` presence plus the
441
- * fetchRoutes table's shape — enough to name the missing service. */
485
+ * reflection per process for the SERVICE PRESENCE facts (below); the
486
+ * fetch-route facts are re-read live on every call — they change while the
487
+ * page is open (the inject may fire, or the fallback may step in, long
488
+ * after the first probe). */
442
489
  const uploadServiceProbe = (probeCtx) => {
443
490
  let cached;
444
491
  return () => {
445
- if (cached !== undefined)
446
- return cached;
447
- const reflect = probeCtx.reflect;
448
- const connection = reflect?.get('connection');
449
- const fileUploads = reflect?.get('fileUploads');
492
+ if (cached === undefined) {
493
+ const reflect = probeCtx.reflect;
494
+ const connection = reflect?.get('connection');
495
+ const fileUploads = reflect?.get('fileUploads');
496
+ cached = {
497
+ connectionService: connection === undefined ? 'absent' : 'present',
498
+ fileUploadsService: fileUploads === undefined ? 'absent' : 'present',
499
+ ...(connection === undefined ? {} : { connectionShape: checkFetchRouteShape(connection) }),
500
+ };
501
+ }
450
502
  const fetchRoute = fetchRouteDiagnosticsRef?.();
451
- cached = {
503
+ return {
452
504
  uploadInjectSeen: fetchRouteInjectSeen,
453
- connectionService: connection === undefined ? 'absent' : 'present',
454
- fileUploadsService: fileUploads === undefined ? 'absent' : 'present',
455
- ...(connection === undefined ? {} : { connectionShape: checkFetchRouteShape(connection) }),
505
+ installedVia: fetchRouteInstalledVia,
506
+ ...(fetchRouteFallbackTriedAt === undefined ? {} : { fallbackTriedAt: fetchRouteFallbackTriedAt }),
507
+ ...cached,
456
508
  ...(fetchRoute === undefined ? {} : { fetchRoute }),
457
509
  };
458
- return cached;
459
510
  };
460
511
  };
461
512
  // The plugin bridge's zenRemote service (PB-1), provided on BOTH roles
@@ -475,7 +526,7 @@ export function apply(ctx, config = {}) {
475
526
  // failing the row over — the bridge stays off, everything else keeps
476
527
  // working — so it degrades to a warning like the session feeds do.
477
528
  const registry = createBridgeRegistry({
478
- warn: (text) => { ctx.logger?.warn('dsh-zen-remote plugin bridge: %s', text); },
529
+ warn: (text) => { logWarn('dsh-zen-remote plugin bridge: %s', text); },
479
530
  });
480
531
  bridgeRegistry = registry;
481
532
  try {
@@ -485,7 +536,7 @@ export function apply(ctx, config = {}) {
485
536
  ctx.provide('zenRemote', service);
486
537
  }
487
538
  catch (error) {
488
- ctx.logger?.warn('dsh-zen-remote: cannot provide the "zenRemote" service: %s', message(error));
539
+ logWarn('dsh-zen-remote: cannot provide the "zenRemote" service: %s', message(error));
489
540
  }
490
541
  ctx.effect(() => () => {
491
542
  if (bridgeRegistry === registry)
@@ -510,7 +561,7 @@ export function apply(ctx, config = {}) {
510
561
  // halves.
511
562
  const adapters = [];
512
563
  {
513
- const adapterWarn = (text) => { ctx.logger?.warn('dsh-zen-remote adapter: %s', text); };
564
+ const adapterWarn = (text) => { logWarn('dsh-zen-remote adapter: %s', text); };
514
565
  for (const adapter of ADAPTERS) {
515
566
  try {
516
567
  const holder = ctx;
@@ -560,10 +611,10 @@ export function apply(ctx, config = {}) {
560
611
  registry.registerNormalized(normalized.declaration);
561
612
  adapters.push(normalized);
562
613
  if (unlisted && surfaceDigest !== undefined) {
563
- ctx.logger?.info('dsh-zen-remote adapter: %s bridged by surface digest %s (version %s unlisted)', normalized.plugin, surfaceDigest, installedVersion);
614
+ logInfo('dsh-zen-remote adapter: %s bridged by surface digest %s (version %s unlisted)', normalized.plugin, surfaceDigest, installedVersion);
564
615
  }
565
616
  else {
566
- ctx.logger?.info('dsh-zen-remote adapter: %s adapted at version %s', normalized.plugin, installedVersion);
617
+ logInfo('dsh-zen-remote adapter: %s adapted at version %s', normalized.plugin, installedVersion);
567
618
  }
568
619
  }
569
620
  catch (error) {
@@ -571,6 +622,19 @@ export function apply(ctx, config = {}) {
571
622
  }
572
623
  }
573
624
  }
625
+ // CU-3: the debug-report route's profile fact — read per call through the
626
+ // reflection layer's store lookup (a composition without profileContext
627
+ // answers undefined, and a throwing read degrades to undefined too).
628
+ const profileName = () => {
629
+ try {
630
+ const holder = ctx;
631
+ const profile = holder.get?.('profileContext');
632
+ return typeof profile?.name === 'string' && profile.name !== '' ? profile.name : undefined;
633
+ }
634
+ catch {
635
+ return undefined;
636
+ }
637
+ };
574
638
  if (effective.values.role === 'host') {
575
639
  // A row that switched from client to host must not leave a stale relay
576
640
  // client where getRelayClient() can find it (T23a-fix).
@@ -579,7 +643,27 @@ export function apply(ctx, config = {}) {
579
643
  // gateway child through LAN_GATE_RELAY_SECRET and kept here for the
580
644
  // relay routes below. The push half never sees it.
581
645
  const relaySecret = randomBytes(32).toString('hex');
582
- ctx.plugin(gateway, { ...effective.values, relaySecret });
646
+ // CU-1 lifecycle: the gateway child's spawn/exit, reported back through
647
+ // this hook (lan-gate.mjs reads it off the config object it is handed).
648
+ // The child runs in a subprocess whose console the ring cannot see, so
649
+ // these two lines are the debug report's only record of it. Either call
650
+ // must never break the gateway half — lan-gate.mjs guards its side, we
651
+ // guard ours. The hook rides as a NON-enumerable property: it is wiring,
652
+ // not configuration — the gateway's config stays exactly "resolved values
653
+ // + relaySecret" for every spread, deep compare and serialization (the
654
+ // role-wiring test pins that shape), while a direct read still finds it.
655
+ const gatewayEvent = (event, detail) => {
656
+ try {
657
+ if (event === 'spawn')
658
+ ring.push('info', 'gateway spawned (port=%s)', String(detail.port ?? '?'));
659
+ else
660
+ ring.push('warn', 'gateway exited (code=%s signal=%s error=%s)', String(detail.code ?? '—'), String(detail.signal ?? '—'), String(detail.error ?? '—'));
661
+ }
662
+ catch { /* the ring must never break the wiring */ }
663
+ };
664
+ const gatewayConfig = { ...effective.values, relaySecret };
665
+ Object.defineProperty(gatewayConfig, 'gatewayEvent', { value: gatewayEvent, enumerable: false });
666
+ ctx.plugin(gateway, gatewayConfig);
583
667
  ctx.plugin(push, effective.values);
584
668
  // The shared-session table backing the relay's access control. The relay
585
669
  // route consumes it below; the activity tracker and idle sweeper (T22c)
@@ -624,7 +708,7 @@ export function apply(ctx, config = {}) {
624
708
  });
625
709
  }
626
710
  catch (error) {
627
- ctx.logger.warn('dsh-zen-remote cannot listen on "session/event": %s', message(error));
711
+ logWarn('dsh-zen-remote cannot listen on "session/event": %s', message(error));
628
712
  }
629
713
  // Auto-share and fork-following (T33a), off `agent/created` — the only
630
714
  // host event that fires EXACTLY ONCE per NEW session. Its `source` is
@@ -647,6 +731,14 @@ export function apply(ctx, config = {}) {
647
731
  // best-effort bookkeeping — it must never be able to fail the
648
732
  // creation that triggered it.
649
733
  try {
734
+ // PB-26a: record the subagent→parent link the moment the child
735
+ // exists — for EVERY source. A sidechat child (better-sidebar's
736
+ // sidechat.start → agents.create) rides no workspace and writes
737
+ // no catalog; until its first session/event the index knew
738
+ // nothing of it, so the relay answered not-shared for a session
739
+ // whose share it inherits through its parent, and the forwarded
740
+ // status frames the client needs to announce the row never left.
741
+ parentIndex.observe(agent.session);
650
742
  if (source !== 'startup')
651
743
  return;
652
744
  onSessionCreated(agent.session.header, {
@@ -656,12 +748,12 @@ export function apply(ctx, config = {}) {
656
748
  });
657
749
  }
658
750
  catch (error) {
659
- ctx.logger.warn('dsh-zen-remote cannot share new session "%s": %s', agent.id, message(error));
751
+ logWarn('dsh-zen-remote cannot share new session "%s": %s', agent.id, message(error));
660
752
  }
661
753
  });
662
754
  }
663
755
  catch (error) {
664
- ctx.logger.warn('dsh-zen-remote cannot listen on "agent/created": %s', message(error));
756
+ logWarn('dsh-zen-remote cannot listen on "agent/created": %s', message(error));
665
757
  }
666
758
  // Busy restore (T33a): `busy` is never persisted, so right after a row
667
759
  // restart every shared session believes itself idle — including one whose
@@ -682,7 +774,7 @@ export function apply(ctx, config = {}) {
682
774
  ctx.inject(['sessionPersistence'], (persistenceCtx) => {
683
775
  const persistence = persistenceCtx.sessionPersistence;
684
776
  if (persistence === undefined) {
685
- ctx.logger.warn('dsh-zen-remote: no sessionPersistence service — pre-restart subagent sessions stay not-shared until they emit an event');
777
+ logWarn('dsh-zen-remote: no sessionPersistence service — pre-restart subagent sessions stay not-shared until they emit an event');
686
778
  return;
687
779
  }
688
780
  void persistence
@@ -690,13 +782,13 @@ export function apply(ctx, config = {}) {
690
782
  .then((snapshots) => {
691
783
  const recorded = seedParentIndexFromSnapshots(parentIndex, snapshots);
692
784
  if (recorded > 0)
693
- ctx.logger.info?.('dsh-zen-remote: restored %d persisted parent links', recorded);
785
+ logInfo('dsh-zen-remote: restored %d persisted parent links', recorded);
694
786
  })
695
- .catch((error) => ctx.logger.warn('dsh-zen-remote cannot seed parent links from persisted sessions: %s', message(error)));
787
+ .catch((error) => logWarn('dsh-zen-remote cannot seed parent links from persisted sessions: %s', message(error)));
696
788
  });
697
789
  }
698
790
  catch (error) {
699
- ctx.logger.warn('dsh-zen-remote cannot listen on "sessionPersistence": %s', message(error));
791
+ logWarn('dsh-zen-remote cannot listen on "sessionPersistence": %s', message(error));
700
792
  }
701
793
  // Settings-surface admin routes (T14). The browser cannot call the
702
794
  // gateway's loopback-only admin API from DSH's origin, so the host
@@ -1124,6 +1216,39 @@ export function apply(ctx, config = {}) {
1124
1216
  };
1125
1217
  }, 'dsh-zen-remote: relay route');
1126
1218
  });
1219
+ // CU-3 (host mount): the gateway child's summary plate for the debug
1220
+ // report — one best-effort probe of the gateway's own status endpoint
1221
+ // (the same seam the wizard's runtime probe reads) reduced to liveness,
1222
+ // port and COUNTS. The endpoint's device rows carry no tokens and we
1223
+ // keep none of their fields anyway; the share table's size is read off
1224
+ // the store this apply owns. The gateway being down IS the summary.
1225
+ const gatewaySummary = async () => {
1226
+ let reachable = false;
1227
+ let port;
1228
+ let deviceCount;
1229
+ let pushSubscriptions;
1230
+ try {
1231
+ const response = await fetch(`http://127.0.0.1:${effective.values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) });
1232
+ reachable = response.ok;
1233
+ if (reachable) {
1234
+ const body = await response.json();
1235
+ if (typeof body?.port === 'number')
1236
+ port = body.port;
1237
+ if (Array.isArray(body?.devices))
1238
+ deviceCount = body.devices.length;
1239
+ if (typeof body?.pushSubscriptions === 'number')
1240
+ pushSubscriptions = body.pushSubscriptions;
1241
+ }
1242
+ }
1243
+ catch { /* unreachable is an honest answer */ }
1244
+ return {
1245
+ reachable,
1246
+ ...(port === undefined ? {} : { port }),
1247
+ ...(deviceCount === undefined ? {} : { deviceCount }),
1248
+ ...(pushSubscriptions === undefined ? {} : { pushSubscriptions }),
1249
+ shareCount: store.list().length,
1250
+ };
1251
+ };
1127
1252
  // The remote-status route lives on BOTH roles (T34-fix): it is what the
1128
1253
  // client-half parts poll, and a desktop app can switch roles without a
1129
1254
  // code path change — on a host it reads the empty
@@ -1142,6 +1267,10 @@ export function apply(ctx, config = {}) {
1142
1267
  getRowConfig: () => config,
1143
1268
  remoteStatusOnly: true,
1144
1269
  getServiceProbe: uploadServiceProbe(clientCtx),
1270
+ getLogSnapshot: () => ring.snapshot(),
1271
+ getGatewaySummary: gatewaySummary,
1272
+ getProfileName: profileName,
1273
+ getPluginVersion: () => pluginVersion,
1145
1274
  }),
1146
1275
  }), 'dsh-zen-remote: client routes (host)');
1147
1276
  });
@@ -1183,6 +1312,24 @@ export function apply(ctx, config = {}) {
1183
1312
  // and `online` is the client's CURRENT state — so a registry change, a
1184
1313
  // fresh handshake or a link flip shows up at the very next read.
1185
1314
  const pluginBridgeStatus = () => compareBridges(registry.list(), relayClient.handshakeInfo?.plugins, relayClient.state === 'online');
1315
+ // CU-1 lifecycle: the relay link's flips enter the ring — `online` with
1316
+ // the server id it handshook, `offline` with the mapped failure code
1317
+ // (relayClient.lastError is a short code by contract, never free text).
1318
+ // subscribe re-announces the CURRENT state when only the handshake
1319
+ // identity moved (the T59 heartbeat), so the dedupe below keeps the
1320
+ // heartbeats out of the ring: one line per actual state change.
1321
+ let lastRelayRingState;
1322
+ const offRelayRing = relayClient.subscribe((state) => {
1323
+ if (state === lastRelayRingState)
1324
+ return;
1325
+ lastRelayRingState = state;
1326
+ if (state === 'online') {
1327
+ ring.push('info', 'relay link online (server=%s)', relayClient.handshakeInfo?.serverId ?? '?');
1328
+ }
1329
+ else if (state === 'offline') {
1330
+ ring.push('warn', 'relay link offline (code=%s)', relayClient.lastError ?? '?');
1331
+ }
1332
+ });
1186
1333
  // A stopped or reloaded row must not leave a stale instance where the
1187
1334
  // module getter can hand it out (T23a-fix), and its reconnect machinery
1188
1335
  // (T43) must not outlive the row either: stop() cancels the armed retry
@@ -1190,6 +1337,7 @@ export function apply(ctx, config = {}) {
1190
1337
  // dispose from clearing a NEWER row's instance that a reload already
1191
1338
  // installed.
1192
1339
  ctx.effect(() => () => {
1340
+ offRelayRing();
1193
1341
  relayClient.stop();
1194
1342
  if (clientRelayClient === relayClient)
1195
1343
  clientRelayClient = undefined;
@@ -1229,7 +1377,7 @@ export function apply(ctx, config = {}) {
1229
1377
  };
1230
1378
  }
1231
1379
  catch (error) {
1232
- ctx.logger.warn('dsh-zen-remote: cannot listen on "loader/volatile-update" for relay credentials: %s', message(error));
1380
+ logWarn('dsh-zen-remote: cannot listen on "loader/volatile-update" for relay credentials: %s', message(error));
1233
1381
  return () => { };
1234
1382
  }
1235
1383
  }, 'dsh-zen-remote: relay credentials watcher');
@@ -1241,7 +1389,7 @@ export function apply(ctx, config = {}) {
1241
1389
  if (error instanceof RelayError && error.code === 'unpaired')
1242
1390
  return;
1243
1391
  const code = error instanceof RelayError ? error.code : 'error';
1244
- ctx.logger.warn('dsh-zen-remote relay client initial connect failed (%s): %s', code, message(error));
1392
+ logWarn('dsh-zen-remote relay client initial connect failed (%s): %s', code, message(error));
1245
1393
  });
1246
1394
  // The typertGateway interception (T23b-1): remote-session calls ride the
1247
1395
  // relay, everything else reaches the local gateway untouched. The live
@@ -1308,9 +1456,6 @@ export function apply(ctx, config = {}) {
1308
1456
  return undefined;
1309
1457
  };
1310
1458
  ctx.inject(['typertGateway'], (gatewayCtx) => {
1311
- // Degraded logging: the real cordis context always carries a logger,
1312
- // but a row fake without one must not crash apply.
1313
- const logWarn = (...args) => { ctx.logger?.warn(...args); };
1314
1459
  const proxy = gatewayCtx.typertGateway;
1315
1460
  const raw = proxy !== null && typeof proxy === 'object'
1316
1461
  ? proxy[symbols.original]
@@ -1336,7 +1481,7 @@ export function apply(ctx, config = {}) {
1336
1481
  // PB-25b: each merged stream reports its real-session inventory
1337
1482
  // share into the union above.
1338
1483
  createSessionInventorySlot,
1339
- log: (format, ...args) => { ctx.logger?.info(`dsh-zen-remote ${format}`, ...args); },
1484
+ log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args); },
1340
1485
  });
1341
1486
  interceptHandle = handle;
1342
1487
  gatewayCtx.effect(() => () => {
@@ -1359,7 +1504,7 @@ export function apply(ctx, config = {}) {
1359
1504
  void runSelfCheck(raw, {
1360
1505
  handle,
1361
1506
  log: logWarn,
1362
- info: (format, ...args) => { ctx.logger?.info(`dsh-zen-remote ${format}`, ...args); },
1507
+ info: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args); },
1363
1508
  getPeer: createOwnerPeerSource(() => gatewayCtx.reflect.get('connection')),
1364
1509
  });
1365
1510
  });
@@ -1403,8 +1548,13 @@ export function apply(ctx, config = {}) {
1403
1548
  fetchRouteDiagnosticsRef = fetchRouteDiagnostics;
1404
1549
  ctx.inject(['connection', 'fileUploads'], (connectionCtx) => {
1405
1550
  fetchRouteInjectSeen = true;
1406
- const logWarn = (...args) => { ctx.logger?.warn(...args); };
1407
- const logInfo = (...args) => { ctx.logger?.info(...args); };
1551
+ // BU-1 double-install guard: the fallback timer may have installed
1552
+ // while this callback sat PENDING — or recorded a refusal. Either way
1553
+ // this primary path has nothing left to do; installing again would
1554
+ // chain a second wrap over the first (behaviorally right, structurally
1555
+ // dirty) and overwrite the diagnostics' installedVia.
1556
+ if (fetchRouteHandle !== undefined || fetchRouteRefused !== undefined)
1557
+ return;
1408
1558
  const proxy = connectionCtx.connection;
1409
1559
  const raw = proxy !== null && typeof proxy === 'object'
1410
1560
  ? proxy[symbols.original]
@@ -1430,12 +1580,112 @@ export function apply(ctx, config = {}) {
1430
1580
  log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args); },
1431
1581
  });
1432
1582
  fetchRouteHandle = handle;
1583
+ fetchRouteInstalledVia = 'inject';
1584
+ // CU-1 lifecycle: a successful install logs nothing today (the wrap's
1585
+ // own log lines only fire per call), so the ring gets one explicit
1586
+ // record — the debug report's "did the redirect actually attach" fact.
1587
+ // The refused and fallback paths funnel through logWarn/logInfo below
1588
+ // and enter the ring that way.
1589
+ ring.push('info', 'fetch-route intercept attached (via=inject)');
1433
1590
  connectionCtx.effect(() => () => {
1434
1591
  handle.uninstall();
1435
- if (fetchRouteHandle === handle)
1592
+ if (fetchRouteHandle === handle) {
1436
1593
  fetchRouteHandle = undefined;
1594
+ fetchRouteInstalledVia = undefined;
1595
+ }
1437
1596
  }, 'dsh-zen-remote: fetch-route intercept');
1438
1597
  });
1598
+ // BU-1: the fetch-route FALLBACK install (SPEC §2). RD-6 proved the
1599
+ // machine-local failure mode: on one Windows client the
1600
+ // `['connection','fileUploads']` inject above sat silently PENDING (a
1601
+ // stuck row keeps cordis waiting forever, printing nothing), the wrap
1602
+ // never installed, and every remote upload fell through to the local
1603
+ // backend. The inject stays the FIRST choice — this timer only acts when
1604
+ // it demonstrably has not fired while BOTH services are present in the
1605
+ // reflection layer, then installs through the same
1606
+ // checkFetchRouteShape → symbols.original → installFetchRouteIntercept
1607
+ // path, sharing the handle/refused slots (which is also the
1608
+ // double-install guard: whichever path runs first, the other sees the
1609
+ // slots set and stands down). Bounded: a 3 s tick, at most 20 (a minute)
1610
+ // — a composition that never ships the services stops probing; unref'd
1611
+ // and cleared with the row.
1612
+ let fallbackTimer;
1613
+ let fallbackAttempts = 0;
1614
+ const stopFallbackTimer = () => {
1615
+ if (fallbackTimer !== undefined) {
1616
+ clearInterval(fallbackTimer);
1617
+ fallbackTimer = undefined;
1618
+ }
1619
+ };
1620
+ fallbackTimer = setInterval(() => {
1621
+ // The tick budget is checked BEFORE acting but does not cancel the
1622
+ // current tick: attempt #20 still runs, no tick #21 ever comes.
1623
+ fallbackAttempts += 1;
1624
+ if (fallbackAttempts >= 20)
1625
+ stopFallbackTimer();
1626
+ // Same reflection the service probe reads: both services must be
1627
+ // PRESENT — their absence means a legitimate startup wait (or a
1628
+ // composition without them), where the inject must stay first choice.
1629
+ const reflect = ctx.reflect;
1630
+ const connection = reflect?.get('connection');
1631
+ const fileUploads = reflect?.get('fileUploads');
1632
+ if (!shouldFallbackInstall({
1633
+ injectSeen: fetchRouteInjectSeen,
1634
+ handleSet: fetchRouteHandle !== undefined,
1635
+ refused: fetchRouteRefused !== undefined,
1636
+ servicesPresent: connection !== undefined && fileUploads !== undefined,
1637
+ })) {
1638
+ // The services being absent is a WAIT (next tick re-judges); the
1639
+ // primary path having advanced is a VERDICT — the fallback has no
1640
+ // business left, stop probing.
1641
+ if (fetchRouteInjectSeen || fetchRouteHandle !== undefined || fetchRouteRefused !== undefined)
1642
+ stopFallbackTimer();
1643
+ return;
1644
+ }
1645
+ // Shape against the proxy first (fetchRoutes reads through it); the
1646
+ // raw instance comes off the same symbols slot the inject path uses.
1647
+ const shape = checkFetchRouteShape(connection);
1648
+ if (!shape.ok) {
1649
+ // Structural mismatch is a verdict, not a wait: record the attempt
1650
+ // and stop. A later inject trigger may still install (it checks the
1651
+ // RAW instance, which can differ from what the proxy exposed).
1652
+ fetchRouteFallbackTriedAt = new Date().toISOString();
1653
+ stopFallbackTimer();
1654
+ logWarn('dsh-zen-remote fetch-route fallback declined: %s', shape.reasons.join('; '));
1655
+ return;
1656
+ }
1657
+ const raw = connection !== null && typeof connection === 'object'
1658
+ ? connection[symbols.original]
1659
+ : undefined;
1660
+ if (raw === null || typeof raw !== 'object') {
1661
+ fetchRouteFallbackTriedAt = new Date().toISOString();
1662
+ stopFallbackTimer();
1663
+ logWarn('dsh-zen-remote fetch-route fallback declined: %s', 'original: connection[symbols.original] is not an object');
1664
+ return;
1665
+ }
1666
+ const handle = installFetchRouteIntercept({
1667
+ connection: raw,
1668
+ relay: relayClient,
1669
+ getServerId: () => relayClient.handshakeInfo?.serverId,
1670
+ log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args); },
1671
+ });
1672
+ fetchRouteHandle = handle;
1673
+ fetchRouteInstalledVia = 'fallback';
1674
+ stopFallbackTimer();
1675
+ logInfo('dsh-zen-remote fetch-route intercept installed via the fallback timer (inject never fired)');
1676
+ // Same disposal contract as the inject path: the wrap dies with the
1677
+ // row (the fallback has no service context of its own to hang on).
1678
+ ctx.effect(() => () => {
1679
+ handle.uninstall();
1680
+ if (fetchRouteHandle === handle) {
1681
+ fetchRouteHandle = undefined;
1682
+ fetchRouteInstalledVia = undefined;
1683
+ }
1684
+ }, 'dsh-zen-remote: fetch-route intercept (fallback)');
1685
+ }, 3_000);
1686
+ if (typeof fallbackTimer.unref === 'function')
1687
+ fallbackTimer.unref();
1688
+ ctx.effect(() => () => { stopFallbackTimer(); }, 'dsh-zen-remote: fetch-route fallback timer');
1439
1689
  // The plugin bridge's route interception (PB-4): the local webServer's
1440
1690
  // `match` wrap forwarding bridged-plugin requests for REMOTE sessions
1441
1691
  // through the relay. Injected on `webServer` alone — the only service
@@ -1467,7 +1717,6 @@ export function apply(ctx, config = {}) {
1467
1717
  return undefined;
1468
1718
  };
1469
1719
  ctx.inject(['webServer'], (wsCtx) => {
1470
- const logWarn = (...args) => { ctx.logger?.warn(...args); };
1471
1720
  const proxy = wsCtx.webServer;
1472
1721
  const raw = proxy !== null && typeof proxy === 'object'
1473
1722
  ? proxy[symbols.original]
@@ -1501,7 +1750,12 @@ export function apply(ctx, config = {}) {
1501
1750
  // answer — the rewriteIds deep scan rewrites an id that already sits
1502
1751
  // in the browser's view even when this request never carried it.
1503
1752
  getKnownRealIds: () => knownRealIds,
1504
- log: (format, ...args) => { ctx.logger?.info(`dsh-zen-remote ${format}`, ...args); },
1753
+ // PB-26a: a response rewrite that minted a virtual id (a sidechat
1754
+ // child from `rewriteIds.fields` or an adapter's `virtualize`) is the
1755
+ // client's only early notice of a row that rides no workspace/follow
1756
+ // frame — hand it to the summary sync so the sidebar row appears.
1757
+ onMintedVirtualIds: (ids) => { interceptHandle?.nudgeSummarySync(ids); },
1758
+ log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args); },
1505
1759
  });
1506
1760
  pluginRouteHandle = handle;
1507
1761
  wsCtx.effect(() => () => {
@@ -1524,6 +1778,9 @@ export function apply(ctx, config = {}) {
1524
1778
  getPluginRouteIntercept: pluginRouteDiagnostics,
1525
1779
  getPluginBridgeStatus: pluginBridgeStatus,
1526
1780
  onCurrentSession,
1781
+ getLogSnapshot: () => ring.snapshot(),
1782
+ getProfileName: profileName,
1783
+ getPluginVersion: () => pluginVersion,
1527
1784
  }),
1528
1785
  }), 'dsh-zen-remote: client routes');
1529
1786
  });
@@ -1550,18 +1807,22 @@ export function apply(ctx, config = {}) {
1550
1807
  // dispose-and-reload entry. A fake context without a fiber (the route
1551
1808
  // tests) only ever reaches the warn.
1552
1809
  const reload = () => {
1810
+ // CU-1 lifecycle: the row is about to reload — the ring dies with this
1811
+ // apply right after, so this line is the reload's only witness in the
1812
+ // NEXT generation's report (the fresh apply's row-apply line follows).
1813
+ ring.push('info', 'row reload: a restart-required setting changed, reloading the plugin row');
1553
1814
  const fiber = ctx.fiber;
1554
1815
  if (fiber?.restart === undefined) {
1555
- ctx.logger.warn('dsh-zen-remote: a restart-required setting changed, but no fiber is available to reload the plugin row');
1816
+ logWarn('dsh-zen-remote: a restart-required setting changed, but no fiber is available to reload the plugin row');
1556
1817
  return;
1557
1818
  }
1558
1819
  try {
1559
1820
  Promise.resolve(fiber.restart()).catch((error) => {
1560
- ctx.logger.warn('dsh-zen-remote: plugin row reload failed: %s', message(error));
1821
+ logWarn('dsh-zen-remote: plugin row reload failed: %s', message(error));
1561
1822
  });
1562
1823
  }
1563
1824
  catch (error) {
1564
- ctx.logger.warn('dsh-zen-remote: plugin row reload failed: %s', message(error));
1825
+ logWarn('dsh-zen-remote: plugin row reload failed: %s', message(error));
1565
1826
  }
1566
1827
  };
1567
1828
  ctx.effect(() => {
@@ -1575,7 +1836,7 @@ export function apply(ctx, config = {}) {
1575
1836
  ctx.on('loader/volatile-update', () => { watcher.checkNow(); });
1576
1837
  }
1577
1838
  catch (error) {
1578
- ctx.logger.warn('dsh-zen-remote: cannot listen on "loader/volatile-update": %s', message(error));
1839
+ logWarn('dsh-zen-remote: cannot listen on "loader/volatile-update": %s', message(error));
1579
1840
  }
1580
1841
  return watcher.stop;
1581
1842
  }, 'dsh-zen-remote: restart watcher');
@@ -1601,7 +1862,7 @@ export function apply(ctx, config = {}) {
1601
1862
  // and a rejection escaping it must reach webServer's unified error
1602
1863
  // handling instead of vanishing into an unhandled rejection (T17b
1603
1864
  // restored the await-shaped contract the route used to have).
1604
- return handleUpload(webCtx, maxBytes, req, res, (admitReq) => webCtx.connection.admit(admitReq));
1865
+ return handleUpload(webCtx, maxBytes, req, res, (admitReq) => webCtx.connection.admit(admitReq), logWarn);
1605
1866
  },
1606
1867
  }), 'dsh-mobile-nav: upload route');
1607
1868
  });