@adhdev/mesh-shared 1.0.60-rc.33 → 1.0.60-rc.35

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 (39) hide show
  1. package/dist/index.d.ts +10 -0
  2. package/dist/index.js +2742 -17
  3. package/dist/index.js.map +1 -1
  4. package/dist/index.mjs +2420 -17
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/mesh-tool-names.d.ts +2 -2
  7. package/dist/mesh-vocabulary.d.ts +70 -0
  8. package/dist/mission-brief.d.ts +62 -0
  9. package/dist/outbound-message.d.ts +202 -0
  10. package/dist/path-ownership.d.ts +101 -0
  11. package/dist/protocol/daemon-server.d.ts +388 -0
  12. package/dist/protocol/dashboard-daemon-p2p.d.ts +212 -0
  13. package/dist/protocol/dashboard-server.d.ts +240 -0
  14. package/dist/protocol/envelope.d.ts +55 -0
  15. package/dist/protocol/index.d.ts +15 -0
  16. package/dist/session-launch.d.ts +113 -0
  17. package/dist/session-status.d.ts +61 -0
  18. package/dist/turn-evidence.d.ts +393 -0
  19. package/dist/turn-ipc.d.ts +974 -0
  20. package/dist/worker-protocol.d.ts +69 -0
  21. package/dist/ws-protocol.d.ts +26 -43
  22. package/package.json +1 -1
  23. package/src/index.ts +10 -0
  24. package/src/mesh-tool-names.ts +0 -1
  25. package/src/mesh-vocabulary.ts +98 -0
  26. package/src/mission-brief.ts +153 -0
  27. package/src/outbound-message.ts +320 -0
  28. package/src/path-ownership.ts +271 -0
  29. package/src/protocol/daemon-server.ts +552 -0
  30. package/src/protocol/dashboard-daemon-p2p.ts +272 -0
  31. package/src/protocol/dashboard-server.ts +340 -0
  32. package/src/protocol/envelope.ts +75 -0
  33. package/src/protocol/index.ts +16 -0
  34. package/src/session-launch.ts +247 -0
  35. package/src/session-status.ts +140 -0
  36. package/src/turn-evidence.ts +542 -0
  37. package/src/turn-ipc.ts +2231 -0
  38. package/src/worker-protocol.ts +142 -0
  39. package/src/ws-protocol.ts +41 -95
