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.
- package/AGENTS.md +54 -9
- package/README.md +249 -190
- package/README.zh-CN.md +247 -201
- package/assets/home.png +0 -0
- package/assets/info.png +0 -0
- package/assets/pairing.png +0 -0
- package/assets/session.png +0 -0
- package/assets/sheet.png +0 -0
- package/cordis.patch.yml +12 -11
- package/cordis.patch.yml.example +24 -9
- package/docs/interface.md +57 -2
- package/docs/remote-access.en.md +99 -58
- package/docs/remote-access.md +99 -42
- package/dsh-push.mjs +67 -54
- package/lan-gate.mjs +88 -20
- package/lib/activity.js +168 -0
- package/lib/activity.js.map +1 -0
- package/lib/admin-routes.js +526 -0
- package/lib/admin-routes.js.map +1 -0
- package/lib/client-pairing.js +224 -0
- package/lib/client-pairing.js.map +1 -0
- package/lib/client-routes.js +674 -0
- package/lib/client-routes.js.map +1 -0
- package/lib/client.js +10051 -4777
- package/lib/config.js +346 -0
- package/lib/config.js.map +1 -0
- package/lib/fetch-route-intercept.js +418 -0
- package/lib/fetch-route-intercept.js.map +1 -0
- package/lib/fingerprint.js +363 -0
- package/lib/fingerprint.js.map +1 -0
- package/lib/http.js +69 -0
- package/lib/http.js.map +1 -0
- package/lib/index.js +675 -54
- package/lib/index.js.map +1 -1
- package/lib/intercept-shape.js +81 -0
- package/lib/intercept-shape.js.map +1 -0
- package/lib/intercept.js +3040 -0
- package/lib/intercept.js.map +1 -0
- package/lib/lan-gate-server.cjs +532 -121
- package/lib/merge-streams.js +1165 -0
- package/lib/merge-streams.js.map +1 -0
- package/lib/relay-access.js +496 -0
- package/lib/relay-access.js.map +1 -0
- package/lib/relay-client.js +1303 -0
- package/lib/relay-client.js.map +1 -0
- package/lib/relay-filter.js +282 -0
- package/lib/relay-filter.js.map +1 -0
- package/lib/relay-server.js +1792 -0
- package/lib/relay-server.js.map +1 -0
- package/lib/restart-fields.js +34 -0
- package/lib/restart-fields.js.map +1 -0
- package/lib/restart-watcher.js +106 -0
- package/lib/restart-watcher.js.map +1 -0
- package/lib/session-reference.js +157 -0
- package/lib/session-reference.js.map +1 -0
- package/lib/share-export.js +16 -1
- package/lib/share-export.js.map +1 -1
- package/lib/share-ops.js +86 -0
- package/lib/share-ops.js.map +1 -0
- package/lib/share-store.js +294 -0
- package/lib/share-store.js.map +1 -0
- package/lib/types/activity.d.ts +99 -0
- package/lib/types/activity.d.ts.map +1 -0
- package/lib/types/admin-routes.d.ts +129 -0
- package/lib/types/admin-routes.d.ts.map +1 -0
- package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -1
- package/lib/types/client/MobileHome.d.ts +1 -1
- package/lib/types/client/MobileHome.d.ts.map +1 -1
- package/lib/types/client/MobileHomeChips.d.ts +1 -1
- package/lib/types/client/MobileHomeChips.d.ts.map +1 -1
- package/lib/types/client/MobileSessionHeader.d.ts +13 -2
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts +20 -3
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/RemoteComposerBanner.d.ts +42 -0
- package/lib/types/client/RemoteComposerBanner.d.ts.map +1 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts +37 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts.map +1 -0
- package/lib/types/client/RemoteShareMenu.d.ts +28 -0
- package/lib/types/client/RemoteShareMenu.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts +36 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -0
- package/lib/types/client/compat/desktop.d.ts +21 -0
- package/lib/types/client/compat/desktop.d.ts.map +1 -0
- package/lib/types/client/compat/types.d.ts +57 -0
- package/lib/types/client/compat/types.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +37 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -0
- package/lib/types/client/effects/header-status.d.ts.map +1 -1
- package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
- package/lib/types/client/effects/permission-sheet.d.ts +39 -0
- package/lib/types/client/effects/permission-sheet.d.ts.map +1 -0
- package/lib/types/client/effects/remote-session.d.ts +25 -0
- package/lib/types/client/effects/remote-session.d.ts.map +1 -0
- package/lib/types/client/effects/turn-fold.d.ts +9 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +187 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/remote-fetch.d.ts +51 -0
- package/lib/types/client/remote-fetch.d.ts.map +1 -0
- package/lib/types/client/remote-group-expansion.d.ts +41 -0
- package/lib/types/client/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client/remote-share-css.d.ts +19 -0
- package/lib/types/client/remote-share-css.d.ts.map +1 -0
- package/lib/types/client/remote-share-register.d.ts +113 -0
- package/lib/types/client/remote-share-register.d.ts.map +1 -0
- package/lib/types/client/remote-status-css.d.ts +15 -0
- package/lib/types/client/remote-status-css.d.ts.map +1 -0
- package/lib/types/client/remote-status-register.d.ts +46 -0
- package/lib/types/client/remote-status-register.d.ts.map +1 -0
- package/lib/types/client/settings/SettingsSection.d.ts +46 -0
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -0
- package/lib/types/client/settings/register-settings.d.ts +58 -0
- package/lib/types/client/settings/register-settings.d.ts.map +1 -0
- package/lib/types/client/settings/settings-css.d.ts +10 -0
- package/lib/types/client/settings/settings-css.d.ts.map +1 -0
- package/lib/types/client/share/share-preview.d.ts.map +1 -1
- package/lib/types/client/styles/composer.css.d.ts +1 -1
- package/lib/types/client/styles/composer.css.d.ts.map +1 -1
- package/lib/types/client/styles/header.css.d.ts +1 -1
- package/lib/types/client/styles/header.css.d.ts.map +1 -1
- package/lib/types/client/styles/info.css.d.ts +1 -1
- package/lib/types/client/styles/info.css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +2 -0
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -0
- package/lib/types/client-data/current-session.d.ts +41 -0
- package/lib/types/client-data/current-session.d.ts.map +1 -0
- package/lib/types/client-data/remote-group-expansion.d.ts +76 -0
- package/lib/types/client-data/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client-data/remote-status.d.ts +148 -0
- package/lib/types/client-data/remote-status.d.ts.map +1 -0
- package/lib/types/client-data/settings-form.d.ts +774 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -0
- package/lib/types/client-data/shares.d.ts +211 -0
- package/lib/types/client-data/shares.d.ts.map +1 -0
- package/lib/types/client-pairing.d.ts +114 -0
- package/lib/types/client-pairing.d.ts.map +1 -0
- package/lib/types/client-routes.d.ts +112 -0
- package/lib/types/client-routes.d.ts.map +1 -0
- package/lib/types/config.d.ts +200 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/fetch-route-intercept.d.ts +156 -0
- package/lib/types/fetch-route-intercept.d.ts.map +1 -0
- package/lib/types/fingerprint.d.ts +164 -0
- package/lib/types/fingerprint.d.ts.map +1 -0
- package/lib/types/http.d.ts +41 -0
- package/lib/types/http.d.ts.map +1 -0
- package/lib/types/index.d.ts +56 -24
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept-shape.d.ts +32 -0
- package/lib/types/intercept-shape.d.ts.map +1 -0
- package/lib/types/intercept.d.ts +432 -0
- package/lib/types/intercept.d.ts.map +1 -0
- package/lib/types/merge-streams.d.ts +393 -0
- package/lib/types/merge-streams.d.ts.map +1 -0
- package/lib/types/relay-access.d.ts +310 -0
- package/lib/types/relay-access.d.ts.map +1 -0
- package/lib/types/relay-client.d.ts +274 -0
- package/lib/types/relay-client.d.ts.map +1 -0
- package/lib/types/relay-filter.d.ts +103 -0
- package/lib/types/relay-filter.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +208 -0
- package/lib/types/relay-server.d.ts.map +1 -0
- package/lib/types/restart-fields.d.ts +22 -0
- package/lib/types/restart-fields.d.ts.map +1 -0
- package/lib/types/restart-watcher.d.ts +83 -0
- package/lib/types/restart-watcher.d.ts.map +1 -0
- package/lib/types/session-reference.d.ts +66 -0
- package/lib/types/session-reference.d.ts.map +1 -0
- package/lib/types/share-export.d.ts +14 -1
- package/lib/types/share-export.d.ts.map +1 -1
- package/lib/types/share-ops.d.ts +79 -0
- package/lib/types/share-ops.d.ts.map +1 -0
- package/lib/types/share-store.d.ts +87 -0
- package/lib/types/share-store.d.ts.map +1 -0
- package/lib/types/virtual-id.d.ts +38 -0
- package/lib/types/virtual-id.d.ts.map +1 -0
- package/lib/virtual-id.js +52 -0
- package/lib/virtual-id.js.map +1 -0
- package/llms.txt +17 -14
- package/package.json +60 -50
- package/scripts/build-client.mjs +5 -3
- package/scripts/check-host-shadow.mjs +19 -2
- package/scripts/check-settings-form.mjs +933 -0
- package/scripts/check-shares.mjs +397 -0
- package/scripts/check-upload-endpoint.mjs +59 -3
- package/scripts/sync-doc-version.mjs +8 -15
- package/scripts/verify-fingerprint-live.mjs +111 -0
- package/src/activity.ts +218 -0
- package/src/admin-routes.ts +627 -0
- package/src/client/MobileAttachButton.tsx +2 -2
- package/src/client/MobileAttachChips.tsx +3 -3
- package/src/client/MobileDrawerFooter.tsx +7 -4
- package/src/client/MobileHome.tsx +20 -16
- package/src/client/MobileHomeChips.tsx +16 -16
- package/src/client/MobileNavOverlay.tsx +2 -2
- package/src/client/MobileNavToggle.tsx +3 -3
- package/src/client/MobileSessionHeader.tsx +63 -42
- package/src/client/MobileSessionInfo.tsx +118 -17
- package/src/client/RemoteComposerBanner.tsx +76 -0
- package/src/client/RemoteHeaderIcon.tsx +103 -0
- package/src/client/RemoteShareMenu.tsx +57 -0
- package/src/client/RemoteStatusIcon.tsx +91 -0
- package/src/client/compat/desktop.ts +22 -0
- package/src/client/compat/types.ts +62 -0
- package/src/client/current-session-report.ts +164 -0
- package/src/client/effects/header-status.ts +21 -17
- package/src/client/effects/keyboard-guard.ts +65 -1
- package/src/client/effects/permission-sheet.ts +149 -0
- package/src/client/effects/remote-session.ts +68 -0
- package/src/client/effects/turn-fold.ts +52 -0
- package/src/client/index.tsx +151 -36
- package/src/client/locales.ts +396 -2
- package/src/client/remote-fetch.ts +125 -0
- package/src/client/remote-group-expansion.ts +190 -0
- package/src/client/remote-share-css.ts +54 -0
- package/src/client/remote-share-register.ts +244 -0
- package/src/client/remote-status-css.ts +64 -0
- package/src/client/remote-status-register.ts +107 -0
- package/src/client/settings/SettingsSection.tsx +1229 -0
- package/src/client/settings/register-settings.ts +116 -0
- package/src/client/settings/settings-css.ts +236 -0
- package/src/client/share/share-preview.tsx +6 -3
- package/src/client/share/share-sheet.tsx +3 -3
- package/src/client/styles/composer.css.ts +119 -26
- package/src/client/styles/header.css.ts +7 -7
- package/src/client/styles/info.css.ts +74 -0
- package/src/client/styles/remote-session.css.ts +251 -0
- package/src/client-data/current-session.ts +57 -0
- package/src/client-data/remote-group-expansion.ts +145 -0
- package/src/client-data/remote-status.ts +374 -0
- package/src/client-data/settings-form.ts +1371 -0
- package/src/client-data/shares.ts +467 -0
- package/src/client-pairing.ts +244 -0
- package/src/client-routes.ts +732 -0
- package/src/config.ts +447 -0
- package/src/fetch-route-intercept.ts +510 -0
- package/src/fingerprint.ts +408 -0
- package/src/http.ts +68 -0
- package/src/index.ts +719 -62
- package/src/intercept-shape.ts +87 -0
- package/src/intercept.ts +3362 -0
- package/src/merge-streams.ts +1272 -0
- package/src/relay-access.ts +669 -0
- package/src/relay-client.ts +1514 -0
- package/src/relay-filter.ts +291 -0
- package/src/relay-server.ts +2025 -0
- package/src/restart-fields.ts +36 -0
- package/src/restart-watcher.ts +140 -0
- package/src/session-reference.ts +156 -0
- package/src/share-export.ts +26 -1
- package/src/share-ops.ts +114 -0
- package/src/share-store.ts +373 -0
- 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
|
+
}
|