dsh-zen-remote 1.1.16 → 2.0.0

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 (255) hide show
  1. package/AGENTS.md +54 -9
  2. package/README.md +249 -190
  3. package/README.zh-CN.md +247 -201
  4. package/assets/home.png +0 -0
  5. package/assets/info.png +0 -0
  6. package/assets/pairing.png +0 -0
  7. package/assets/session.png +0 -0
  8. package/assets/sheet.png +0 -0
  9. package/cordis.patch.yml +12 -11
  10. package/cordis.patch.yml.example +24 -9
  11. package/docs/interface.md +57 -2
  12. package/docs/remote-access.en.md +99 -58
  13. package/docs/remote-access.md +99 -42
  14. package/dsh-push.mjs +67 -54
  15. package/lan-gate.mjs +88 -20
  16. package/lib/activity.js +168 -0
  17. package/lib/activity.js.map +1 -0
  18. package/lib/admin-routes.js +526 -0
  19. package/lib/admin-routes.js.map +1 -0
  20. package/lib/client-pairing.js +224 -0
  21. package/lib/client-pairing.js.map +1 -0
  22. package/lib/client-routes.js +674 -0
  23. package/lib/client-routes.js.map +1 -0
  24. package/lib/client.js +10051 -4777
  25. package/lib/config.js +346 -0
  26. package/lib/config.js.map +1 -0
  27. package/lib/fetch-route-intercept.js +418 -0
  28. package/lib/fetch-route-intercept.js.map +1 -0
  29. package/lib/fingerprint.js +363 -0
  30. package/lib/fingerprint.js.map +1 -0
  31. package/lib/http.js +69 -0
  32. package/lib/http.js.map +1 -0
  33. package/lib/index.js +675 -54
  34. package/lib/index.js.map +1 -1
  35. package/lib/intercept-shape.js +81 -0
  36. package/lib/intercept-shape.js.map +1 -0
  37. package/lib/intercept.js +3040 -0
  38. package/lib/intercept.js.map +1 -0
  39. package/lib/lan-gate-server.cjs +532 -121
  40. package/lib/merge-streams.js +1165 -0
  41. package/lib/merge-streams.js.map +1 -0
  42. package/lib/relay-access.js +496 -0
  43. package/lib/relay-access.js.map +1 -0
  44. package/lib/relay-client.js +1303 -0
  45. package/lib/relay-client.js.map +1 -0
  46. package/lib/relay-filter.js +282 -0
  47. package/lib/relay-filter.js.map +1 -0
  48. package/lib/relay-server.js +1792 -0
  49. package/lib/relay-server.js.map +1 -0
  50. package/lib/restart-fields.js +34 -0
  51. package/lib/restart-fields.js.map +1 -0
  52. package/lib/restart-watcher.js +106 -0
  53. package/lib/restart-watcher.js.map +1 -0
  54. package/lib/session-reference.js +157 -0
  55. package/lib/session-reference.js.map +1 -0
  56. package/lib/share-export.js +16 -1
  57. package/lib/share-export.js.map +1 -1
  58. package/lib/share-ops.js +86 -0
  59. package/lib/share-ops.js.map +1 -0
  60. package/lib/share-store.js +294 -0
  61. package/lib/share-store.js.map +1 -0
  62. package/lib/types/activity.d.ts +99 -0
  63. package/lib/types/activity.d.ts.map +1 -0
  64. package/lib/types/admin-routes.d.ts +129 -0
  65. package/lib/types/admin-routes.d.ts.map +1 -0
  66. package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -1
  67. package/lib/types/client/MobileHome.d.ts +1 -1
  68. package/lib/types/client/MobileHome.d.ts.map +1 -1
  69. package/lib/types/client/MobileHomeChips.d.ts +1 -1
  70. package/lib/types/client/MobileHomeChips.d.ts.map +1 -1
  71. package/lib/types/client/MobileSessionHeader.d.ts +13 -2
  72. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
  73. package/lib/types/client/MobileSessionInfo.d.ts +20 -3
  74. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  75. package/lib/types/client/RemoteComposerBanner.d.ts +42 -0
  76. package/lib/types/client/RemoteComposerBanner.d.ts.map +1 -0
  77. package/lib/types/client/RemoteHeaderIcon.d.ts +37 -0
  78. package/lib/types/client/RemoteHeaderIcon.d.ts.map +1 -0
  79. package/lib/types/client/RemoteShareMenu.d.ts +28 -0
  80. package/lib/types/client/RemoteShareMenu.d.ts.map +1 -0
  81. package/lib/types/client/RemoteStatusIcon.d.ts +36 -0
  82. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -0
  83. package/lib/types/client/compat/desktop.d.ts +21 -0
  84. package/lib/types/client/compat/desktop.d.ts.map +1 -0
  85. package/lib/types/client/compat/types.d.ts +57 -0
  86. package/lib/types/client/compat/types.d.ts.map +1 -1
  87. package/lib/types/client/current-session-report.d.ts +37 -0
  88. package/lib/types/client/current-session-report.d.ts.map +1 -0
  89. package/lib/types/client/effects/header-status.d.ts.map +1 -1
  90. package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
  91. package/lib/types/client/effects/permission-sheet.d.ts +39 -0
  92. package/lib/types/client/effects/permission-sheet.d.ts.map +1 -0
  93. package/lib/types/client/effects/remote-session.d.ts +25 -0
  94. package/lib/types/client/effects/remote-session.d.ts.map +1 -0
  95. package/lib/types/client/effects/turn-fold.d.ts +9 -0
  96. package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
  97. package/lib/types/client/index.d.ts.map +1 -1
  98. package/lib/types/client/locales.d.ts +187 -2
  99. package/lib/types/client/locales.d.ts.map +1 -1
  100. package/lib/types/client/remote-fetch.d.ts +51 -0
  101. package/lib/types/client/remote-fetch.d.ts.map +1 -0
  102. package/lib/types/client/remote-group-expansion.d.ts +41 -0
  103. package/lib/types/client/remote-group-expansion.d.ts.map +1 -0
  104. package/lib/types/client/remote-share-css.d.ts +19 -0
  105. package/lib/types/client/remote-share-css.d.ts.map +1 -0
  106. package/lib/types/client/remote-share-register.d.ts +113 -0
  107. package/lib/types/client/remote-share-register.d.ts.map +1 -0
  108. package/lib/types/client/remote-status-css.d.ts +15 -0
  109. package/lib/types/client/remote-status-css.d.ts.map +1 -0
  110. package/lib/types/client/remote-status-register.d.ts +46 -0
  111. package/lib/types/client/remote-status-register.d.ts.map +1 -0
  112. package/lib/types/client/settings/SettingsSection.d.ts +46 -0
  113. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -0
  114. package/lib/types/client/settings/register-settings.d.ts +58 -0
  115. package/lib/types/client/settings/register-settings.d.ts.map +1 -0
  116. package/lib/types/client/settings/settings-css.d.ts +10 -0
  117. package/lib/types/client/settings/settings-css.d.ts.map +1 -0
  118. package/lib/types/client/share/share-preview.d.ts.map +1 -1
  119. package/lib/types/client/styles/composer.css.d.ts +1 -1
  120. package/lib/types/client/styles/composer.css.d.ts.map +1 -1
  121. package/lib/types/client/styles/header.css.d.ts +1 -1
  122. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  123. package/lib/types/client/styles/info.css.d.ts +1 -1
  124. package/lib/types/client/styles/info.css.d.ts.map +1 -1
  125. package/lib/types/client/styles/remote-session.css.d.ts +2 -0
  126. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -0
  127. package/lib/types/client-data/current-session.d.ts +41 -0
  128. package/lib/types/client-data/current-session.d.ts.map +1 -0
  129. package/lib/types/client-data/remote-group-expansion.d.ts +76 -0
  130. package/lib/types/client-data/remote-group-expansion.d.ts.map +1 -0
  131. package/lib/types/client-data/remote-status.d.ts +148 -0
  132. package/lib/types/client-data/remote-status.d.ts.map +1 -0
  133. package/lib/types/client-data/settings-form.d.ts +774 -0
  134. package/lib/types/client-data/settings-form.d.ts.map +1 -0
  135. package/lib/types/client-data/shares.d.ts +211 -0
  136. package/lib/types/client-data/shares.d.ts.map +1 -0
  137. package/lib/types/client-pairing.d.ts +114 -0
  138. package/lib/types/client-pairing.d.ts.map +1 -0
  139. package/lib/types/client-routes.d.ts +112 -0
  140. package/lib/types/client-routes.d.ts.map +1 -0
  141. package/lib/types/config.d.ts +200 -0
  142. package/lib/types/config.d.ts.map +1 -0
  143. package/lib/types/fetch-route-intercept.d.ts +156 -0
  144. package/lib/types/fetch-route-intercept.d.ts.map +1 -0
  145. package/lib/types/fingerprint.d.ts +164 -0
  146. package/lib/types/fingerprint.d.ts.map +1 -0
  147. package/lib/types/http.d.ts +41 -0
  148. package/lib/types/http.d.ts.map +1 -0
  149. package/lib/types/index.d.ts +56 -24
  150. package/lib/types/index.d.ts.map +1 -1
  151. package/lib/types/intercept-shape.d.ts +32 -0
  152. package/lib/types/intercept-shape.d.ts.map +1 -0
  153. package/lib/types/intercept.d.ts +432 -0
  154. package/lib/types/intercept.d.ts.map +1 -0
  155. package/lib/types/merge-streams.d.ts +393 -0
  156. package/lib/types/merge-streams.d.ts.map +1 -0
  157. package/lib/types/relay-access.d.ts +310 -0
  158. package/lib/types/relay-access.d.ts.map +1 -0
  159. package/lib/types/relay-client.d.ts +274 -0
  160. package/lib/types/relay-client.d.ts.map +1 -0
  161. package/lib/types/relay-filter.d.ts +103 -0
  162. package/lib/types/relay-filter.d.ts.map +1 -0
  163. package/lib/types/relay-server.d.ts +208 -0
  164. package/lib/types/relay-server.d.ts.map +1 -0
  165. package/lib/types/restart-fields.d.ts +22 -0
  166. package/lib/types/restart-fields.d.ts.map +1 -0
  167. package/lib/types/restart-watcher.d.ts +83 -0
  168. package/lib/types/restart-watcher.d.ts.map +1 -0
  169. package/lib/types/session-reference.d.ts +66 -0
  170. package/lib/types/session-reference.d.ts.map +1 -0
  171. package/lib/types/share-export.d.ts +14 -1
  172. package/lib/types/share-export.d.ts.map +1 -1
  173. package/lib/types/share-ops.d.ts +79 -0
  174. package/lib/types/share-ops.d.ts.map +1 -0
  175. package/lib/types/share-store.d.ts +87 -0
  176. package/lib/types/share-store.d.ts.map +1 -0
  177. package/lib/types/virtual-id.d.ts +38 -0
  178. package/lib/types/virtual-id.d.ts.map +1 -0
  179. package/lib/virtual-id.js +52 -0
  180. package/lib/virtual-id.js.map +1 -0
  181. package/llms.txt +17 -14
  182. package/package.json +60 -50
  183. package/scripts/build-client.mjs +5 -3
  184. package/scripts/check-host-shadow.mjs +19 -2
  185. package/scripts/check-settings-form.mjs +933 -0
  186. package/scripts/check-shares.mjs +397 -0
  187. package/scripts/check-upload-endpoint.mjs +59 -3
  188. package/scripts/sync-doc-version.mjs +8 -15
  189. package/scripts/verify-fingerprint-live.mjs +111 -0
  190. package/src/activity.ts +218 -0
  191. package/src/admin-routes.ts +627 -0
  192. package/src/client/MobileAttachButton.tsx +2 -2
  193. package/src/client/MobileAttachChips.tsx +3 -3
  194. package/src/client/MobileDrawerFooter.tsx +7 -4
  195. package/src/client/MobileHome.tsx +20 -16
  196. package/src/client/MobileHomeChips.tsx +16 -16
  197. package/src/client/MobileNavOverlay.tsx +2 -2
  198. package/src/client/MobileNavToggle.tsx +3 -3
  199. package/src/client/MobileSessionHeader.tsx +63 -42
  200. package/src/client/MobileSessionInfo.tsx +118 -17
  201. package/src/client/RemoteComposerBanner.tsx +76 -0
  202. package/src/client/RemoteHeaderIcon.tsx +103 -0
  203. package/src/client/RemoteShareMenu.tsx +57 -0
  204. package/src/client/RemoteStatusIcon.tsx +91 -0
  205. package/src/client/compat/desktop.ts +22 -0
  206. package/src/client/compat/types.ts +62 -0
  207. package/src/client/current-session-report.ts +164 -0
  208. package/src/client/effects/header-status.ts +21 -17
  209. package/src/client/effects/keyboard-guard.ts +65 -1
  210. package/src/client/effects/permission-sheet.ts +149 -0
  211. package/src/client/effects/remote-session.ts +68 -0
  212. package/src/client/effects/turn-fold.ts +52 -0
  213. package/src/client/index.tsx +151 -36
  214. package/src/client/locales.ts +396 -2
  215. package/src/client/remote-fetch.ts +125 -0
  216. package/src/client/remote-group-expansion.ts +190 -0
  217. package/src/client/remote-share-css.ts +54 -0
  218. package/src/client/remote-share-register.ts +244 -0
  219. package/src/client/remote-status-css.ts +64 -0
  220. package/src/client/remote-status-register.ts +107 -0
  221. package/src/client/settings/SettingsSection.tsx +1229 -0
  222. package/src/client/settings/register-settings.ts +116 -0
  223. package/src/client/settings/settings-css.ts +236 -0
  224. package/src/client/share/share-preview.tsx +6 -3
  225. package/src/client/share/share-sheet.tsx +3 -3
  226. package/src/client/styles/composer.css.ts +119 -26
  227. package/src/client/styles/header.css.ts +7 -7
  228. package/src/client/styles/info.css.ts +74 -0
  229. package/src/client/styles/remote-session.css.ts +251 -0
  230. package/src/client-data/current-session.ts +57 -0
  231. package/src/client-data/remote-group-expansion.ts +145 -0
  232. package/src/client-data/remote-status.ts +374 -0
  233. package/src/client-data/settings-form.ts +1371 -0
  234. package/src/client-data/shares.ts +467 -0
  235. package/src/client-pairing.ts +244 -0
  236. package/src/client-routes.ts +732 -0
  237. package/src/config.ts +447 -0
  238. package/src/fetch-route-intercept.ts +510 -0
  239. package/src/fingerprint.ts +408 -0
  240. package/src/http.ts +68 -0
  241. package/src/index.ts +719 -62
  242. package/src/intercept-shape.ts +87 -0
  243. package/src/intercept.ts +3362 -0
  244. package/src/merge-streams.ts +1272 -0
  245. package/src/relay-access.ts +669 -0
  246. package/src/relay-client.ts +1514 -0
  247. package/src/relay-filter.ts +291 -0
  248. package/src/relay-server.ts +2025 -0
  249. package/src/restart-fields.ts +36 -0
  250. package/src/restart-watcher.ts +140 -0
  251. package/src/session-reference.ts +156 -0
  252. package/src/share-export.ts +26 -1
  253. package/src/share-ops.ts +114 -0
  254. package/src/share-store.ts +373 -0
  255. package/src/virtual-id.ts +59 -0
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Shared "which sessions have remote enabled" table (2.0.0 remote-session
3
+ * groundwork): pure logic plus a small JSON file under the DSH data dir. The
4
+ * server opens/closes remote per session; a paired desktop client may close
5
+ * but never open; everything else here exists to make one rule survivable
6
+ * across restarts — an idle shared session must put itself away.
7
+ *
8
+ * Three clock rules, all driven by the caller:
9
+ * - `touch()` marks activity (turn start/end, message sent, approval or
10
+ * question answered — opening a session to look at it is NOT activity).
11
+ * - `setBusy()` marks running/waiting; busy sessions never idle out and the
12
+ * idle clock restarts from the moment busy ends.
13
+ * - everything else is derived: `sweep()` closes whatever has been idle past
14
+ * `idleHours`, `remainingMs()` reports the countdown.
15
+ *
16
+ * Accessibility crosses generations: subagent and fork sessions are reachable
17
+ * when any ancestor is shared, without themselves appearing in the table
18
+ * (`isAccessible` walks `parentOf`).
19
+ *
20
+ * Persistence is deliberately dumb: one JSON file, rewritten atomically
21
+ * (tmp + rename) after every operation that changes what the file would say,
22
+ * synchronously, because a crash right after "share" must not silently
23
+ * un-share. `busy` is never persisted — after a restart every session is
24
+ * idle by definition until the caller says otherwise.
25
+ */
26
+
27
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
28
+ import { dirname } from 'node:path'
29
+
30
+ /** One row of the table, as `list()` reports it. */
31
+ export interface ShareEntry {
32
+ sessionId: string
33
+ /** When remote was first enabled (repeat `share` does not move it). */
34
+ sharedAt: number
35
+ /** Last activity the caller announced via `touch()` (or busy-end). */
36
+ lastActivityAt: number
37
+ /** Memory-only: never persisted, always false after a restart. */
38
+ busy: boolean
39
+ }
40
+
41
+ /** Why a session left the table. `manual` = server-side close, `client` =
42
+ * desktop-client close, `idle` = automatic idle shutdown. */
43
+ export type UnshareReason = 'manual' | 'client' | 'idle'
44
+
45
+ export type ShareEvent =
46
+ | { type: 'shared'; sessionId: string }
47
+ | { type: 'unshared'; sessionId: string; reason: UnshareReason }
48
+
49
+ export interface ShareStoreOptions {
50
+ /** Absolute path of the persistence file. */
51
+ file: string
52
+ /** Idle budget in hours; must be 0 < x ≤ 8760, anything else falls back
53
+ * to 48. Changeable later via `setIdleHours`. */
54
+ idleHours: number
55
+ /** Injectable clock; defaults to Date.now. */
56
+ now?: () => number
57
+ }
58
+
59
+ export interface ShareStore {
60
+ /** Enable remote for a session. True when newly shared; false when it was
61
+ * already shared (sharedAt stays put, lastActivityAt refreshes to now). */
62
+ share(sessionId: string): boolean
63
+ /** Remove a session from the table. True when it was actually removed. */
64
+ unshare(sessionId: string, reason: UnshareReason): boolean
65
+ /** Whether the session itself is in the table (inheritance NOT applied). */
66
+ isShared(sessionId: string): boolean
67
+ /** Whether the session is reachable for remote access: itself shared, or
68
+ * some ancestor within 16 `parentOf` hops shared. */
69
+ isAccessible(sessionId: string, parentOf: (id: string) => string | undefined): boolean
70
+ /** Announce activity. No-op for sessions not in the table. */
71
+ touch(sessionId: string): void
72
+ /** Announce running/waiting state. No-op for sessions not in the table.
73
+ * busy true→false restarts the idle clock from now. */
74
+ setBusy(sessionId: string, busy: boolean): void
75
+ /** Close every expired session now. Returns the closed ids, sorted. */
76
+ sweep(): string[]
77
+ /** undefined when not shared; Infinity while busy; otherwise how many ms
78
+ * are left before this session would idle out (never negative). */
79
+ remainingMs(sessionId: string): number | undefined
80
+ /** Snapshot of the table, sharedAt ascending (ties broken by id). */
81
+ list(): ShareEntry[]
82
+ /** Replace the idle budget. Invalid values are ignored; takes effect
83
+ * immediately for subsequent sweep/remainingMs calls. */
84
+ setIdleHours(hours: number): void
85
+ /** Observe share/unshare events. Listener errors are swallowed. Returns
86
+ * an unsubscribe function. */
87
+ subscribe(listener: (event: ShareEvent) => void): () => void
88
+ }
89
+
90
+ const FILE_VERSION = 1
91
+
92
+ const DEFAULT_IDLE_HOURS = 48
93
+
94
+ /** One year — beyond that an "idle budget" is a typo, not a policy. */
95
+ const MAX_IDLE_HOURS = 8760
96
+
97
+ const HOUR_MS = 3_600_000
98
+
99
+ /** Session ids longer than this are considered hostile, not real. */
100
+ const MAX_ID_LENGTH = 200
101
+
102
+ /** Same-walkancestor bound for isAccessible: deep subagent chains must not
103
+ * turn into an unbounded walk, and a cycle must not turn into a hang. */
104
+ const MAX_ANCESTOR_DEPTH = 16
105
+
106
+ /** Disk-write throttle for lastActivityAt (see flushActivity below). */
107
+ const ACTIVITY_FLUSH_INTERVAL_MS = 60_000
108
+
109
+ interface PersistedRow {
110
+ sharedAt: number
111
+ lastActivityAt: number
112
+ }
113
+
114
+ interface Row extends PersistedRow {
115
+ busy: boolean
116
+ }
117
+
118
+ function isValidId(id: unknown): id is string {
119
+ return typeof id === 'string' && id.length > 0 && id.length <= MAX_ID_LENGTH
120
+ }
121
+
122
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
123
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
124
+ }
125
+
126
+ function isFiniteNumber(value: unknown): value is number {
127
+ return typeof value === 'number' && Number.isFinite(value)
128
+ }
129
+
130
+ function normalizeIdleHours(hours: unknown): number {
131
+ return typeof hours === 'number' && Number.isFinite(hours) && hours > 0 && hours <= MAX_IDLE_HOURS
132
+ ? hours
133
+ : DEFAULT_IDLE_HOURS
134
+ }
135
+
136
+ export function createShareStore(options: ShareStoreOptions): ShareStore {
137
+ const file = options.file
138
+ const now = options.now ?? Date.now
139
+ let idleHours = normalizeIdleHours(options.idleHours)
140
+
141
+ /** Insertion order = shared order = file key order, so the persisted JSON
142
+ * is stable across reloads as long as nothing is removed. */
143
+ const sessions = new Map<string, Row>()
144
+
145
+ /** Per-session time of the last DISK write of lastActivityAt — the
146
+ * throttle state for flushActivity, not the activity itself. */
147
+ const activityFlushedAt = new Map<string, number>()
148
+
149
+ const listeners = new Set<(event: ShareEvent) => void>()
150
+
151
+ /** Set by persist() when the tmp+rename dance failed; undefined after a
152
+ * successful write. Debugging aid only — write failures never throw. */
153
+ let lastWriteError: unknown
154
+
155
+ // ---- startup load (synchronous, once) ----
156
+ //
157
+ // Missing file = first run, empty table. Unreadable path (a directory,
158
+ // permissions) also starts empty but is NOT quarantined — renaming
159
+ // something we could not even read could eat a live directory.
160
+ let raw: string | null = null
161
+ try {
162
+ raw = readFileSync(file, 'utf8')
163
+ } catch {
164
+ raw = null
165
+ }
166
+ if (raw !== null) {
167
+ let parsed: unknown
168
+ try {
169
+ parsed = JSON.parse(raw)
170
+ } catch {
171
+ parsed = undefined
172
+ }
173
+ // Container-level damage (unparseable JSON, wrong version, wrong shape)
174
+ // quarantines the whole file; row-level damage (one bad record) only
175
+ // skips that record — the rest of the table is worth keeping.
176
+ if (isPlainObject(parsed) && parsed.version === FILE_VERSION && isPlainObject(parsed.sessions)) {
177
+ for (const [id, value] of Object.entries(parsed.sessions)) {
178
+ if (!isValidId(id) || !isPlainObject(value)) continue
179
+ const { sharedAt, lastActivityAt } = value
180
+ if (!isFiniteNumber(sharedAt) || !isFiniteNumber(lastActivityAt)) continue
181
+ sessions.set(id, { sharedAt, lastActivityAt, busy: false })
182
+ }
183
+ } else {
184
+ try {
185
+ renameSync(file, `${file}.corrupt-${now()}`)
186
+ } catch {
187
+ // Keep the empty table either way; startup must not fail here.
188
+ }
189
+ }
190
+ }
191
+
192
+ function emit(event: ShareEvent): void {
193
+ // Snapshot: a listener may unsubscribe itself (or others) mid-delivery.
194
+ for (const listener of [...listeners]) {
195
+ try {
196
+ listener(event)
197
+ } catch {
198
+ // Listeners are bystanders: their failures belong to them.
199
+ }
200
+ }
201
+ }
202
+
203
+ function persist(): void {
204
+ // Null prototype: with a plain {} an id of "__proto__" would hit the
205
+ // prototype setter instead of becoming an own property, so the row would
206
+ // silently vanish from JSON.stringify and from the next load. (Loading is
207
+ // safe the other way round: JSON.parse defines "__proto__" as an own data
208
+ // property, which Object.entries picks up.)
209
+ const fileSessions: Record<string, PersistedRow> = Object.create(null)
210
+ for (const [id, row] of sessions) {
211
+ // busy is deliberately absent: restarts always resume as idle.
212
+ fileSessions[id] = { sharedAt: row.sharedAt, lastActivityAt: row.lastActivityAt }
213
+ }
214
+ try {
215
+ mkdirSync(dirname(file), { recursive: true })
216
+ writeFileSync(`${file}.tmp`, JSON.stringify({ version: FILE_VERSION, sessions: fileSessions }))
217
+ renameSync(`${file}.tmp`, file)
218
+ lastWriteError = undefined
219
+ } catch (error) {
220
+ lastWriteError = error
221
+ }
222
+ }
223
+
224
+ /**
225
+ * ponytail: lastActivityAt 的写盘按会话节流为最多每 60 秒一次(touch 在
226
+ * 回合中可能每秒都来,每次都落盘太浪费)。内存值永远即时更新,节流只压
227
+ * 写盘,所以进程崩溃后恢复的 lastActivityAt 最多落后真实值 60 秒——闲置
228
+ * 计时因此最多偏差 60 秒,相对 48 小时量级的休眠阈值可以接受。结构性写盘
229
+ * (share / unshare / sweep)不节流。
230
+ */
231
+ function flushActivity(id: string, t: number): void {
232
+ const flushedAt = activityFlushedAt.get(id)
233
+ if (flushedAt !== undefined && t - flushedAt < ACTIVITY_FLUSH_INTERVAL_MS) return
234
+ activityFlushedAt.set(id, t)
235
+ persist()
236
+ }
237
+
238
+ const store: ShareStore = {
239
+ share(sessionId) {
240
+ if (!isValidId(sessionId)) return false
241
+ const t = now()
242
+ const existing = sessions.get(sessionId)
243
+ if (existing !== undefined) {
244
+ // "已开启多久"从第一次算起,但重复开启算一次活跃。
245
+ existing.lastActivityAt = t
246
+ activityFlushedAt.set(sessionId, t)
247
+ persist()
248
+ return false
249
+ }
250
+ sessions.set(sessionId, { sharedAt: t, lastActivityAt: t, busy: false })
251
+ activityFlushedAt.set(sessionId, t)
252
+ persist()
253
+ emit({ type: 'shared', sessionId })
254
+ return true
255
+ },
256
+
257
+ unshare(sessionId, reason) {
258
+ if (!isValidId(sessionId) || !sessions.has(sessionId)) return false
259
+ sessions.delete(sessionId)
260
+ activityFlushedAt.delete(sessionId)
261
+ persist()
262
+ emit({ type: 'unshared', sessionId, reason })
263
+ return true
264
+ },
265
+
266
+ isShared(sessionId) {
267
+ return isValidId(sessionId) && sessions.has(sessionId)
268
+ },
269
+
270
+ isAccessible(sessionId, parentOf) {
271
+ if (!isValidId(sessionId)) return false
272
+ if (sessions.has(sessionId)) return true
273
+ // Subagent/fork sessions never enter the table; their reachability is
274
+ // borrowed from the ancestor chain. Bound the walk (depth) and stop
275
+ // dead on cycles (a repeated id) so a hostile parentOf cannot hang us.
276
+ const seen = new Set<string>([sessionId])
277
+ let current = sessionId
278
+ for (let depth = 0; depth < MAX_ANCESTOR_DEPTH; depth += 1) {
279
+ let parent: string | undefined
280
+ try {
281
+ parent = parentOf(current)
282
+ } catch {
283
+ // A throwing parentOf means "no parent from here".
284
+ return false
285
+ }
286
+ if (parent === undefined || !isValidId(parent)) return false
287
+ if (seen.has(parent)) return false
288
+ seen.add(parent)
289
+ if (sessions.has(parent)) return true
290
+ current = parent
291
+ }
292
+ return false
293
+ },
294
+
295
+ touch(sessionId) {
296
+ const row = isValidId(sessionId) ? sessions.get(sessionId) : undefined
297
+ if (row === undefined) return
298
+ const t = now()
299
+ row.lastActivityAt = t
300
+ flushActivity(sessionId, t)
301
+ },
302
+
303
+ setBusy(sessionId, busy) {
304
+ const row = isValidId(sessionId) ? sessions.get(sessionId) : undefined
305
+ if (row === undefined || row.busy === busy) return
306
+ row.busy = busy
307
+ if (!busy) {
308
+ // Busy sessions never count toward idle; the clock restarts from the
309
+ // moment busy ends, which is why this write carries a fresh stamp.
310
+ const t = now()
311
+ row.lastActivityAt = t
312
+ flushActivity(sessionId, t)
313
+ }
314
+ // busy itself is never persisted; a restart resumes as idle.
315
+ },
316
+
317
+ sweep() {
318
+ const t = now()
319
+ const idleMs = idleHours * HOUR_MS
320
+ const expired: string[] = []
321
+ for (const [id, row] of sessions) {
322
+ if (!row.busy && t - row.lastActivityAt >= idleMs) expired.push(id)
323
+ }
324
+ expired.sort()
325
+ // One unshare per session, never delete-all-then-announce (T73): a
326
+ // listener judging "what did this event close" must not find sessions
327
+ // the event did not name already gone — page A quoted session B's id.
328
+ for (const id of expired) store.unshare(id, 'idle')
329
+ return expired
330
+ },
331
+
332
+ remainingMs(sessionId) {
333
+ const row = isValidId(sessionId) ? sessions.get(sessionId) : undefined
334
+ if (row === undefined) return undefined
335
+ if (row.busy) return Infinity
336
+ return Math.max(0, row.lastActivityAt + idleHours * HOUR_MS - now())
337
+ },
338
+
339
+ list() {
340
+ return [...sessions.entries()]
341
+ .map(([id, row]) => ({
342
+ sessionId: id,
343
+ sharedAt: row.sharedAt,
344
+ lastActivityAt: row.lastActivityAt,
345
+ busy: row.busy,
346
+ }))
347
+ .sort((a, b) => a.sharedAt - b.sharedAt || (a.sessionId < b.sessionId ? -1 : 1))
348
+ },
349
+
350
+ setIdleHours(hours) {
351
+ if (typeof hours !== 'number' || !Number.isFinite(hours) || hours <= 0 || hours > MAX_IDLE_HOURS) {
352
+ return
353
+ }
354
+ idleHours = hours
355
+ },
356
+
357
+ subscribe(listener) {
358
+ listeners.add(listener)
359
+ return () => {
360
+ listeners.delete(listener)
361
+ }
362
+ },
363
+ }
364
+
365
+ // Debugging aid, outside the public interface on purpose: when remote
366
+ // state "does not stick", this is where the write error hides.
367
+ Object.defineProperty(store, 'lastWriteError', {
368
+ get: () => lastWriteError,
369
+ enumerable: false,
370
+ })
371
+
372
+ return store
373
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Reversible virtual ids for sessions and workspaces the sub-client surfaces
3
+ * locally while their real records live on a relay server (T23b-1). The UI
4
+ * only ever sees `zr~<serverId>~<original id>`; the interceptor maps those
5
+ * back to the original ids before a call travels, and maps server-returned
6
+ * ids back to virtual form on the way out.
7
+ *
8
+ * The mapping is deliberately a PURE function of (serverId, id), not a
9
+ * session-table lookup: every shared session of one server gets the same
10
+ * virtual id whether or not this process ever opened it, so result rewriting
11
+ * needs no state. Parsing validates the fixed skeleton (`zr~` + the 8 hex
12
+ * chars `loadServerId` mints, relay-server.ts) and keeps the remainder
13
+ * VERBATIM — the original id may contain any characters, including `~`, so
14
+ * nothing after the second separator is interpreted.
15
+ *
16
+ * A local DSH id can never collide with the prefix: real session ids are
17
+ * `session-<uuid>` and workspace ids are bare uuids.
18
+ */
19
+
20
+ /** The fixed prefix every virtual id starts with. */
21
+ export const VIRTUAL_ID_PREFIX = 'zr~'
22
+
23
+ /** The server id half: exactly the 8 lowercase hex chars relay-server.ts
24
+ * mints (`randomBytes(4).toString('hex')`). */
25
+ const SERVER_ID_PATTERN = /^[0-9a-f]{8}$/
26
+
27
+ /** The two halves {@link fromVirtual} recovers from a virtual id. */
28
+ export interface VirtualIdParts {
29
+ serverId: string
30
+ id: string
31
+ }
32
+
33
+ /** Build the virtual form of one server-side session or workspace id. */
34
+ export function toVirtual(serverId: string, id: string): string {
35
+ return `${VIRTUAL_ID_PREFIX}${serverId}~${id}`
36
+ }
37
+
38
+ /**
39
+ * Parse one virtual id back into its halves, or `undefined` for anything
40
+ * that does not match the skeleton. Strict on the parts that carry meaning:
41
+ * the literal prefix, exactly 8 lowercase hex server id chars, and the
42
+ * separating `~` after them. The remainder is not validated — any string,
43
+ * including the empty one, is a legal original id.
44
+ */
45
+ export function fromVirtual(id: unknown): VirtualIdParts | undefined {
46
+ if (typeof id !== 'string' || !id.startsWith(VIRTUAL_ID_PREFIX)) return undefined
47
+ const rest = id.slice(VIRTUAL_ID_PREFIX.length)
48
+ // The shortest legal remainder is `<8 hex>~` — 9 chars with the separator
49
+ // at index 8. Anything shorter cannot carry a server id.
50
+ if (rest.length < 9 || rest[8] !== '~') return undefined
51
+ const serverId = rest.slice(0, 8)
52
+ if (!SERVER_ID_PATTERN.test(serverId)) return undefined
53
+ return { serverId, id: rest.slice(9) }
54
+ }
55
+
56
+ /** True when {@link fromVirtual} would parse this value. */
57
+ export function isVirtual(id: unknown): boolean {
58
+ return fromVirtual(id) !== undefined
59
+ }