dsh-zen-remote 2.3.11 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/CHANGELOG.md +45 -78
  2. package/README.md +94 -84
  3. package/README.zh-CN.md +90 -81
  4. package/bin/zen-remote.mjs +5 -0
  5. package/docs/interface.md +6 -6
  6. package/docs/remote-access.en.md +6 -4
  7. package/docs/remote-access.md +6 -4
  8. package/dsh-push.mjs +96 -10
  9. package/lib/adapters/dsh-better-sidebar/index.js +7 -3
  10. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  11. package/lib/admin-routes.js +10 -2
  12. package/lib/admin-routes.js.map +1 -1
  13. package/lib/cli/args.js +251 -0
  14. package/lib/cli/args.js.map +1 -0
  15. package/lib/cli/bootstrap.js +449 -0
  16. package/lib/cli/bootstrap.js.map +1 -0
  17. package/lib/cli/command-setup.js +484 -0
  18. package/lib/cli/command-setup.js.map +1 -0
  19. package/lib/cli/commands-client.js +359 -0
  20. package/lib/cli/commands-client.js.map +1 -0
  21. package/lib/cli/commands-host.js +1108 -0
  22. package/lib/cli/commands-host.js.map +1 -0
  23. package/lib/cli/commands-status.js +279 -0
  24. package/lib/cli/commands-status.js.map +1 -0
  25. package/lib/cli/config-writer.js +334 -0
  26. package/lib/cli/config-writer.js.map +1 -0
  27. package/lib/cli/context.js +10 -0
  28. package/lib/cli/context.js.map +1 -0
  29. package/lib/cli/i18n.js +620 -0
  30. package/lib/cli/i18n.js.map +1 -0
  31. package/lib/cli/ipc-client.js +160 -0
  32. package/lib/cli/ipc-client.js.map +1 -0
  33. package/lib/cli/ipc-server.js +464 -0
  34. package/lib/cli/ipc-server.js.map +1 -0
  35. package/lib/cli/main.js +245 -0
  36. package/lib/cli/main.js.map +1 -0
  37. package/lib/cli/qr-terminal.js +48 -0
  38. package/lib/cli/qr-terminal.js.map +1 -0
  39. package/lib/client-data/qr.js +286 -0
  40. package/lib/client-data/qr.js.map +1 -0
  41. package/lib/client-routes.js +308 -25
  42. package/lib/client-routes.js.map +1 -1
  43. package/lib/client.js +1013 -229
  44. package/lib/config.js +50 -12
  45. package/lib/config.js.map +1 -1
  46. package/lib/debug-report.js +28 -1
  47. package/lib/debug-report.js.map +1 -1
  48. package/lib/fetch-route-intercept.js +334 -26
  49. package/lib/fetch-route-intercept.js.map +1 -1
  50. package/lib/index.js +608 -375
  51. package/lib/index.js.map +1 -1
  52. package/lib/intercept.js +1833 -280
  53. package/lib/intercept.js.map +1 -1
  54. package/lib/lan-gate-server.cjs +36 -10
  55. package/lib/merge-streams.js +103 -11
  56. package/lib/merge-streams.js.map +1 -1
  57. package/lib/plugin-route-intercept.js +93 -63
  58. package/lib/plugin-route-intercept.js.map +1 -1
  59. package/lib/relay-access.js +8 -1
  60. package/lib/relay-access.js.map +1 -1
  61. package/lib/relay-client.js +133 -4
  62. package/lib/relay-client.js.map +1 -1
  63. package/lib/relay-pool.js +275 -0
  64. package/lib/relay-pool.js.map +1 -0
  65. package/lib/relay-server.js +269 -17
  66. package/lib/relay-server.js.map +1 -1
  67. package/lib/restart-fields.js +6 -4
  68. package/lib/restart-fields.js.map +1 -1
  69. package/lib/share-ops.js +68 -0
  70. package/lib/share-ops.js.map +1 -1
  71. package/lib/share-store.js +65 -27
  72. package/lib/share-store.js.map +1 -1
  73. package/lib/state-store.js +400 -0
  74. package/lib/state-store.js.map +1 -0
  75. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
  76. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  77. package/lib/types/admin-routes.d.ts.map +1 -1
  78. package/lib/types/cli/args.d.ts +78 -0
  79. package/lib/types/cli/args.d.ts.map +1 -0
  80. package/lib/types/cli/bootstrap.d.ts +170 -0
  81. package/lib/types/cli/bootstrap.d.ts.map +1 -0
  82. package/lib/types/cli/command-setup.d.ts +72 -0
  83. package/lib/types/cli/command-setup.d.ts.map +1 -0
  84. package/lib/types/cli/commands-client.d.ts +36 -0
  85. package/lib/types/cli/commands-client.d.ts.map +1 -0
  86. package/lib/types/cli/commands-host.d.ts +82 -0
  87. package/lib/types/cli/commands-host.d.ts.map +1 -0
  88. package/lib/types/cli/commands-status.d.ts +46 -0
  89. package/lib/types/cli/commands-status.d.ts.map +1 -0
  90. package/lib/types/cli/config-writer.d.ts +92 -0
  91. package/lib/types/cli/config-writer.d.ts.map +1 -0
  92. package/lib/types/cli/context.d.ts +39 -0
  93. package/lib/types/cli/context.d.ts.map +1 -0
  94. package/lib/types/cli/i18n.d.ts +32 -0
  95. package/lib/types/cli/i18n.d.ts.map +1 -0
  96. package/lib/types/cli/ipc-client.d.ts +83 -0
  97. package/lib/types/cli/ipc-client.d.ts.map +1 -0
  98. package/lib/types/cli/ipc-server.d.ts +113 -0
  99. package/lib/types/cli/ipc-server.d.ts.map +1 -0
  100. package/lib/types/cli/main.d.ts +57 -0
  101. package/lib/types/cli/main.d.ts.map +1 -0
  102. package/lib/types/cli/qr-terminal.d.ts +17 -0
  103. package/lib/types/cli/qr-terminal.d.ts.map +1 -0
  104. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
  105. package/lib/types/client/current-session-report.d.ts +4 -0
  106. package/lib/types/client/current-session-report.d.ts.map +1 -1
  107. package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
  108. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  109. package/lib/types/client/locales.d.ts +47 -3
  110. package/lib/types/client/locales.d.ts.map +1 -1
  111. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  112. package/lib/types/client/settings/settings-css.d.ts +1 -1
  113. package/lib/types/client/settings/settings-css.d.ts.map +1 -1
  114. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  115. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  116. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
  117. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
  118. package/lib/types/client/wizard/wizard-css.d.ts +1 -1
  119. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
  120. package/lib/types/client-data/model-group-side.d.ts +23 -2
  121. package/lib/types/client-data/model-group-side.d.ts.map +1 -1
  122. package/lib/types/client-data/remote-status.d.ts +47 -1
  123. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  124. package/lib/types/client-data/settings-form.d.ts +85 -1
  125. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  126. package/lib/types/client-data/wizard.d.ts +56 -1
  127. package/lib/types/client-data/wizard.d.ts.map +1 -1
  128. package/lib/types/client-routes.d.ts +25 -1
  129. package/lib/types/client-routes.d.ts.map +1 -1
  130. package/lib/types/config.d.ts +40 -14
  131. package/lib/types/config.d.ts.map +1 -1
  132. package/lib/types/debug-report.d.ts.map +1 -1
  133. package/lib/types/fetch-route-intercept.d.ts +66 -8
  134. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  135. package/lib/types/index.d.ts +9 -2
  136. package/lib/types/index.d.ts.map +1 -1
  137. package/lib/types/intercept.d.ts +72 -10
  138. package/lib/types/intercept.d.ts.map +1 -1
  139. package/lib/types/merge-streams.d.ts +79 -1
  140. package/lib/types/merge-streams.d.ts.map +1 -1
  141. package/lib/types/plugin-route-intercept.d.ts +25 -9
  142. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  143. package/lib/types/relay-access.d.ts +3 -1
  144. package/lib/types/relay-access.d.ts.map +1 -1
  145. package/lib/types/relay-client.d.ts +23 -2
  146. package/lib/types/relay-client.d.ts.map +1 -1
  147. package/lib/types/relay-pool.d.ts +148 -0
  148. package/lib/types/relay-pool.d.ts.map +1 -0
  149. package/lib/types/relay-server.d.ts +30 -3
  150. package/lib/types/relay-server.d.ts.map +1 -1
  151. package/lib/types/restart-fields.d.ts +5 -5
  152. package/lib/types/restart-fields.d.ts.map +1 -1
  153. package/lib/types/share-ops.d.ts +47 -1
  154. package/lib/types/share-ops.d.ts.map +1 -1
  155. package/lib/types/share-store.d.ts.map +1 -1
  156. package/lib/types/state-store.d.ts +179 -0
  157. package/lib/types/state-store.d.ts.map +1 -0
  158. package/lib/types/upload-limits.d.ts +15 -0
  159. package/lib/types/upload-limits.d.ts.map +1 -1
  160. package/lib/types/virtual-id.d.ts +21 -0
  161. package/lib/types/virtual-id.d.ts.map +1 -1
  162. package/lib/types/wizard/probe.d.ts.map +1 -1
  163. package/lib/types/wizard/proxy-snippets.d.ts +3 -0
  164. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
  165. package/lib/types/wizard/routes.d.ts +1 -0
  166. package/lib/types/wizard/routes.d.ts.map +1 -1
  167. package/lib/types/wizard/status.d.ts +9 -0
  168. package/lib/types/wizard/status.d.ts.map +1 -1
  169. package/lib/types/wizard/verify.d.ts +3 -0
  170. package/lib/types/wizard/verify.d.ts.map +1 -1
  171. package/lib/upload-limits.js +15 -0
  172. package/lib/upload-limits.js.map +1 -1
  173. package/lib/virtual-id.js +60 -0
  174. package/lib/virtual-id.js.map +1 -1
  175. package/lib/wizard/probe.js +46 -1
  176. package/lib/wizard/probe.js.map +1 -1
  177. package/lib/wizard/proxy-snippets.js +14 -1
  178. package/lib/wizard/proxy-snippets.js.map +1 -1
  179. package/lib/wizard/routes.js +23 -4
  180. package/lib/wizard/routes.js.map +1 -1
  181. package/lib/wizard/status.js +21 -0
  182. package/lib/wizard/status.js.map +1 -1
  183. package/lib/wizard/verify.js +31 -8
  184. package/lib/wizard/verify.js.map +1 -1
  185. package/package.json +8 -4
  186. package/pwa/icons/badge-96.png +0 -0
  187. package/pwa/icons/badge.svg +1 -0
  188. package/pwa/manifest.json +2 -1
  189. package/pwa/sw.js +3 -1
  190. package/scripts/check-debug-report.mjs +41 -1
  191. package/scripts/check-settings-form.mjs +14 -3
  192. package/scripts/check-settings-view.mjs +247 -0
  193. package/scripts/check-wizard-ui.mjs +37 -0
  194. package/src/adapters/dsh-better-sidebar/index.ts +7 -3
  195. package/src/admin-routes.ts +10 -2
  196. package/src/cli/args.ts +289 -0
  197. package/src/cli/bootstrap.ts +569 -0
  198. package/src/cli/command-setup.ts +540 -0
  199. package/src/cli/commands-client.ts +369 -0
  200. package/src/cli/commands-host.ts +1127 -0
  201. package/src/cli/commands-status.ts +296 -0
  202. package/src/cli/config-writer.ts +354 -0
  203. package/src/cli/context.ts +38 -0
  204. package/src/cli/i18n.ts +645 -0
  205. package/src/cli/ipc-client.ts +216 -0
  206. package/src/cli/ipc-server.ts +531 -0
  207. package/src/cli/main.ts +285 -0
  208. package/src/cli/qr-terminal.ts +49 -0
  209. package/src/client/RemoteStatusIcon.tsx +4 -1
  210. package/src/client/current-session-report.ts +28 -4
  211. package/src/client/effects/model-group-side.ts +19 -5
  212. package/src/client/effects/remote-session.ts +26 -6
  213. package/src/client/locales.ts +96 -6
  214. package/src/client/settings/SettingsSection.tsx +287 -92
  215. package/src/client/settings/settings-css.ts +18 -0
  216. package/src/client/styles/remote-session.css.ts +15 -1
  217. package/src/client/wizard/GatewayWizard.tsx +470 -109
  218. package/src/client/wizard/cards/ProxyCard.tsx +2 -1
  219. package/src/client/wizard/wizard-css.ts +23 -0
  220. package/src/client-data/model-group-side.ts +37 -10
  221. package/src/client-data/remote-status.ts +112 -17
  222. package/src/client-data/settings-form.ts +164 -8
  223. package/src/client-data/wizard.ts +112 -2
  224. package/src/client-routes.ts +334 -26
  225. package/src/config.ts +62 -16
  226. package/src/debug-report.ts +27 -1
  227. package/src/fetch-route-intercept.ts +381 -32
  228. package/src/index.ts +608 -380
  229. package/src/intercept.ts +1866 -286
  230. package/src/merge-streams.ts +155 -10
  231. package/src/plugin-route-intercept.ts +120 -71
  232. package/src/relay-access.ts +14 -3
  233. package/src/relay-client.ts +163 -5
  234. package/src/relay-pool.ts +387 -0
  235. package/src/relay-server.ts +312 -16
  236. package/src/restart-fields.ts +6 -4
  237. package/src/share-ops.ts +82 -1
  238. package/src/share-store.ts +59 -26
  239. package/src/state-store.ts +428 -0
  240. package/src/upload-limits.ts +16 -0
  241. package/src/virtual-id.ts +57 -0
  242. package/src/wizard/probe.ts +54 -2
  243. package/src/wizard/proxy-snippets.ts +17 -2
  244. package/src/wizard/routes.ts +24 -5
  245. package/src/wizard/status.ts +28 -0
  246. package/src/wizard/verify.ts +33 -11
  247. package/AGENTS.md +0 -185
  248. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
  249. package/docs/adr/0002-relay-emulates-websocket.md +0 -8
