@adhdev/mesh-shared 1.0.60-rc.7 → 1.0.60-rc.71
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/dist/brain-routing.d.ts +2 -2
- package/dist/index.d.ts +11 -0
- package/dist/index.js +3156 -48
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2827 -45
- package/dist/index.mjs.map +1 -1
- package/dist/interpolation.d.ts +0 -9
- package/dist/keyed-doc-delta.d.ts +105 -0
- package/dist/magi.d.ts +1 -1
- package/dist/mesh-tool-names.d.ts +27 -2
- package/dist/mesh-vocabulary.d.ts +67 -0
- package/dist/mission-brief.d.ts +62 -0
- package/dist/outbound-message.d.ts +202 -0
- package/dist/path-ownership.d.ts +101 -0
- package/dist/protocol/daemon-server.d.ts +379 -0
- package/dist/protocol/dashboard-daemon-p2p.d.ts +213 -0
- package/dist/protocol/dashboard-server.d.ts +240 -0
- package/dist/protocol/dashboard-wire-version.d.ts +21 -0
- package/dist/protocol/envelope.d.ts +55 -0
- package/dist/protocol/index.d.ts +16 -0
- package/dist/session-launch.d.ts +113 -0
- package/dist/session-status.d.ts +75 -0
- package/dist/slot-proposal.d.ts +1 -1
- package/dist/turn-evidence.d.ts +434 -0
- package/dist/turn-ipc.d.ts +1040 -0
- package/dist/types.d.ts +11 -0
- package/dist/worker-protocol.d.ts +69 -0
- package/dist/ws-protocol.d.ts +69 -41
- package/package.json +1 -1
- package/src/brain-routing.ts +3 -3
- package/src/index.ts +11 -0
- package/src/interpolation.ts +0 -18
- package/src/keyed-doc-delta.ts +413 -0
- package/src/magi.ts +1 -1
- package/src/mesh-tool-names.ts +65 -19
- package/src/mesh-vocabulary.ts +95 -0
- package/src/mission-brief.ts +153 -0
- package/src/outbound-message.ts +320 -0
- package/src/path-ownership.ts +271 -0
- package/src/protocol/daemon-server.ts +541 -0
- package/src/protocol/dashboard-daemon-p2p.ts +273 -0
- package/src/protocol/dashboard-server.ts +340 -0
- package/src/protocol/dashboard-wire-version.ts +27 -0
- package/src/protocol/envelope.ts +75 -0
- package/src/protocol/index.ts +17 -0
- package/src/session-launch.ts +247 -0
- package/src/session-status.ts +164 -0
- package/src/slot-proposal.ts +1 -1
- package/src/turn-evidence.ts +596 -0
- package/src/turn-ipc.ts +2330 -0
- package/src/types.ts +8 -0
- package/src/worker-protocol.ts +164 -0
- package/src/ws-protocol.ts +98 -93
|
@@ -0,0 +1,273 @@
|
|
|
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.
|
|
13
|
+
*
|
|
14
|
+
* Chunking: any request frame may be split by the sender into
|
|
15
|
+
* `<kind>_chunk` frames (`id`, `chunkId`, `index`, `total`, `data`) when it
|
|
16
|
+
* exceeds `MESH_MAX_INLINE_FRAME_BYTES` (../rpc-chunking.ts). Each direction
|
|
17
|
+
* reassembles BEFORE decoding, so the decoders see whole frames; the chunk
|
|
18
|
+
* envelope is typed here so the splitter and the reassembler agree.
|
|
19
|
+
*
|
|
20
|
+
* Content boundary: this is the P2P plane — rich session data (chat, prompts,
|
|
21
|
+
* transcripts, PTY output) is CORRECT here and must not be reduced.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
type CommandPayload,
|
|
26
|
+
type MessageId,
|
|
27
|
+
type AssertSameMembers,
|
|
28
|
+
isRecord,
|
|
29
|
+
isNonEmptyString,
|
|
30
|
+
makeTypeGuard,
|
|
31
|
+
} from './envelope'
|
|
32
|
+
import type { DaemonStatusEventWirePayload } from './daemon-server'
|
|
33
|
+
|
|
34
|
+
// ─── Chunk envelope (both directions) ──────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export type ChunkFrame<T extends string> = {
|
|
37
|
+
type: T
|
|
38
|
+
/** Request id of the frame being split, when it has one. */
|
|
39
|
+
id?: MessageId
|
|
40
|
+
chunkId: string
|
|
41
|
+
index: number
|
|
42
|
+
total: number
|
|
43
|
+
data: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isChunkType(value: unknown): value is `${string}_chunk` {
|
|
47
|
+
return typeof value === 'string' && value.endsWith('_chunk') && value.length > '_chunk'.length
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readChunkFields(frame: Record<string, unknown>): Omit<ChunkFrame<string>, 'type'> | null {
|
|
51
|
+
if (!isNonEmptyString(frame.chunkId)) return null
|
|
52
|
+
if (!Number.isInteger(frame.index) || !Number.isInteger(frame.total)) return null
|
|
53
|
+
if (typeof frame.data !== 'string') return null
|
|
54
|
+
return {
|
|
55
|
+
...(isNonEmptyString(frame.id) ? { id: frame.id } : {}),
|
|
56
|
+
chunkId: frame.chunkId,
|
|
57
|
+
index: frame.index as number,
|
|
58
|
+
total: frame.total as number,
|
|
59
|
+
data: frame.data,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Dashboard → Daemon ────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
export const DASHBOARD_TO_DAEMON_P2P_TYPES = [
|
|
66
|
+
'ping',
|
|
67
|
+
'pong',
|
|
68
|
+
'command',
|
|
69
|
+
'input',
|
|
70
|
+
'pty_input',
|
|
71
|
+
'pty_resize',
|
|
72
|
+
'screenshot_start',
|
|
73
|
+
'screenshot_stop',
|
|
74
|
+
'subscribe',
|
|
75
|
+
'unsubscribe',
|
|
76
|
+
'seqscribe_session_interest',
|
|
77
|
+
'read',
|
|
78
|
+
'write',
|
|
79
|
+
'list',
|
|
80
|
+
] as const
|
|
81
|
+
export type DashboardToDaemonP2PType = typeof DASHBOARD_TO_DAEMON_P2P_TYPES[number]
|
|
82
|
+
export const isDashboardToDaemonP2PType = makeTypeGuard(DASHBOARD_TO_DAEMON_P2P_TYPES)
|
|
83
|
+
|
|
84
|
+
export type DashboardToDaemonChunkType = `${DashboardToDaemonP2PType}_chunk`
|
|
85
|
+
export function isDashboardToDaemonChunkType(value: unknown): value is DashboardToDaemonChunkType {
|
|
86
|
+
return isChunkType(value) && isDashboardToDaemonP2PType(value.slice(0, -'_chunk'.length))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type P2PPingMsg = { type: 'ping'; ts: number }
|
|
90
|
+
export type P2PPongMsg = { type: 'pong'; ts: number }
|
|
91
|
+
/** A command routed through the daemon's command router; `commandType`/`data` are dynamic by design. */
|
|
92
|
+
export type P2PCommandMsg = { type: 'command'; id: MessageId; commandType: string; data: CommandPayload }
|
|
93
|
+
/** Remote-desktop input (click/type/key) for the target session's IDE surface. */
|
|
94
|
+
export type P2PInputMsg = { type: 'input'; id: MessageId; action: string; params: CommandPayload; targetSessionId?: string }
|
|
95
|
+
/** Raw PTY input. `sessionId` is canonical; `targetSessionId` is the accepted alias. */
|
|
96
|
+
export type P2PPtyInputMsg = { type: 'pty_input'; sessionId?: string; targetSessionId?: string; data: string }
|
|
97
|
+
export type P2PPtyResizeMsg = { type: 'pty_resize'; sessionId: string; cols: number; rows: number }
|
|
98
|
+
export type P2PScreenshotStartMsg = { type: 'screenshot_start'; targetSessionId: string }
|
|
99
|
+
export type P2PScreenshotStopMsg = { type: 'screenshot_stop'; targetSessionId?: string }
|
|
100
|
+
/**
|
|
101
|
+
* Topic subscription. `params` is topic-specific (daemon-core `SubscribeRequestMap`); `object` keeps every topic's interface assignable.
|
|
102
|
+
* `wireVersion` is the dashboard wire version the page speaks (./dashboard-wire-version.ts).
|
|
103
|
+
*/
|
|
104
|
+
export type P2PSubscribeMsg = { type: 'subscribe'; topic: string; key: string; params: object; wireVersion?: number }
|
|
105
|
+
export type P2PUnsubscribeMsg = { type: 'unsubscribe'; topic: string; key: string }
|
|
106
|
+
/** Which sessions this peer wants replicated over the seqscribe lane; also the peer's responder proof. */
|
|
107
|
+
export type P2PSessionInterestMsg = { type: 'seqscribe_session_interest'; sessionIds: readonly string[] }
|
|
108
|
+
export type P2PFileReadMsg = { type: 'read'; id: MessageId; path: string }
|
|
109
|
+
export type P2PFileWriteMsg = { type: 'write'; id: MessageId; path: string; content: string }
|
|
110
|
+
export type P2PFileListMsg = { type: 'list'; id: MessageId; path: string }
|
|
111
|
+
export type DashboardToDaemonChunkMsg = ChunkFrame<DashboardToDaemonChunkType>
|
|
112
|
+
|
|
113
|
+
export type DashboardToDaemonP2PMsg =
|
|
114
|
+
| P2PPingMsg
|
|
115
|
+
| P2PPongMsg
|
|
116
|
+
| P2PCommandMsg
|
|
117
|
+
| P2PInputMsg
|
|
118
|
+
| P2PPtyInputMsg
|
|
119
|
+
| P2PPtyResizeMsg
|
|
120
|
+
| P2PScreenshotStartMsg
|
|
121
|
+
| P2PScreenshotStopMsg
|
|
122
|
+
| P2PSubscribeMsg
|
|
123
|
+
| P2PUnsubscribeMsg
|
|
124
|
+
| P2PSessionInterestMsg
|
|
125
|
+
| P2PFileReadMsg
|
|
126
|
+
| P2PFileWriteMsg
|
|
127
|
+
| P2PFileListMsg
|
|
128
|
+
|
|
129
|
+
const _dashboardToDaemonNamesCoverUnion: AssertSameMembers<typeof DASHBOARD_TO_DAEMON_P2P_TYPES, DashboardToDaemonP2PMsg['type']> = true
|
|
130
|
+
void _dashboardToDaemonNamesCoverUnion
|
|
131
|
+
|
|
132
|
+
/** Request kinds the daemon answers by `id`; a frame without one cannot be answered and is dropped at the boundary. */
|
|
133
|
+
const DASHBOARD_REQUEST_TYPES = new Set<DashboardToDaemonP2PType>(['command', 'input', 'read', 'write', 'list'])
|
|
134
|
+
|
|
135
|
+
/** Parse boundary for the daemon's chunk reassembler: one `<kind>_chunk` piece of a split request. */
|
|
136
|
+
export function decodeDashboardToDaemonP2PChunk(raw: unknown): DashboardToDaemonChunkMsg | null {
|
|
137
|
+
if (!isRecord(raw) || !isDashboardToDaemonChunkType(raw.type)) return null
|
|
138
|
+
const fields = readChunkFields(raw)
|
|
139
|
+
return fields ? { type: raw.type, ...fields } : null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse boundary for the daemon (after chunk reassembly): a whole request.
|
|
144
|
+
* Chunk pieces are not requests and decode to null here; see
|
|
145
|
+
* `decodeDashboardToDaemonP2PChunk`.
|
|
146
|
+
*/
|
|
147
|
+
export function decodeDashboardToDaemonP2P(raw: unknown): DashboardToDaemonP2PMsg | null {
|
|
148
|
+
if (!isRecord(raw) || !isDashboardToDaemonP2PType(raw.type)) return null
|
|
149
|
+
if (DASHBOARD_REQUEST_TYPES.has(raw.type) && !isNonEmptyString(raw.id)) return null
|
|
150
|
+
return raw as DashboardToDaemonP2PMsg
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── Daemon → Dashboard ────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
export const DAEMON_TO_DASHBOARD_P2P_TYPES = [
|
|
156
|
+
'ping',
|
|
157
|
+
'pong',
|
|
158
|
+
'p2p_evicted',
|
|
159
|
+
'status_event',
|
|
160
|
+
'topic_update',
|
|
161
|
+
'topic_update_chunk',
|
|
162
|
+
'session_output',
|
|
163
|
+
'session_output_chunk',
|
|
164
|
+
'session_io_error',
|
|
165
|
+
'command_result',
|
|
166
|
+
'command_result_chunk',
|
|
167
|
+
'response',
|
|
168
|
+
'transcript_topics_available',
|
|
169
|
+
] as const
|
|
170
|
+
export type DaemonToDashboardP2PType = typeof DAEMON_TO_DASHBOARD_P2P_TYPES[number]
|
|
171
|
+
export const isDaemonToDashboardP2PType = makeTypeGuard(DAEMON_TO_DASHBOARD_P2P_TYPES)
|
|
172
|
+
|
|
173
|
+
/** The server-bound event plus the P2P-only structured prompt (daemon-core `P2PStatusEventPayload`). */
|
|
174
|
+
export type P2PStatusEventWirePayload = DaemonStatusEventWirePayload & {
|
|
175
|
+
interactivePrompt?: unknown
|
|
176
|
+
promptId?: string
|
|
177
|
+
multiSelect?: boolean
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** One topic update (daemon-core `TopicUpdateEnvelope`); consumers narrow by `topic`. */
|
|
181
|
+
export type TopicUpdateWireEnvelope = { topic: string; key: string }
|
|
182
|
+
|
|
183
|
+
export type P2PFileEntry = { name: string; type: string; size?: number }
|
|
184
|
+
|
|
185
|
+
export type P2PEvictedMsg = { type: 'p2p_evicted'; reason: string; maxconnections: number }
|
|
186
|
+
export type P2PStatusEventMsg = { type: 'status_event'; payload: P2PStatusEventWirePayload; timestamp: number }
|
|
187
|
+
export type P2PTopicUpdateMsg = { type: 'topic_update'; update: TopicUpdateWireEnvelope }
|
|
188
|
+
export type P2PTopicUpdateChunkMsg = ChunkFrame<'topic_update_chunk'>
|
|
189
|
+
export type P2PSessionOutputMsg = { type: 'session_output'; sessionId: string; data: string }
|
|
190
|
+
export type P2PSessionOutputChunkMsg = ChunkFrame<'session_output_chunk'> & { sessionId: string }
|
|
191
|
+
export type P2PSessionIoErrorMsg = { type: 'session_io_error'; sessionId: string; reason: string; error?: string }
|
|
192
|
+
/** Result of a `command` frame. The router spreads the handler result in; `resultId` preserves a result-side `id`. */
|
|
193
|
+
export type P2PCommandResultMsg = { type: 'command_result'; id: MessageId; success: boolean; error?: string; resultId?: unknown } & Record<string, unknown>
|
|
194
|
+
export type P2PCommandResultChunkMsg = ChunkFrame<'command_result_chunk'>
|
|
195
|
+
/** Result of an `input` or file (`read`/`write`/`list`) frame. */
|
|
196
|
+
export type P2PResponseMsg = {
|
|
197
|
+
type: 'response'
|
|
198
|
+
id: MessageId
|
|
199
|
+
success: boolean
|
|
200
|
+
result?: unknown
|
|
201
|
+
error?: string
|
|
202
|
+
content?: string
|
|
203
|
+
entries?: P2PFileEntry[]
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* `session.<id>.chat` topics that just became SUB-able for THIS peer (the
|
|
208
|
+
* topic was defined, or the peer's declared session interest widened to it).
|
|
209
|
+
* Sent only after the grant is in place, so a seqscribe SUB issued on receipt
|
|
210
|
+
* is accepted; without it the browser can only guess when to re-SUB a refused
|
|
211
|
+
* subscription. Topic names only — the P2P plane already carries the ids.
|
|
212
|
+
*/
|
|
213
|
+
export type P2PTranscriptTopicsAvailableMsg = { type: 'transcript_topics_available'; topics: string[] }
|
|
214
|
+
|
|
215
|
+
export type DaemonToDashboardP2PMsg =
|
|
216
|
+
| P2PPingMsg
|
|
217
|
+
| P2PPongMsg
|
|
218
|
+
| P2PEvictedMsg
|
|
219
|
+
| P2PStatusEventMsg
|
|
220
|
+
| P2PTopicUpdateMsg
|
|
221
|
+
| P2PTopicUpdateChunkMsg
|
|
222
|
+
| P2PSessionOutputMsg
|
|
223
|
+
| P2PSessionOutputChunkMsg
|
|
224
|
+
| P2PSessionIoErrorMsg
|
|
225
|
+
| P2PCommandResultMsg
|
|
226
|
+
| P2PCommandResultChunkMsg
|
|
227
|
+
| P2PResponseMsg
|
|
228
|
+
| P2PTranscriptTopicsAvailableMsg
|
|
229
|
+
|
|
230
|
+
const _daemonToDashboardNamesCoverUnion: AssertSameMembers<typeof DAEMON_TO_DASHBOARD_P2P_TYPES, DaemonToDashboardP2PMsg['type']> = true
|
|
231
|
+
void _daemonToDashboardNamesCoverUnion
|
|
232
|
+
|
|
233
|
+
/** Parse boundary for the dashboard (after chunk reassembly). */
|
|
234
|
+
export function decodeDaemonToDashboardP2P(raw: unknown): DaemonToDashboardP2PMsg | null {
|
|
235
|
+
if (!isRecord(raw)) return null
|
|
236
|
+
if (!isDaemonToDashboardP2PType(raw.type)) return null
|
|
237
|
+
switch (raw.type) {
|
|
238
|
+
case 'ping':
|
|
239
|
+
case 'pong':
|
|
240
|
+
case 'p2p_evicted':
|
|
241
|
+
return raw as DaemonToDashboardP2PMsg
|
|
242
|
+
case 'status_event':
|
|
243
|
+
return isRecord(raw.payload) ? (raw as DaemonToDashboardP2PMsg) : null
|
|
244
|
+
case 'topic_update':
|
|
245
|
+
return isRecord(raw.update) ? (raw as P2PTopicUpdateMsg) : null
|
|
246
|
+
case 'topic_update_chunk':
|
|
247
|
+
case 'command_result_chunk':
|
|
248
|
+
case 'session_output_chunk': {
|
|
249
|
+
const fields = readChunkFields(raw)
|
|
250
|
+
if (!fields) return null
|
|
251
|
+
if (raw.type === 'session_output_chunk') {
|
|
252
|
+
return isNonEmptyString(raw.sessionId) ? { type: raw.type, sessionId: raw.sessionId, ...fields } : null
|
|
253
|
+
}
|
|
254
|
+
return { type: raw.type, ...fields }
|
|
255
|
+
}
|
|
256
|
+
case 'session_output':
|
|
257
|
+
return isNonEmptyString(raw.sessionId) && typeof raw.data === 'string' ? (raw as P2PSessionOutputMsg) : null
|
|
258
|
+
case 'session_io_error':
|
|
259
|
+
return isNonEmptyString(raw.sessionId) ? (raw as P2PSessionIoErrorMsg) : null
|
|
260
|
+
case 'command_result':
|
|
261
|
+
case 'response':
|
|
262
|
+
return isNonEmptyString(raw.id) ? (raw as DaemonToDashboardP2PMsg) : null
|
|
263
|
+
case 'transcript_topics_available':
|
|
264
|
+
return Array.isArray(raw.topics) && raw.topics.every((topic) => typeof topic === 'string')
|
|
265
|
+
? { type: raw.type, topics: raw.topics as string[] }
|
|
266
|
+
: null
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Both directions, for the legacy `DashboardP2PMessageKind` name and the drift gate. */
|
|
271
|
+
export const DASHBOARD_P2P_MESSAGE_KINDS = [
|
|
272
|
+
...new Set<string>([...DASHBOARD_TO_DAEMON_P2P_TYPES, ...DAEMON_TO_DASHBOARD_P2P_TYPES]),
|
|
273
|
+
] 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,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* protocol/dashboard-wire-version — the dashboard ↔ daemon wire version.
|
|
3
|
+
*
|
|
4
|
+
* Both dashboards (cloud over P2P, standalone over WS) state the version they
|
|
5
|
+
* speak on every topic subscribe; the daemon states its own on the standalone
|
|
6
|
+
* hello, on every keyed snapshot frame, and in the `protocol_mismatch` reply it
|
|
7
|
+
* sends INSTEAD of state when the two differ. There is no dual-format serving:
|
|
8
|
+
* a mismatched pair renders no state at all —
|
|
9
|
+
* - the page is older than the daemon → the page must reload (its bundle
|
|
10
|
+
* cannot read the daemon's frames);
|
|
11
|
+
* - the daemon is older than the page → the daemon must be updated.
|
|
12
|
+
*
|
|
13
|
+
* Bump this whenever a frame shape changes incompatibly; one release carries
|
|
14
|
+
* the same value on both ends.
|
|
15
|
+
* 1 — implicit (frames before versioning).
|
|
16
|
+
* 2 — daemon.metadata deltas on the keyed-doc engine (2026-09-29).
|
|
17
|
+
*/
|
|
18
|
+
export const DASHBOARD_WIRE_VERSION = 2
|
|
19
|
+
|
|
20
|
+
export type DashboardWireCompat = 'ok' | 'reload_required' | 'daemon_update_required'
|
|
21
|
+
|
|
22
|
+
/** How a page speaking `pageVersion` relates to a daemon speaking `daemonVersion` (absent = 1). */
|
|
23
|
+
export function compareDashboardWireVersion(pageVersion: number, daemonVersion: number | null | undefined): DashboardWireCompat {
|
|
24
|
+
const daemon = typeof daemonVersion === 'number' && Number.isFinite(daemonVersion) ? daemonVersion : 1
|
|
25
|
+
if (daemon === pageVersion) return 'ok'
|
|
26
|
+
return daemon > pageVersion ? 'reload_required' : 'daemon_update_required'
|
|
27
|
+
}
|