@@ -0,0 +1,272 @@
1
+ /**
2
+ * protocol/dashboard-daemon-p2p — the dashboard ↔ daemon WebRTC DataChannel
3
+ * link (`data` channel, JSON frames; screenshots ride the same channel as
4
+ * binary and are not frames).
5
+ *
6
+ * Wiring-unification Phase A2 (docs/design/2026-09-23-wiring-unification.md §3 A2).
7
+ *
8
+ * Before this file ~20 kinds were matched as bare string literals on both ends
9
+ * (web-cloud `p2p.ts`, daemon-cloud `data-channel-router.ts`), the shared
10
+ * `DashboardP2PMessageKind` listed 12 of them and was imported by nothing, and
11
+ * the daemon's file-operation replies carried no `type` at all — the browser
12
+ * matched them by `id` as a fallthrough. They are now `response` frames; the
13
+ * browser decoder still accepts the typeless legacy shape from older daemons.
14
+ *
15
+ * Chunking: any request frame may be split by the sender into
16
+ * `<kind>_chunk` frames (`id`, `chunkId`, `index`, `total`, `data`) when it
17
+ * exceeds `MESH_MAX_INLINE_FRAME_BYTES` (../rpc-chunking.ts). Each direction
18
+ * reassembles BEFORE decoding, so the decoders see whole frames; the chunk
19
+ * envelope is typed here so the splitter and the reassembler agree.
20
+ *
21
+ * Content boundary: this is the P2P plane — rich session data (chat, prompts,
22
+ * transcripts, PTY output) is CORRECT here and must not be reduced.
23
+ */
24
+
25
+ import {
26
+ type CommandPayload,
27
+ type MessageId,
28
+ type AssertSameMembers,
29
+ isRecord,
30
+ isNonEmptyString,
31
+ makeTypeGuard,
32
+ } from './envelope'
33
+ import type { DaemonStatusEventWirePayload } from './daemon-server'
34
+
35
+ // ─── Chunk envelope (both directions) ──────────────────────────────────────
36
+
37
+ export type ChunkFrame<T extends string> = {
38
+ type: T
39
+ /** Request id of the frame being split, when it has one. */
40
+ id?: MessageId
41
+ chunkId: string
42
+ index: number
43
+ total: number
44
+ data: string
45
+ }
46
+
47
+ export function isChunkType(value: unknown): value is `${string}_chunk` {
48
+ return typeof value === 'string' && value.endsWith('_chunk') && value.length > '_chunk'.length
49
+ }
50
+
51
+ function readChunkFields(frame: Record<string, unknown>): Omit<ChunkFrame<string>, 'type'> | null {
52
+ if (!isNonEmptyString(frame.chunkId)) return null
53
+ if (!Number.isInteger(frame.index) || !Number.isInteger(frame.total)) return null
54
+ if (typeof frame.data !== 'string') return null
55
+ return {
56
+ ...(isNonEmptyString(frame.id) ? { id: frame.id } : {}),
57
+ chunkId: frame.chunkId,
58
+ index: frame.index as number,
59
+ total: frame.total as number,
60
+ data: frame.data,
61
+ }
62
+ }
63
+
64
+ // ─── Dashboard → Daemon ────────────────────────────────────────────────────
65
+
66
+ export const DASHBOARD_TO_DAEMON_P2P_TYPES = [
67
+ 'ping',
68
+ 'pong',
69
+ 'command',
70
+ 'input',
71
+ 'pty_input',
72
+ 'pty_resize',
73
+ 'screenshot_start',
74
+ 'screenshot_stop',
75
+ 'subscribe',
76
+ 'unsubscribe',
77
+ 'seqscribe_session_interest',
78
+ 'read',
79
+ 'write',
80
+ 'list',
81
+ ] as const
82
+ export type DashboardToDaemonP2PType = typeof DASHBOARD_TO_DAEMON_P2P_TYPES[number]
83
+ export const isDashboardToDaemonP2PType = makeTypeGuard(DASHBOARD_TO_DAEMON_P2P_TYPES)
84
+
85
+ export type DashboardToDaemonChunkType = `${DashboardToDaemonP2PType}_chunk`
86
+ export function isDashboardToDaemonChunkType(value: unknown): value is DashboardToDaemonChunkType {
87
+ return isChunkType(value) && isDashboardToDaemonP2PType(value.slice(0, -'_chunk'.length))
88
+ }
89
+
90
+ export type P2PPingMsg = { type: 'ping'; ts: number }
91
+ export type P2PPongMsg = { type: 'pong'; ts: number }
92
+ /** A command routed through the daemon's command router; `commandType`/`data` are dynamic by design. */
93
+ export type P2PCommandMsg = { type: 'command'; id: MessageId; commandType: string; data: CommandPayload }
94
+ /** Remote-desktop input (click/type/key) for the target session's IDE surface. */
95
+ export type P2PInputMsg = { type: 'input'; id: MessageId; action: string; params: CommandPayload; targetSessionId?: string }
96
+ /** Raw PTY input. `sessionId` is canonical; `targetSessionId` is the accepted alias. */
97
+ export type P2PPtyInputMsg = { type: 'pty_input'; sessionId?: string; targetSessionId?: string; data: string }
98
+ export type P2PPtyResizeMsg = { type: 'pty_resize'; sessionId: string; cols: number; rows: number }
99
+ export type P2PScreenshotStartMsg = { type: 'screenshot_start'; targetSessionId: string }
100
+ export type P2PScreenshotStopMsg = { type: 'screenshot_stop'; targetSessionId?: string }
101
+ /** Topic subscription. `params` is topic-specific (daemon-core `SubscribeRequestMap`); `object` keeps every topic's interface assignable. */
102
+ export type P2PSubscribeMsg = { type: 'subscribe'; topic: string; key: string; params: object }
103
+ export type P2PUnsubscribeMsg = { type: 'unsubscribe'; topic: string; key: string }
104
+ /** Which sessions this peer wants replicated over the seqscribe lane; also the peer's responder proof. */
105
+ export type P2PSessionInterestMsg = { type: 'seqscribe_session_interest'; sessionIds: readonly string[] }
106
+ export type P2PFileReadMsg = { type: 'read'; id: MessageId; path: string }
107
+ export type P2PFileWriteMsg = { type: 'write'; id: MessageId; path: string; content: string }
108
+ export type P2PFileListMsg = { type: 'list'; id: MessageId; path: string }
109
+ export type DashboardToDaemonChunkMsg = ChunkFrame<DashboardToDaemonChunkType>
110
+
111
+ export type DashboardToDaemonP2PMsg =
112
+ | P2PPingMsg
113
+ | P2PPongMsg
114
+ | P2PCommandMsg
115
+ | P2PInputMsg
116
+ | P2PPtyInputMsg
117
+ | P2PPtyResizeMsg
118
+ | P2PScreenshotStartMsg
119
+ | P2PScreenshotStopMsg
120
+ | P2PSubscribeMsg
121
+ | P2PUnsubscribeMsg
122
+ | P2PSessionInterestMsg
123
+ | P2PFileReadMsg
124
+ | P2PFileWriteMsg
125
+ | P2PFileListMsg
126
+
127
+ const _dashboardToDaemonNamesCoverUnion: AssertSameMembers<typeof DASHBOARD_TO_DAEMON_P2P_TYPES, DashboardToDaemonP2PMsg['type']> = true
128
+ void _dashboardToDaemonNamesCoverUnion
129
+
130
+ /** Request kinds the daemon answers by `id`; a frame without one cannot be answered and is dropped at the boundary. */
131
+ const DASHBOARD_REQUEST_TYPES = new Set<DashboardToDaemonP2PType>(['command', 'input', 'read', 'write', 'list'])
132
+
133
+ /** Parse boundary for the daemon's chunk reassembler: one `<kind>_chunk` piece of a split request. */
134
+ export function decodeDashboardToDaemonP2PChunk(raw: unknown): DashboardToDaemonChunkMsg | null {
135
+ if (!isRecord(raw) || !isDashboardToDaemonChunkType(raw.type)) return null
136
+ const fields = readChunkFields(raw)
137
+ return fields ? { type: raw.type, ...fields } : null
138
+ }
139
+
140
+ /**
141
+ * Parse boundary for the daemon (after chunk reassembly): a whole request.
142
+ * Chunk pieces are not requests and decode to null here; see
143
+ * `decodeDashboardToDaemonP2PChunk`.
144
+ */
145
+ export function decodeDashboardToDaemonP2P(raw: unknown): DashboardToDaemonP2PMsg | null {
146
+ if (!isRecord(raw) || !isDashboardToDaemonP2PType(raw.type)) return null
147
+ if (DASHBOARD_REQUEST_TYPES.has(raw.type) && !isNonEmptyString(raw.id)) return null
148
+ return raw as DashboardToDaemonP2PMsg
149
+ }
150
+
151
+ // ─── Daemon → Dashboard ────────────────────────────────────────────────────
152
+
153
+ export const DAEMON_TO_DASHBOARD_P2P_TYPES = [
154
+ 'ping',
155
+ 'pong',
156
+ 'p2p_evicted',
157
+ 'status_report',
158
+ 'status_event',
159
+ 'topic_update',
160
+ 'topic_update_chunk',
161
+ 'session_output',
162
+ 'session_output_chunk',
163
+ 'session_io_error',
164
+ 'command_result',
165
+ 'command_result_chunk',
166
+ 'response',
167
+ ] as const
168
+ export type DaemonToDashboardP2PType = typeof DAEMON_TO_DASHBOARD_P2P_TYPES[number]
169
+ export const isDaemonToDashboardP2PType = makeTypeGuard(DAEMON_TO_DASHBOARD_P2P_TYPES)
170
+
171
+ /** Rich P2P status snapshot (daemon-core `StatusReportPayload`, or a `_delta` partial of it). Dynamic by design on this plane. */
172
+ export type P2PStatusReportWirePayload = Record<string, unknown>
173
+
174
+ /** The server-bound event plus the P2P-only structured prompt (daemon-core `P2PStatusEventPayload`). */
175
+ export type P2PStatusEventWirePayload = DaemonStatusEventWirePayload & {
176
+ interactivePrompt?: unknown
177
+ promptId?: string
178
+ multiSelect?: boolean
179
+ }
180
+
181
+ /** One topic update (daemon-core `TopicUpdateEnvelope`); consumers narrow by `topic`. */
182
+ export type TopicUpdateWireEnvelope = { topic: string; key: string }
183
+
184
+ export type P2PFileEntry = { name: string; type: string; size?: number }
185
+
186
+ export type P2PEvictedMsg = { type: 'p2p_evicted'; reason: string; maxconnections: number }
187
+ export type P2PStatusReportMsg = { type: 'status_report'; payload: P2PStatusReportWirePayload; timestamp: number }
188
+ export type P2PStatusEventMsg = { type: 'status_event'; payload: P2PStatusEventWirePayload; timestamp: number }
189
+ export type P2PTopicUpdateMsg = { type: 'topic_update'; update: TopicUpdateWireEnvelope }
190
+ export type P2PTopicUpdateChunkMsg = ChunkFrame<'topic_update_chunk'>
191
+ export type P2PSessionOutputMsg = { type: 'session_output'; sessionId: string; data: string }
192
+ export type P2PSessionOutputChunkMsg = ChunkFrame<'session_output_chunk'> & { sessionId: string }
193
+ export type P2PSessionIoErrorMsg = { type: 'session_io_error'; sessionId: string; reason: string; error?: string }
194
+ /** Result of a `command` frame. The router spreads the handler result in; `resultId` preserves a result-side `id`. */
195
+ export type P2PCommandResultMsg = { type: 'command_result'; id: MessageId; success: boolean; error?: string; resultId?: unknown } & Record<string, unknown>
196
+ export type P2PCommandResultChunkMsg = ChunkFrame<'command_result_chunk'>
197
+ /** Result of an `input` or file (`read`/`write`/`list`) frame. */
198
+ export type P2PResponseMsg = {
199
+ type: 'response'
200
+ id: MessageId
201
+ success: boolean
202
+ result?: unknown
203
+ error?: string
204
+ content?: string
205
+ entries?: P2PFileEntry[]
206
+ }
207
+
208
+ export type DaemonToDashboardP2PMsg =
209
+ | P2PPingMsg
210
+ | P2PPongMsg
211
+ | P2PEvictedMsg
212
+ | P2PStatusReportMsg
213
+ | P2PStatusEventMsg
214
+ | P2PTopicUpdateMsg
215
+ | P2PTopicUpdateChunkMsg
216
+ | P2PSessionOutputMsg
217
+ | P2PSessionOutputChunkMsg
218
+ | P2PSessionIoErrorMsg
219
+ | P2PCommandResultMsg
220
+ | P2PCommandResultChunkMsg
221
+ | P2PResponseMsg
222
+
223
+ const _daemonToDashboardNamesCoverUnion: AssertSameMembers<typeof DAEMON_TO_DASHBOARD_P2P_TYPES, DaemonToDashboardP2PMsg['type']> = true
224
+ void _daemonToDashboardNamesCoverUnion
225
+
226
+ /**
227
+ * Parse boundary for the dashboard (after chunk reassembly).
228
+ *
229
+ * A typeless `{ id, success }` frame is a file/input reply from a daemon that
230
+ * predates the `response` kind; it is promoted here so the union stays
231
+ * exhaustive while the fleet rolls forward.
232
+ */
233
+ export function decodeDaemonToDashboardP2P(raw: unknown): DaemonToDashboardP2PMsg | null {
234
+ if (!isRecord(raw)) return null
235
+ if (raw.type === undefined && isNonEmptyString(raw.id) && typeof raw.success === 'boolean') {
236
+ return { ...raw, type: 'response' } as P2PResponseMsg
237
+ }
238
+ if (!isDaemonToDashboardP2PType(raw.type)) return null
239
+ switch (raw.type) {
240
+ case 'ping':
241
+ case 'pong':
242
+ case 'p2p_evicted':
243
+ return raw as DaemonToDashboardP2PMsg
244
+ case 'status_report':
245
+ case 'status_event':
246
+ return isRecord(raw.payload) ? (raw as DaemonToDashboardP2PMsg) : null
247
+ case 'topic_update':
248
+ return isRecord(raw.update) ? (raw as P2PTopicUpdateMsg) : null
249
+ case 'topic_update_chunk':
250
+ case 'command_result_chunk':
251
+ case 'session_output_chunk': {
252
+ const fields = readChunkFields(raw)
253
+ if (!fields) return null
254
+ if (raw.type === 'session_output_chunk') {
255
+ return isNonEmptyString(raw.sessionId) ? { type: raw.type, sessionId: raw.sessionId, ...fields } : null
256
+ }
257
+ return { type: raw.type, ...fields }
258
+ }
259
+ case 'session_output':
260
+ return isNonEmptyString(raw.sessionId) && typeof raw.data === 'string' ? (raw as P2PSessionOutputMsg) : null
261
+ case 'session_io_error':
262
+ return isNonEmptyString(raw.sessionId) ? (raw as P2PSessionIoErrorMsg) : null
263
+ case 'command_result':
264
+ case 'response':
265
+ return isNonEmptyString(raw.id) ? (raw as DaemonToDashboardP2PMsg) : null
266
+ }
267
+ }
268
+
269
+ /** Both directions, for the legacy `DashboardP2PMessageKind` name and the drift gate. */
270
+ export const DASHBOARD_P2P_MESSAGE_KINDS = [
271
+ ...new Set<string>([...DASHBOARD_TO_DAEMON_P2P_TYPES, ...DAEMON_TO_DASHBOARD_P2P_TYPES]),
272
+ ] as ReadonlyArray<DashboardToDaemonP2PType | DaemonToDashboardP2PType>
@@ -0,0 +1,340 @@
1
+ /**
2
+ * protocol/dashboard-server — the dashboard ↔ UserSessionDO WebSocket link,
3
+ * plus the share-viewer ↔ SharedSessionDO link the same dashboard bundle speaks.
4
+ *
5
+ * Wiring-unification Phase A2 (docs/design/2026-09-23-wiring-unification.md §3 A2).
6
+ *
7
+ * Before this file web-cloud `ws.ts` declared its own `IncomingWSMessage`
8
+ * union and `WSEventType`, the server built every outbound frame as an object
9
+ * literal, and the two disagreed: the server's `connected` frame was missing
10
+ * from the dashboard's type, while the dashboard typed `plan_limit_error`,
11
+ * `plan_limit_warning`, `daemon_screenshot` and `agent_event` that no server
12
+ * path has emitted since the dashboard WS command relay was removed. Those four
13
+ * are gone; `connected` is in.
14
+ *
15
+ * Content boundary: this link is server WS. It carries routing metadata,
16
+ * signaling and the approved approval-modal text only (`modalMessage` /
17
+ * `modalButtons`). Chat content rides the P2P link (./dashboard-daemon-p2p.ts).
18
+ */
19
+
20
+ import {
21
+ type Envelope,
22
+ type AssertSameMembers,
23
+ isRecord,
24
+ makeTypeGuard,
25
+ readEnvelopeFields,
26
+ } from './envelope'
27
+ import {
28
+ DAEMON_STATUS_EVENT_NAMES,
29
+ type DaemonStatusEventName,
30
+ type P2PIceWirePayload,
31
+ type P2POfferWirePayload,
32
+ type P2PStatusWireSummary,
33
+ type SeqscribeStatusWireSummary,
34
+ type ReleaseChannelWire,
35
+ type VersionUpdatePolicyWire,
36
+ type VersionUpdateReasonWire,
37
+ } from './daemon-server'
38
+
39
+ // ─── Status events as delivered to a dashboard ─────────────────────────────
40
+
41
+ /** Server-originated events the dashboard receives in addition to the daemon set. */
42
+ export const SERVER_STATUS_EVENT_NAMES = [
43
+ 'daemon:disconnect',
44
+ 'team:session_viewed',
45
+ 'team:view_request',
46
+ 'team:view_request_approved',
47
+ 'team:view_request_rejected',
48
+ ] as const
49
+
50
+ /** Mirror of daemon-core `DashboardStatusEventName`; pinned equal by the server at compile time. */
51
+ export const DASHBOARD_STATUS_EVENT_NAMES = [...DAEMON_STATUS_EVENT_NAMES, ...SERVER_STATUS_EVENT_NAMES] as const
52
+ export type DashboardStatusEventName = DaemonStatusEventName | typeof SERVER_STATUS_EVENT_NAMES[number]
53
+ export const isDashboardStatusEventName = makeTypeGuard(DASHBOARD_STATUS_EVENT_NAMES)
54
+
55
+ const _dashboardStatusEventNamesCoverUnion: AssertSameMembers<typeof DASHBOARD_STATUS_EVENT_NAMES, DashboardStatusEventName> = true
56
+ void _dashboardStatusEventNamesCoverUnion
57
+
58
+ /**
59
+ * Sanitized status event as the server relays it (UserSession
60
+ * `buildDashboardStatusEvent`). Mirror of daemon-core
61
+ * `DashboardStatusEventPayload` minus the P2P-only structured prompt fields
62
+ * (`interactivePrompt`/`promptId`/`multiSelect`), which the WS relay never
63
+ * carries — they are agent-authored content.
64
+ */
65
+ export type DashboardStatusEventWirePayload = {
66
+ event: DashboardStatusEventName
67
+ timestamp: number
68
+ daemonId?: string
69
+ providerType?: string
70
+ targetSessionId?: string
71
+ duration?: number
72
+ elapsedSec?: number
73
+ modalMessage?: string
74
+ modalButtons?: string[]
75
+ requestId?: string
76
+ requesterName?: string
77
+ targetName?: string
78
+ orgId?: string
79
+ permission?: string
80
+ shareUrl?: string
81
+ shareToken?: string
82
+ viewerName?: string
83
+ }
84
+
85
+ // ─── Bootstrap ─────────────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * Cold-start machine card. Mirror of daemon-core `DashboardBootstrapDaemonEntry`
89
+ * (which extends `Partial<CloudDaemonSummaryEntry>`), field for field, so the
90
+ * server's entry assigns in and the dashboard's daemon-core-typed consumer
91
+ * assigns out without a cast.
92
+ */
93
+ export type BootstrapDaemonWireEntry = {
94
+ id: string
95
+ type?: string
96
+ machineId?: string
97
+ platform?: string
98
+ hostname?: string
99
+ nickname?: string
100
+ version?: string
101
+ p2p?: P2PStatusWireSummary
102
+ seqscribe?: SeqscribeStatusWireSummary
103
+ cdpConnected?: boolean
104
+ serverVersion?: string
105
+ releaseChannel?: ReleaseChannelWire
106
+ updateChannel?: ReleaseChannelWire
107
+ updatePolicy?: VersionUpdatePolicyWire
108
+ updateCommand?: string
109
+ versionMismatch?: boolean
110
+ versionUpdateRequired?: boolean
111
+ versionUpdateReason?: VersionUpdateReasonWire
112
+ timestamp?: number
113
+ }
114
+
115
+ export const SYSTEM_ANNOUNCEMENT_TYPES = ['info', 'warning', 'critical'] as const
116
+ export type SystemAnnouncementTypeWire = typeof SYSTEM_ANNOUNCEMENT_TYPES[number]
117
+
118
+ /** Admin-authored banner; `null` clears it. Mirror of server `ActiveSystemAnnouncement` / web-cloud `SystemAnnouncement`. */
119
+ export type SystemAnnouncementWire = {
120
+ id: string
121
+ message: string
122
+ type: SystemAnnouncementTypeWire
123
+ createdAt: string | null
124
+ expiresAt: string | null
125
+ }
126
+
127
+ export type DashboardBootstrapWirePayload = {
128
+ daemons: BootstrapDaemonWireEntry[]
129
+ announcement?: SystemAnnouncementWire | null
130
+ }
131
+
132
+ export type DashboardConnectedWirePayload = {
133
+ message?: string
134
+ peerId: string
135
+ plan: string
136
+ dashboardCount: number
137
+ }
138
+
139
+ /** Daemon auth rejection relayed to the owner's dashboards — enum reason and counters only (no server-authored sentence). */
140
+ export type DashboardAuthErrorWirePayload = {
141
+ reason: string
142
+ limit?: number
143
+ current?: number
144
+ }
145
+
146
+ export type SystemAnnouncementWirePayload = {
147
+ announcement: SystemAnnouncementWire | null
148
+ }
149
+
150
+ // ─── Signaling payloads (server → dashboard, from the daemon offerer) ───────
151
+
152
+ export type DashboardP2POfferWirePayload = {
153
+ daemonId: string
154
+ peerId?: string
155
+ sdp: string
156
+ type: string
157
+ }
158
+
159
+ export type DashboardP2PIceWirePayload = {
160
+ daemonId: string
161
+ peerId?: string
162
+ candidate: string
163
+ mid?: string
164
+ sdpMid?: string | null
165
+ sdpMLineIndex?: number | null
166
+ }
167
+
168
+ // ─── UserSession → Dashboard ───────────────────────────────────────────────
169
+
170
+ export const USER_SESSION_TO_DASHBOARD_TYPES = [
171
+ 'connected',
172
+ 'initial_state',
173
+ 'daemon_status',
174
+ 'status_event',
175
+ 'system_announcement',
176
+ 'auth_error',
177
+ 'heartbeat',
178
+ 'p2p_offer',
179
+ 'p2p_ice',
180
+ ] as const
181
+ export type UserSessionToDashboardType = typeof USER_SESSION_TO_DASHBOARD_TYPES[number]
182
+ export const isUserSessionToDashboardType = makeTypeGuard(USER_SESSION_TO_DASHBOARD_TYPES)
183
+
184
+ export type DashboardConnectedMsg = Envelope<'connected', DashboardConnectedWirePayload>
185
+ export type DashboardInitialStateMsg = Envelope<'initial_state', DashboardBootstrapWirePayload>
186
+ export type DashboardDaemonStatusMsg = Envelope<'daemon_status', DashboardBootstrapWirePayload>
187
+ export type DashboardStatusEventMsg = Envelope<'status_event', DashboardStatusEventWirePayload>
188
+ export type DashboardSystemAnnouncementMsg = Envelope<'system_announcement', SystemAnnouncementWirePayload>
189
+ export type DashboardAuthErrorMsg = Envelope<'auth_error', DashboardAuthErrorWirePayload>
190
+ /**
191
+ * Reply to the dashboard's heartbeat. Carries the cached announcement so a
192
+ * stale banner self-corrects; the field is optional because the reply is first
193
+ * of all a liveness signal and must not be dropped by an older server's
194
+ * payload-less spelling.
195
+ */
196
+ export type DashboardHeartbeatReplyMsg = Envelope<'heartbeat', Partial<SystemAnnouncementWirePayload>>
197
+ export type DashboardP2POfferMsg = Envelope<'p2p_offer', DashboardP2POfferWirePayload>
198
+ export type DashboardP2PIceMsg = Envelope<'p2p_ice', DashboardP2PIceWirePayload>
199
+
200
+ export type UserSessionToDashboardMsg =
201
+ | DashboardConnectedMsg
202
+ | DashboardInitialStateMsg
203
+ | DashboardDaemonStatusMsg
204
+ | DashboardStatusEventMsg
205
+ | DashboardSystemAnnouncementMsg
206
+ | DashboardAuthErrorMsg
207
+ | DashboardHeartbeatReplyMsg
208
+ | DashboardP2POfferMsg
209
+ | DashboardP2PIceMsg
210
+
211
+ const _userSessionToDashboardNamesCoverUnion: AssertSameMembers<typeof USER_SESSION_TO_DASHBOARD_TYPES, UserSessionToDashboardMsg['type']> = true
212
+ void _userSessionToDashboardNamesCoverUnion
213
+
214
+ export type UserSessionToDashboardPayloadOf<T extends UserSessionToDashboardType> = Extract<UserSessionToDashboardMsg, { type: T }>['payload']
215
+
216
+ /** Parse boundary for the dashboard. A heartbeat reply is accepted without a payload (liveness first). */
217
+ export function decodeUserSessionToDashboard(raw: unknown): UserSessionToDashboardMsg | null {
218
+ if (!isRecord(raw) || !isUserSessionToDashboardType(raw.type)) return null
219
+ if (raw.type === 'heartbeat') {
220
+ return { type: 'heartbeat', payload: isRecord(raw.payload) ? raw.payload : {}, ...readEnvelopeFields(raw) }
221
+ }
222
+ if (!isRecord(raw.payload)) return null
223
+ return { type: raw.type, payload: raw.payload, ...readEnvelopeFields(raw) } as UserSessionToDashboardMsg
224
+ }
225
+
226
+ // ─── Dashboard → UserSession ───────────────────────────────────────────────
227
+
228
+ export const DASHBOARD_TO_USER_SESSION_TYPES = ['heartbeat', 'refresh', 'p2p_ready', 'p2p_answer', 'p2p_ice'] as const
229
+ export type DashboardToUserSessionType = typeof DASHBOARD_TO_USER_SESSION_TYPES[number]
230
+ export const isDashboardToUserSessionType = makeTypeGuard(DASHBOARD_TO_USER_SESSION_TYPES)
231
+
232
+ /** Signals a dashboard (answerer) sends toward a daemon; `daemonId` routes, `peerId` identifies the tab. */
233
+ export const DASHBOARD_P2P_SIGNAL_TYPES = ['p2p_ready', 'p2p_answer', 'p2p_ice'] as const
234
+ export type DashboardP2PSignalType = typeof DASHBOARD_P2P_SIGNAL_TYPES[number]
235
+ export const isDashboardP2PSignalType = makeTypeGuard(DASHBOARD_P2P_SIGNAL_TYPES)
236
+
237
+ export type DashboardP2PReadyWirePayload = { daemonId: string; peerId: string }
238
+ export type DashboardP2PAnswerWirePayload = { daemonId: string; peerId: string; sdp: string; type: string }
239
+ export type DashboardP2PLocalIceWirePayload = {
240
+ daemonId: string
241
+ peerId: string
242
+ candidate: string
243
+ sdpMid: string | null
244
+ sdpMLineIndex: number | null
245
+ }
246
+
247
+ export type DashboardHeartbeatMsg = { type: 'heartbeat'; timestamp?: number }
248
+ export type DashboardRefreshMsg = { type: 'refresh' }
249
+ export type DashboardP2PReadyMsg = Envelope<'p2p_ready', DashboardP2PReadyWirePayload>
250
+ export type DashboardP2PAnswerMsg = Envelope<'p2p_answer', DashboardP2PAnswerWirePayload>
251
+ export type DashboardP2PLocalIceMsg = Envelope<'p2p_ice', DashboardP2PLocalIceWirePayload>
252
+
253
+ export type DashboardToUserSessionMsg =
254
+ | DashboardHeartbeatMsg
255
+ | DashboardRefreshMsg
256
+ | DashboardP2PReadyMsg
257
+ | DashboardP2PAnswerMsg
258
+ | DashboardP2PLocalIceMsg
259
+
260
+ const _dashboardToUserSessionNamesCoverUnion: AssertSameMembers<typeof DASHBOARD_TO_USER_SESSION_TYPES, DashboardToUserSessionMsg['type']> = true
261
+ void _dashboardToUserSessionNamesCoverUnion
262
+
263
+ export type DashboardToUserSessionPayloadOf<T extends DashboardP2PSignalType> = Extract<DashboardToUserSessionMsg, { type: T }>['payload']
264
+
265
+ /** Parse boundary for UserSessionDO. Signals need a `daemonId` to route; without one there is nothing to do. */
266
+ export function decodeDashboardToUserSession(raw: unknown): DashboardToUserSessionMsg | null {
267
+ if (!isRecord(raw) || !isDashboardToUserSessionType(raw.type)) return null
268
+ if (raw.type === 'heartbeat') return { type: 'heartbeat', ...readEnvelopeFields(raw) }
269
+ if (raw.type === 'refresh') return { type: 'refresh' }
270
+ if (!isRecord(raw.payload) || typeof raw.payload.daemonId !== 'string') return null
271
+ return { type: raw.type, payload: raw.payload, ...readEnvelopeFields(raw) } as DashboardToUserSessionMsg
272
+ }
273
+
274
+ // ─── SharedSession ↔ share viewer ──────────────────────────────────────────
275
+
276
+ export const SHARED_SESSION_TO_VIEWER_TYPES = [
277
+ 'share:connected',
278
+ 'share:revoked',
279
+ 'share:expired',
280
+ 'share:error',
281
+ 'share:viewer_count',
282
+ 'p2p_offer',
283
+ 'p2p_ice',
284
+ ] as const
285
+ export type SharedSessionToViewerType = typeof SHARED_SESSION_TO_VIEWER_TYPES[number]
286
+ export const isSharedSessionToViewerType = makeTypeGuard(SHARED_SESSION_TO_VIEWER_TYPES)
287
+
288
+ /** Frames on this link are FLAT (no `payload`), except the relayed signals. Kept as the viewer page reads them. */
289
+ export type ShareConnectedMsg = {
290
+ type: 'share:connected'
291
+ viewerId: string
292
+ viewerName: string
293
+ permission: string
294
+ mode: string
295
+ providerType: string
296
+ targetSessionId?: string
297
+ ownerName: string
298
+ daemonId: string
299
+ iceServers: unknown[]
300
+ requiresAuth: boolean
301
+ }
302
+ export type ShareRevokedMsg = { type: 'share:revoked' }
303
+ export type ShareExpiredMsg = { type: 'share:expired' }
304
+ export type ShareErrorMsg = { type: 'share:error'; error: string }
305
+ export type ShareViewerCountMsg = { type: 'share:viewer_count'; count: number; maxViewers: number }
306
+ /**
307
+ * Daemon signal relayed to the addressed viewer, payload as the daemon sent it;
308
+ * `from` is the daemon DO id, which the viewer page folds in as `daemonId`.
309
+ */
310
+ export type ShareRelayedP2POfferMsg = { type: 'p2p_offer'; payload: P2POfferWirePayload; from: string }
311
+ export type ShareRelayedP2PIceMsg = { type: 'p2p_ice'; payload: P2PIceWirePayload; from: string }
312
+
313
+ export type SharedSessionToViewerMsg =
314
+ | ShareConnectedMsg
315
+ | ShareRevokedMsg
316
+ | ShareExpiredMsg
317
+ | ShareErrorMsg
318
+ | ShareViewerCountMsg
319
+ | ShareRelayedP2POfferMsg
320
+ | ShareRelayedP2PIceMsg
321
+
322
+ const _sharedSessionToViewerNamesCoverUnion: AssertSameMembers<typeof SHARED_SESSION_TO_VIEWER_TYPES, SharedSessionToViewerMsg['type']> = true
323
+ void _sharedSessionToViewerNamesCoverUnion
324
+
325
+ export const VIEWER_TO_SHARED_SESSION_TYPES = ['p2p_ready', 'p2p_offer', 'p2p_answer', 'p2p_ice'] as const
326
+ export type ViewerToSharedSessionType = typeof VIEWER_TO_SHARED_SESSION_TYPES[number]
327
+ export const isViewerToSharedSessionType = makeTypeGuard(VIEWER_TO_SHARED_SESSION_TYPES)
328
+
329
+ /** The viewer's signal as sent; the DO stamps `daemonId`/`peerId`/`sharePermission` before relaying. */
330
+ export type ViewerToSharedSessionMsg = Envelope<ViewerToSharedSessionType, Record<string, unknown>>
331
+
332
+ const _viewerToSharedSessionNamesCoverUnion: AssertSameMembers<typeof VIEWER_TO_SHARED_SESSION_TYPES, ViewerToSharedSessionMsg['type']> = true
333
+ void _viewerToSharedSessionNamesCoverUnion
334
+
335
+ /** Parse boundary for SharedSessionDO: signaling frames only. */
336
+ export function decodeViewerToSharedSession(raw: unknown): ViewerToSharedSessionMsg | null {
337
+ if (!isRecord(raw) || !isViewerToSharedSessionType(raw.type)) return null
338
+ const payload = isRecord(raw.payload) ? raw.payload : {}
339
+ return { type: raw.type, payload, ...readEnvelopeFields(raw) }
340
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * protocol/envelope — helpers shared by the three link protocols.
3
+ *
4
+ * Wiring-unification Phase A2 (docs/design/2026-09-23-wiring-unification.md).
5
+ *
6
+ * Every link (daemon↔server WS, dashboard↔UserSession WS, dashboard↔daemon
7
+ * P2P DataChannel) is described the same way:
8
+ *
9
+ * - a `const` tuple of message-kind names per direction;
10
+ * - a discriminated union of `{ type, ... }` frames derived from it;
11
+ * - a `decode<Link><Direction>(raw)` parse-boundary function that checks the
12
+ * `type` is a member and the frame carries the MINIMAL shape the union
13
+ * promises (an object payload where one is required, the top-level fields
14
+ * a payload-less frame is keyed by). Semantic validation — is this session
15
+ * id known, is the SDP well formed — stays with the handler that owns it.
16
+ *
17
+ * mesh-shared is a dependency-free leaf, so payload types here are STRUCTURAL
18
+ * mirrors of the richer daemon-core types (`RoutingSessionEntry`,
19
+ * `DaemonStatusEventPayload`, ...): daemon-core, the server and the web assign
20
+ * their richer types into these, never the other way round. A type-only import
21
+ * of daemon-core would make the published `.d.ts` of this package reference a
22
+ * package that depends on it — a cycle a leaf must not create.
23
+ */
24
+
25
+ export type MessageId = string
26
+
27
+ /** `{ type, payload }` frame with the optional correlation/ordering fields every link uses. */
28
+ export type Envelope<T extends string, P> = {
29
+ type: T
30
+ payload: P
31
+ id?: MessageId
32
+ timestamp?: number
33
+ }
34
+
35
+ /** Genuinely dynamic payloads (command arguments and results). Allowed by design; the union stays exhaustive on `type`. */
36
+ export type CommandPayload = Record<string, unknown>
37
+
38
+ export function isRecord(value: unknown): value is Record<string, unknown> {
39
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
40
+ }
41
+
42
+ export function isNonEmptyString(value: unknown): value is string {
43
+ return typeof value === 'string' && value.length > 0
44
+ }
45
+
46
+ export function isFiniteNumber(value: unknown): value is number {
47
+ return typeof value === 'number' && Number.isFinite(value)
48
+ }
49
+
50
+ export function makeTypeGuard<T extends readonly string[]>(names: T): (value: unknown) => value is T[number] {
51
+ const set: ReadonlySet<string> = new Set(names)
52
+ return (value: unknown): value is T[number] => typeof value === 'string' && set.has(value)
53
+ }
54
+
55
+ /**
56
+ * Compile-time proof that a name tuple and a union have exactly the same
57
+ * members. Used as `const _: AssertSameMembers<typeof NAMES, Union> = true`.
58
+ */
59
+ export type AssertSameMembers<Tuple extends readonly string[], Union extends string> =
60
+ [Exclude<Union, Tuple[number]>, Exclude<Tuple[number], Union>] extends [never, never] ? true : never
61
+
62
+ /** Narrow a frame's `type` to a union member, or null. */
63
+ export function decodeTypeField<T extends string>(raw: unknown, isType: (value: unknown) => value is T): { type: T; frame: Record<string, unknown> } | null {
64
+ if (!isRecord(raw)) return null
65
+ if (!isType(raw.type)) return null
66
+ return { type: raw.type, frame: raw }
67
+ }
68
+
69
+ /** Read the optional envelope fields a frame may carry, dropping malformed ones. */
70
+ export function readEnvelopeFields(frame: Record<string, unknown>): { id?: MessageId; timestamp?: number } {
71
+ return {
72
+ ...(isNonEmptyString(frame.id) ? { id: frame.id } : {}),
73
+ ...(isFiniteNumber(frame.timestamp) ? { timestamp: frame.timestamp } : {}),
74
+ }
75
+ }