@@ -183,6 +183,9 @@ interface RelayMethod {
183
183
  * forwarded. Like the global reads it claims no session field — access is
184
184
  * judged per frame / per answer. */
185
185
  events?: true
186
+ /** Set on duplex streams (`terminal/attach` in DSH 0.2.2+): the stream
187
+ * route accepts an `uplink` queue and exposes it to `/v1/stream-uplink`. */
188
+ uplink?: true
186
189
  }
187
190
 
188
191
  /**
@@ -302,6 +305,11 @@ const RELAY_METHODS: Record<string, RelayMethod> = {
302
305
  'terminal/rename': { fields: ['agentId'] },
303
306
  'terminal/close': { fields: ['agentId'] },
304
307
  'terminal/follow': { fields: ['agentId'], stream: true },
308
+ // TA-1: the 0.2.2 duplex stream — follow+write+resize merged into one
309
+ // subscription with an `uplink` half (keystrokes/resize from the client).
310
+ // Same attachment semantics as follow (exclusive input control), same
311
+ // server-side trust premise; follow/write/resize stay for ≤ 0.2.1 hosts.
312
+ 'terminal/attach': { fields: ['agentId'], stream: true, uplink: true },
305
313
  // the retain/list half locates by a plain top-level json sessionId
306
314
  'terminal/list': { fields: ['sessionId'] },
307
315
  'terminal/retain': { fields: ['sessionId'], stream: true },
@@ -350,9 +358,10 @@ export type InvokeDecision = { allow: true; filter?: InvokeFilter } | { allow: f
350
358
  /** One stream decision: allow (global streams carry a `streamFilter`, scoped
351
359
  * streams list the session ids the subscription depends on — the relay kills
352
360
  * the stream and counts viewers with them, and the event subscription sets
353
- * `events`), or the 403 reason. */
361
+ * `events`; a duplex entry sets `uplink` so the route arms the
362
+ * `/v1/stream-uplink` channel), or the 403 reason. */
354
363
  export type StreamDecision =
355
- | { allow: true; filter?: StreamFilter; sessionIds: string[]; events?: true }
364
+ | { allow: true; filter?: StreamFilter; sessionIds: string[]; events?: true; uplink?: true }
356
365
  | { allow: false; reason: InvokeDenyReason }
357
366
 
358
367
  function isPlainObject(value: unknown): value is Record<string, unknown> {
@@ -496,7 +505,9 @@ export function decideStream(
496
505
  for (const id of claimed.ids) {
497
506
  if (!isAccessible(id)) return { allow: false, reason: 'not-shared' }
498
507
  }
499
- return { allow: true, sessionIds: claimed.ids }
508
+ return entry.uplink === true
509
+ ? { allow: true, sessionIds: claimed.ids, uplink: true }
510
+ : { allow: true, sessionIds: claimed.ids }
500
511
  }
501
512
 
502
513
  /**
@@ -57,6 +57,9 @@ const RELAY_PROTOCOL = 1
57
57
  const HANDSHAKE_PATH = '/_dsh/zen-remote/relay/v1/handshake'
58
58
  const INVOKE_PATH = '/_dsh/zen-remote/relay/v1/invoke'
59
59
  const STREAM_PATH = '/_dsh/zen-remote/relay/v1/stream'
60
+ /** TA-1: the duplex uplink's post route — one JSON body of frames per call,
61
+ * sequenced by the pump below. */
62
+ const STREAM_UPLINK_PATH = '/_dsh/zen-remote/relay/v1/stream-uplink'
60
63
  /** the relay's existing authenticated health route
61
64
  * (`GET …/relay/ping`, answers `{ok:true}`). Used by probeHealth() only. */
62
65
  const PING_PATH = '/_dsh/zen-remote/relay/ping'
@@ -360,8 +363,12 @@ export interface RelayClient {
360
363
  /** Open the NDJSON stream route. `frame` lines are yielded, `ping` lines
361
364
  * only refresh the idle clock, `end` finishes the iteration, an `error`
362
365
  * line throws its RelayError. A caller abort ENDS the iteration normally;
363
- * `break` aborts the underlying request. */
364
- openStream(namespace: string, method: string, args: unknown, signal?: AbortSignal): AsyncIterable<unknown>
366
+ * `break` aborts the underlying request. TA-1: with `options.uplink` the
367
+ * stream is DUPLEX — the server's handshake line names the stream id and
368
+ * the pump forwards the host's frames through `/v1/stream-uplink`
369
+ * (single-flight batched, strictly ordered); an uplink refusal surfaces as
370
+ * the stream's error, and the host iterable is always released. */
371
+ openStream(namespace: string, method: string, args: unknown, signal?: AbortSignal, options?: RelayStreamOptions): AsyncIterable<unknown>
365
372
  /** One plain-HTTP round-trip (T41b): `route` / `query` name a registered
366
373
  * `/api` GET (relay-access's RELAY_HTTP_ROUTES; `query` is the URL-encoded
367
374
  * query string with the session id already restored). Resolves with the
@@ -469,6 +476,21 @@ export interface RelayHttpResult {
469
476
  body: string
470
477
  }
471
478
 
479
+ /**
480
+ * The duplex-stream options (TA-1). `uplink` is the HOST-side frame source
481
+ * (the local `UplinkInbox` a `terminal/attach` session writes keystrokes and
482
+ * resize frames into); while the stream is open the pump below forwards its
483
+ * frames to the server through `/v1/stream-uplink`, single-flight batched,
484
+ * and — on stream exit — calls its `return()` so the host inbox is released
485
+ * even when the pump was parked mid-read. `uplinkWindow` is the host's own
486
+ * inbox budget, forwarded verbatim so the relay's bounded queue and the
487
+ * host's overflow accounting mean the same thing.
488
+ */
489
+ export interface RelayStreamOptions {
490
+ uplink?: AsyncIterable<unknown>
491
+ uplinkWindow?: { maxBytes: number; frameOverheadBytes: number }
492
+ }
493
+
472
494
  /**
473
495
  * The credential digest recorded beside a completed handshake (T23a-fix):
474
496
  * sha256 over `url + "\n" + token`, first 16 hex chars. Enough to detect a
@@ -522,6 +544,7 @@ type LineVerdict =
522
544
  | { kind: 'end' }
523
545
  | { kind: 'error'; error: RelayError }
524
546
  | { kind: 'info'; serverName?: string; deviceName?: string }
547
+ | { kind: 'uplink'; streamId: string }
525
548
 
526
549
  /** A string field of an info line, or undefined — absent, blank or not a string. */
527
550
  function infoString(value: unknown): string | undefined {
@@ -538,6 +561,13 @@ function classifyLine(line: string): LineVerdict {
538
561
  if (!isRecord(parsed)) return { kind: 'none' }
539
562
  if (parsed.type === 'frame' && 'frame' in parsed) return { kind: 'frame', frame: parsed.frame }
540
563
  if (parsed.type === 'end') return { kind: 'end' }
564
+ // TA-1: the duplex handshake line — the server naming the stream id the
565
+ // uplink posts must carry. A client that opened one-direction (or an older
566
+ // client that predates the channel) never sees this verdict: the check
567
+ // rides inside classifyLine, so unrecognized shapes fall to `none` below.
568
+ if (parsed.type === 'uplink' && typeof parsed.streamId === 'string' && parsed.streamId !== '') {
569
+ return { kind: 'uplink', streamId: parsed.streamId }
570
+ }
541
571
  if (parsed.type === 'error') {
542
572
  const detail = isRecord(parsed.error) ? parsed.error : {}
543
573
  const code = typeof detail.code === 'string' && detail.code !== '' ? detail.code : 'internal'
@@ -1782,12 +1812,18 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
1782
1812
  })
1783
1813
  }
1784
1814
 
1785
- function openStream(namespace: string, method: string, args: unknown, signal?: AbortSignal): AsyncIterable<unknown> {
1815
+ function openStream(
1816
+ namespace: string,
1817
+ method: string,
1818
+ args: unknown,
1819
+ signal?: AbortSignal,
1820
+ options?: RelayStreamOptions,
1821
+ ): AsyncIterable<unknown> {
1786
1822
  // Eager, not generator-lazy: an unconfigured client fails at CALL time,
1787
1823
  // exactly like connect/invoke, instead of hiding the error inside the
1788
1824
  // first next().
1789
1825
  requireCredentials()
1790
- return streamLines(namespace, method, args, signal)
1826
+ return streamLines(namespace, method, args, signal, options)
1791
1827
  }
1792
1828
 
1793
1829
  /**
@@ -1797,12 +1833,24 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
1797
1833
  * and the generator's own exit — `break`, a thrown error line, normal
1798
1834
  * end — which tears the request down so the server's upstream does not
1799
1835
  * keep pumping behind a consumer that left.
1836
+ *
1837
+ * TA-1: with `options.uplink` the pump is DUPLEX. An independent async
1838
+ * task consumes the host's iterable the moment the 200 lands (frames read
1839
+ * before the handshake line are buffered locally, never lost), and a
1840
+ * single-in-flight flush loop batches them into `/v1/stream-uplink` posts:
1841
+ * while one post is on the wire the newer frames accumulate in
1842
+ * `pendingItems` and leave as ONE follow-up batch — strictly ordered seq
1843
+ * numbers without a second concurrent request, which is what keeps the
1844
+ * host's own input sequencing intact. An uplink refusal is the stream's
1845
+ * error: it aborts the downstream controller so the consumer sees the
1846
+ * RelayError, never a silent drop.
1800
1847
  */
1801
1848
  async function* streamLines(
1802
1849
  namespace: string,
1803
1850
  method: string,
1804
1851
  args: unknown,
1805
1852
  signal: AbortSignal | undefined,
1853
+ options: RelayStreamOptions | undefined,
1806
1854
  ): AsyncGenerator<unknown> {
1807
1855
  if (signal?.aborted) return
1808
1856
  // Re-checked: the generator body runs at the first next(), so the
@@ -1814,6 +1862,62 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
1814
1862
  }
1815
1863
  if (signal !== undefined) signal.addEventListener('abort', onExternalAbort)
1816
1864
 
1865
+ // ---- the uplink pump's state (TA-1) --------------------------------
1866
+ const uplink = options?.uplink
1867
+ let uplinkIterator: AsyncIterator<unknown> | undefined
1868
+ let uplinkError: unknown
1869
+ let pumpStopped = false
1870
+ let assignedStreamId: string | undefined
1871
+ let nextSeq = 1
1872
+ let uplinkEnded = false
1873
+ let endSent = false
1874
+ let flushing = false
1875
+ const pendingItems: unknown[] = []
1876
+
1877
+ /** The single-flight flush loop: one POST at a time, everything read
1878
+ * while it flew leaving as the next batch. Called from the consume task
1879
+ * and from the handshake-line verdict; re-entry while flushing is a
1880
+ * no-op (the running loop already sees the newest state on every turn). */
1881
+ const flushUplink = async (): Promise<void> => {
1882
+ if (flushing || pumpStopped || assignedStreamId === undefined) return
1883
+ flushing = true
1884
+ try {
1885
+ while (!pumpStopped && (pendingItems.length > 0 || (uplinkEnded && !endSent))) {
1886
+ const batch = pendingItems.splice(0, pendingItems.length)
1887
+ const sendEnd = uplinkEnded && pendingItems.length === 0 && !endSent
1888
+ if (batch.length === 0 && !sendEnd) break
1889
+ if (sendEnd) endSent = true
1890
+ const seq = nextSeq++
1891
+ try {
1892
+ await exchange(
1893
+ STREAM_UPLINK_PATH,
1894
+ JSON.stringify({
1895
+ streamId: assignedStreamId,
1896
+ seq,
1897
+ ...(batch.length > 0 ? { items: batch } : {}),
1898
+ ...(sendEnd ? { end: true } : {}),
1899
+ }),
1900
+ controller.signal,
1901
+ true,
1902
+ { url, token },
1903
+ { timeoutSetsOffline: false },
1904
+ )
1905
+ } catch (error) {
1906
+ // The refusal (or a transport death that is not the caller's own
1907
+ // abort) kills the DOWNSTREAM too: the caller must see the
1908
+ // uplink's error instead of a silently dead terminal input.
1909
+ if (!controller.signal.aborted) {
1910
+ uplinkError = error
1911
+ controller.abort()
1912
+ }
1913
+ return
1914
+ }
1915
+ }
1916
+ } finally {
1917
+ flushing = false
1918
+ }
1919
+ }
1920
+
1817
1921
  let idleFired = false
1818
1922
  let headersTimedOut = false
1819
1923
  let idleTimer: ReturnType<typeof setTimeout> | undefined
@@ -1849,7 +1953,16 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
1849
1953
  let response: Response
1850
1954
  try {
1851
1955
  response = await fetchImpl(`${url}${STREAM_PATH}`, {
1852
- ...requestInit(token, JSON.stringify({ namespace, method, args })),
1956
+ ...requestInit(
1957
+ token,
1958
+ JSON.stringify({
1959
+ namespace,
1960
+ method,
1961
+ args,
1962
+ ...(uplink !== undefined ? { uplink: true } : {}),
1963
+ ...(uplink !== undefined && options?.uplinkWindow !== undefined ? { uplinkWindow: options.uplinkWindow } : {}),
1964
+ }),
1965
+ ),
1853
1966
  signal: controller.signal,
1854
1967
  })
1855
1968
  } catch (error) {
@@ -1881,6 +1994,31 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
1881
1994
  setState('online')
1882
1995
  if (response.body === null) return
1883
1996
  armIdle()
1997
+ // TA-1: the uplink consume task starts with the downstream — frames
1998
+ // the host pushes before the handshake line arrives land in
1999
+ // pendingItems and flush the moment the id is known.
2000
+ if (uplink !== undefined) {
2001
+ const iterator = uplink[Symbol.asyncIterator]()
2002
+ uplinkIterator = iterator
2003
+ void (async (): Promise<void> => {
2004
+ try {
2005
+ while (true) {
2006
+ const result = await iterator.next()
2007
+ if (result.done) break
2008
+ pendingItems.push(result.value)
2009
+ void flushUplink()
2010
+ }
2011
+ uplinkEnded = true
2012
+ void flushUplink()
2013
+ } catch (error) {
2014
+ // A host-side iterable failure is this stream's failure too.
2015
+ if (!controller.signal.aborted && !pumpStopped) {
2016
+ uplinkError = error
2017
+ controller.abort()
2018
+ }
2019
+ }
2020
+ })()
2021
+ }
1884
2022
  const decoder = new TextDecoder()
1885
2023
  let buffer = ''
1886
2024
  reader = response.body.getReader()
@@ -1910,6 +2048,13 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
1910
2048
  // T59: the heartbeat's names, folded in quietly — a state-sparing
1911
2049
  // notify wakes the interceptor's rename path without any reconnect.
1912
2050
  applyInfoDelta(verdict.serverName, verdict.deviceName)
2051
+ } else if (verdict.kind === 'uplink') {
2052
+ // TA-1: the duplex handshake. The FIRST line names the stream;
2053
+ // any later one is ignored (one id per stream, ever).
2054
+ if (assignedStreamId === undefined) {
2055
+ assignedStreamId = verdict.streamId
2056
+ void flushUplink()
2057
+ }
1913
2058
  }
1914
2059
  }
1915
2060
  }
@@ -1927,10 +2072,17 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
1927
2072
  throw verdict.error
1928
2073
  } else if (verdict.kind === 'info') {
1929
2074
  applyInfoDelta(verdict.serverName, verdict.deviceName)
2075
+ } else if (verdict.kind === 'uplink' && assignedStreamId === undefined) {
2076
+ assignedStreamId = verdict.streamId
2077
+ void flushUplink()
1930
2078
  }
1931
2079
  }
1932
2080
  } catch (error) {
1933
2081
  if (signal?.aborted) return
2082
+ // TA-1: an uplink refusal outranks the transport noise the abort it
2083
+ // caused surfaces as — the caller sees the relay's own error code
2084
+ // (`out-of-order`, `uplink-overflow`, …), the actionable one.
2085
+ if (uplinkError !== undefined) throw uplinkError
1934
2086
  if (error instanceof RelayError) throw error
1935
2087
  // Anything else escaping the body read is the transport dying.
1936
2088
  noteFailure('offline')
@@ -1940,6 +2092,12 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
1940
2092
  disarmIdle()
1941
2093
  clearTimeout(headerTimer)
1942
2094
  if (signal !== undefined) signal.removeEventListener('abort', onExternalAbort)
2095
+ // TA-1: stop the pump and release the host iterable — a parked next()
2096
+ // (empty inbox, pump waiting) would otherwise pin the host's
2097
+ // UplinkInbox after the stream is gone. return() on an exhausted
2098
+ // iterator is a no-op; a missing one is skipped.
2099
+ pumpStopped = true
2100
+ void Promise.resolve(uplinkIterator?.return?.()).catch(() => {})
1943
2101
  // Release the body even if a custom fetchImpl ignored the signal —
1944
2102
  // an in-flight reader keeps the connection's buffers alive. Cancel
1945
2103
  // may reject on an already-dead stream; that is fine.