@zhihand/mcp 0.30.0 → 0.32.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.
@@ -1,29 +1,44 @@
1
1
  /**
2
2
  * Device registry — the single source of truth for all paired devices,
3
- * their live state (profile, online flag, SSE connection), and multi-
4
- * device routing.
3
+ * their live state, and multi-user WebSocket streams.
5
4
  *
6
- * Holds a per-credential AbortController for SSE, a per-device heartbeat
7
- * timer, and a single debounced notifier for list_changed.
5
+ * Groups devices under users. Each user has one UserEventWebSocket.
6
+ * Online detection is server-authoritative (no local heartbeat polling).
7
+ * Config hot-reload via fs.watchFile.
8
8
  */
9
- import { loadConfig, addDevice as configAddDevice, removeDevice as configRemoveDevice, renameDevice as configRenameDevice, setDefaultDevice as configSetDefault, updateLastSeen as configUpdateLastSeen, recordToRuntimeConfig, } from "./config.js";
10
- import { extractStatic, computeCapabilities, fetchDeviceProfileOnce, normalizeProfilePayload, } from "./device.js";
11
- import { connectSSEForCredential, handleSSEEvent } from "./sse.js";
12
- import { dbg } from "../daemon/logger.js";
13
- const HEARTBEAT_INTERVAL_MS = 30_000;
14
- const ONLINE_PROFILE_TTL_MS = 60_000;
9
+ import fs from "node:fs";
10
+ import { loadConfig, getConfigPath, resolveDefaultEndpoint, addDeviceToUser as configAddDeviceToUser, updateDeviceLastSeen as configUpdateDeviceLastSeen, } from "./config.js";
11
+ import { extractStatic, computeCapabilities, normalizeProfilePayload, } from "./device.js";
12
+ import { UserEventWebSocket, fetchUserCredentials, } from "./ws.js";
13
+ import { log } from "./logger.js";
15
14
  const LIST_CHANGED_DEBOUNCE_MS = 2500;
15
+ const CONFIG_WATCH_INTERVAL_MS = 1000;
16
+ const CONFIG_RECONCILE_DEBOUNCE_MS = 300;
16
17
  class Registry {
17
- devices = new Map();
18
+ userStates = new Map();
18
19
  listChangedSubs = new Set();
19
20
  debounceTimer = null;
20
21
  lastOnlineSet = new Set();
21
22
  initialized = false;
23
+ configWatchActive = false;
24
+ reconcileTimer = null;
25
+ // ── Public API ──────────────────────────────────────────
22
26
  get(credentialId) {
23
- return this.devices.get(credentialId) ?? null;
27
+ for (const us of this.userStates.values()) {
28
+ const d = us.devices.get(credentialId);
29
+ if (d)
30
+ return d;
31
+ }
32
+ return null;
24
33
  }
25
34
  list() {
26
- return Array.from(this.devices.values());
35
+ const all = [];
36
+ for (const us of this.userStates.values()) {
37
+ for (const d of us.devices.values()) {
38
+ all.push(d);
39
+ }
40
+ }
41
+ return all;
27
42
  }
28
43
  listOnline() {
29
44
  return this.list()
@@ -31,38 +46,82 @@ class Registry {
31
46
  .sort((a, b) => b.lastSeenAtMs - a.lastSeenAtMs);
32
47
  }
33
48
  /**
34
- * Priority:
35
- * 1. If the user has explicitly set a default via `zhihand default <id>`
36
- * AND that device is online → return it. Honoring an explicit user
37
- * preference is the least-surprising UX.
38
- * 2. Otherwise → most-recently-active online device (online[0] is sorted
39
- * desc by lastSeenAtMs).
40
- * 3. No online devices → null.
49
+ * Most-recently-active online device across all users.
41
50
  */
42
51
  resolveDefault() {
43
52
  const online = this.listOnline();
44
- if (online.length === 0)
45
- return null;
46
- const cfg = loadConfig();
47
- if (cfg.default_credential_id) {
48
- const d = this.devices.get(cfg.default_credential_id);
49
- if (d && d.online)
50
- return d;
51
- }
52
- return online[0];
53
+ return online.length > 0 ? online[0] : null;
54
+ }
55
+ isMultiUser() {
56
+ return this.userStates.size > 1;
53
57
  }
54
58
  toRuntimeConfig(state) {
55
- return recordToRuntimeConfig(state.record);
59
+ const us = this.userStates.get(state.userId);
60
+ return {
61
+ controlPlaneEndpoint: us?.endpoint ?? resolveDefaultEndpoint(),
62
+ credentialId: state.credentialId,
63
+ controllerToken: us?.controllerToken ?? "",
64
+ timeoutMs: 10_000,
65
+ };
56
66
  }
57
67
  subscribe(cb) {
58
68
  this.listChangedSubs.add(cb);
59
69
  return () => this.listChangedSubs.delete(cb);
60
70
  }
71
+ // ── Init / Shutdown ────────────────────────────────────
72
+ async init() {
73
+ if (this.initialized)
74
+ return;
75
+ this.initialized = true;
76
+ const cfg = loadConfig();
77
+ const endpoint = resolveDefaultEndpoint();
78
+ const users = Object.values(cfg.users);
79
+ // Create user states and start WS streams
80
+ const fetchPromises = [];
81
+ for (const userRec of users) {
82
+ const us = this.createUserState(userRec, endpoint);
83
+ this.userStates.set(userRec.user_id, us);
84
+ this.startUserStream(us);
85
+ // Fetch initial credentials with online status
86
+ fetchPromises.push(this.fetchAndPopulateDevices(us, userRec).catch(() => {
87
+ // Non-fatal: populate from config alone
88
+ this.populateDevicesFromConfig(us, userRec);
89
+ }));
90
+ }
91
+ // Overall 5s cap on initial fetches
92
+ await Promise.race([
93
+ Promise.all(fetchPromises),
94
+ new Promise((r) => setTimeout(r, 5000)),
95
+ ]);
96
+ // Start config hot-reload watcher
97
+ this.startConfigWatch();
98
+ }
99
+ shutdown() {
100
+ for (const us of this.userStates.values()) {
101
+ us.stream?.stop();
102
+ us.stream = null;
103
+ }
104
+ this.userStates.clear();
105
+ if (this.debounceTimer) {
106
+ clearTimeout(this.debounceTimer);
107
+ this.debounceTimer = null;
108
+ }
109
+ if (this.reconcileTimer) {
110
+ clearTimeout(this.reconcileTimer);
111
+ this.reconcileTimer = null;
112
+ }
113
+ this.stopConfigWatch();
114
+ this.listChangedSubs.clear();
115
+ this.initialized = false;
116
+ }
117
+ // ── Online set change detection ─────────────────────────
61
118
  computeOnlineSet() {
62
119
  const out = new Set();
63
- for (const d of this.devices.values()) {
64
- if (d.online)
65
- out.add(d.credentialId);
120
+ for (const us of this.userStates.values()) {
121
+ for (const d of us.devices.values()) {
122
+ if (d.online)
123
+ out.add(d.credentialId);
124
+ }
66
125
  }
67
126
  return out;
68
127
  }
@@ -76,9 +135,8 @@ class Registry {
76
135
  }
77
136
  scheduleListChanged() {
78
137
  const now = this.computeOnlineSet();
79
- if (this.setsEqual(now, this.lastOnlineSet)) {
138
+ if (this.setsEqual(now, this.lastOnlineSet))
80
139
  return;
81
- }
82
140
  if (this.debounceTimer)
83
141
  return;
84
142
  this.debounceTimer = setTimeout(() => {
@@ -95,194 +153,263 @@ class Registry {
95
153
  }
96
154
  }, LIST_CHANGED_DEBOUNCE_MS);
97
155
  }
98
- updateOnlineFlag(state) {
99
- const profileFresh = state.profileReceivedAtMs > 0 &&
100
- (Date.now() - state.profileReceivedAtMs) < ONLINE_PROFILE_TTL_MS;
101
- const newOnline = state.sseConnected && profileFresh;
102
- if (newOnline !== state.online) {
103
- state.online = newOnline;
104
- dbg(`[registry] ${state.credentialId} online=${newOnline}`);
105
- this.scheduleListChanged();
106
- }
156
+ // ── User/Device state management ───────────────────────
157
+ createUserState(userRec, endpoint) {
158
+ return {
159
+ userId: userRec.user_id,
160
+ controllerToken: userRec.controller_token,
161
+ label: userRec.label,
162
+ endpoint,
163
+ stream: null,
164
+ devices: new Map(),
165
+ };
107
166
  }
108
- touchLastSeen(state) {
109
- state.lastSeenAtMs = Date.now();
110
- const iso = new Date(state.lastSeenAtMs).toISOString();
111
- try {
112
- configUpdateLastSeen(state.credentialId, iso);
113
- state.record.last_seen_at = iso;
114
- }
115
- catch {
116
- // non-fatal
117
- }
167
+ makeDeviceState(record, userId, userLabel, online = false) {
168
+ return {
169
+ credentialId: record.credential_id,
170
+ userId,
171
+ userLabel,
172
+ label: record.label,
173
+ platform: record.platform,
174
+ online,
175
+ lastSeenAtMs: 0,
176
+ profile: null,
177
+ capabilities: null,
178
+ profileReceivedAtMs: 0,
179
+ rawAttributes: {},
180
+ record,
181
+ };
118
182
  }
119
- async refreshProfile(state) {
120
- const cfg = this.toRuntimeConfig(state);
121
- const result = await fetchDeviceProfileOnce(cfg);
122
- if (!result) {
123
- state.online = false;
124
- this.scheduleListChanged();
125
- return false;
126
- }
127
- state.rawAttributes = result.rawAttrs;
128
- state.profileReceivedAtMs = result.receivedAtMs;
129
- state.profile = extractStatic(result.rawAttrs);
130
- state.capabilities = computeCapabilities(result.rawAttrs, result.receivedAtMs);
131
- // Infer platform from profile
132
- const plat = state.profile.platform;
133
- if (plat === "ios" || plat === "android") {
134
- state.platform = plat;
135
- state.record.platform = plat;
183
+ populateDevicesFromConfig(us, userRec) {
184
+ for (const dev of userRec.devices) {
185
+ if (!us.devices.has(dev.credential_id)) {
186
+ us.devices.set(dev.credential_id, this.makeDeviceState(dev, us.userId, us.label));
187
+ }
136
188
  }
137
- this.touchLastSeen(state);
138
- this.updateOnlineFlag(state);
139
- return true;
140
- }
141
- startHeartbeat(state) {
142
- if (state.heartbeatTimer)
143
- return;
144
- state.heartbeatTimer = setInterval(() => {
145
- this.refreshProfile(state).catch(() => { });
146
- }, HEARTBEAT_INTERVAL_MS);
147
189
  }
148
- stopHeartbeat(state) {
149
- if (state.heartbeatTimer) {
150
- clearInterval(state.heartbeatTimer);
151
- state.heartbeatTimer = null;
190
+ async fetchAndPopulateDevices(us, userRec) {
191
+ const creds = await fetchUserCredentials(us.endpoint, us.userId, us.controllerToken);
192
+ // Populate from server response (has online status)
193
+ for (const cred of creds) {
194
+ const existing = us.devices.get(cred.credential_id);
195
+ if (existing) {
196
+ existing.online = cred.online ?? false;
197
+ if (cred.online) {
198
+ existing.lastSeenAtMs = Date.now();
199
+ }
200
+ }
201
+ else {
202
+ const record = {
203
+ credential_id: cred.credential_id,
204
+ label: cred.label ?? cred.credential_id,
205
+ platform: cred.platform ?? "unknown",
206
+ paired_at: cred.paired_at ?? new Date().toISOString(),
207
+ last_seen_at: cred.last_seen_at ?? new Date().toISOString(),
208
+ };
209
+ const state = this.makeDeviceState(record, us.userId, us.label, cred.online ?? false);
210
+ if (cred.online)
211
+ state.lastSeenAtMs = Date.now();
212
+ us.devices.set(cred.credential_id, state);
213
+ }
152
214
  }
215
+ // Also ensure all config devices are present
216
+ this.populateDevicesFromConfig(us, userRec);
153
217
  }
154
- startSSE(state) {
155
- if (state.sseController)
218
+ // ── WS stream management ──────────────────────────────
219
+ startUserStream(us) {
220
+ if (us.stream)
156
221
  return;
157
- const cfg = this.toRuntimeConfig(state);
158
- state.sseController = connectSSEForCredential(cfg, {
159
- onEvent: (ev) => {
160
- // Dispatch command ACKs globally
161
- handleSSEEvent(ev);
162
- if (ev.kind === "device_profile.updated" && ev.device_profile) {
163
- const attrs = normalizeProfilePayload(ev.device_profile);
164
- state.rawAttributes = attrs;
165
- state.profileReceivedAtMs = Date.now();
166
- state.profile = extractStatic(attrs);
167
- state.capabilities = computeCapabilities(attrs, state.profileReceivedAtMs);
168
- const plat = state.profile.platform;
222
+ us.stream = new UserEventWebSocket(us.userId, us.controllerToken, us.endpoint, {
223
+ onDeviceOnline: (credentialId) => {
224
+ const d = us.devices.get(credentialId);
225
+ if (d) {
226
+ d.online = true;
227
+ d.lastSeenAtMs = Date.now();
228
+ this.touchLastSeen(d);
229
+ log.debug(`[registry] ${credentialId} online`);
230
+ this.scheduleListChanged();
231
+ }
232
+ },
233
+ onDeviceOffline: (credentialId) => {
234
+ const d = us.devices.get(credentialId);
235
+ if (d) {
236
+ d.online = false;
237
+ log.debug(`[registry] ${credentialId} offline`);
238
+ this.scheduleListChanged();
239
+ }
240
+ },
241
+ onDeviceProfileUpdated: (credentialId, profile) => {
242
+ const d = us.devices.get(credentialId);
243
+ if (d) {
244
+ const attrs = normalizeProfilePayload(profile);
245
+ d.rawAttributes = attrs;
246
+ d.profileReceivedAtMs = Date.now();
247
+ d.profile = extractStatic(attrs);
248
+ d.capabilities = computeCapabilities(attrs, d.profileReceivedAtMs);
249
+ const plat = d.profile.platform;
169
250
  if (plat === "ios" || plat === "android") {
170
- state.platform = plat;
171
- state.record.platform = plat;
251
+ d.platform = plat;
252
+ d.record.platform = plat;
172
253
  }
173
- this.touchLastSeen(state);
174
- this.updateOnlineFlag(state);
254
+ this.touchLastSeen(d);
175
255
  }
176
- else if (ev.kind === "credential.revoked") {
177
- dbg(`[registry] ${state.credentialId} credential.revoked`);
178
- state.online = false;
256
+ },
257
+ onCommandAcked: (event) => {
258
+ // Already handled by handleWSEvent in the stream dispatch
259
+ },
260
+ onCredentialAdded: (credential) => {
261
+ const credId = credential.credential_id;
262
+ if (credId && !us.devices.has(credId)) {
263
+ const record = {
264
+ credential_id: credId,
265
+ label: credential.label ?? credId,
266
+ platform: (credential.platform ?? "unknown"),
267
+ paired_at: credential.paired_at ?? new Date().toISOString(),
268
+ last_seen_at: new Date().toISOString(),
269
+ };
270
+ const state = this.makeDeviceState(record, us.userId, us.label, true);
271
+ state.lastSeenAtMs = Date.now();
272
+ us.devices.set(credId, state);
273
+ // Persist to config immediately so fs.watchFile reconciliation
274
+ // doesn't wipe this device before the pairing CLI writes it.
275
+ try {
276
+ configAddDeviceToUser(us.userId, record);
277
+ }
278
+ catch {
279
+ // non-fatal — pairing CLI will persist shortly
280
+ }
179
281
  this.scheduleListChanged();
180
282
  }
181
283
  },
284
+ onCredentialRemoved: (credentialId) => {
285
+ us.devices.delete(credentialId);
286
+ this.scheduleListChanged();
287
+ },
182
288
  onConnected: () => {
183
- dbg(`[registry] SSE connected: ${state.credentialId}`);
184
- state.sseConnected = true;
185
- this.updateOnlineFlag(state);
289
+ log.debug(`[registry] WS connected for user ${us.userId}`);
290
+ // Re-fetch credentials to reconcile missed events
291
+ fetchUserCredentials(us.endpoint, us.userId, us.controllerToken)
292
+ .then((creds) => {
293
+ const onlineIds = new Set(creds.filter((c) => c.online).map((c) => c.credential_id));
294
+ for (const d of us.devices.values()) {
295
+ const wasOnline = d.online;
296
+ d.online = onlineIds.has(d.credentialId);
297
+ if (d.online)
298
+ d.lastSeenAtMs = Date.now();
299
+ if (wasOnline !== d.online) {
300
+ this.scheduleListChanged();
301
+ }
302
+ }
303
+ })
304
+ .catch(() => { });
186
305
  },
187
306
  onDisconnected: () => {
188
- dbg(`[registry] SSE disconnected: ${state.credentialId}`);
189
- state.sseConnected = false;
190
- this.updateOnlineFlag(state);
307
+ log.debug(`[registry] WS disconnected for user ${us.userId}`);
191
308
  },
192
309
  });
310
+ us.stream.start();
193
311
  }
194
- stopSSE(state) {
195
- state.sseController?.abort();
196
- state.sseController = null;
197
- state.sseConnected = false;
198
- }
199
- makeState(record) {
200
- return {
201
- credentialId: record.credential_id,
202
- label: record.label,
203
- platform: record.platform,
204
- online: false,
205
- lastSeenAtMs: 0,
206
- profile: null,
207
- capabilities: null,
208
- profileReceivedAtMs: 0,
209
- rawAttributes: {},
210
- sseController: null,
211
- sseConnected: false,
212
- heartbeatTimer: null,
213
- record,
214
- };
215
- }
216
- async init() {
217
- if (this.initialized)
218
- return;
219
- this.initialized = true;
220
- const cfg = loadConfig();
221
- const records = Object.values(cfg.devices);
222
- for (const r of records) {
223
- const s = this.makeState(r);
224
- this.devices.set(r.credential_id, s);
225
- this.startSSE(s);
226
- this.startHeartbeat(s);
312
+ touchLastSeen(state) {
313
+ state.lastSeenAtMs = Date.now();
314
+ const iso = new Date(state.lastSeenAtMs).toISOString();
315
+ try {
316
+ configUpdateDeviceLastSeen(state.userId, state.credentialId, iso);
317
+ state.record.last_seen_at = iso;
318
+ }
319
+ catch {
320
+ // non-fatal
227
321
  }
228
- // Fire off initial profile fetches in parallel, with overall ~5s cap
229
- const fetches = records.map((r) => {
230
- const s = this.devices.get(r.credential_id);
231
- return this.refreshProfile(s).catch(() => false);
232
- });
233
- await Promise.race([
234
- Promise.all(fetches),
235
- new Promise((r) => setTimeout(r, 5000)),
236
- ]);
237
322
  }
238
- async addDevice(record) {
239
- configAddDevice(record);
240
- let s = this.devices.get(record.credential_id);
241
- if (!s) {
242
- s = this.makeState(record);
243
- this.devices.set(record.credential_id, s);
323
+ // ── Config hot-reload ─────────────────────────────────
324
+ startConfigWatch() {
325
+ if (this.configWatchActive)
326
+ return;
327
+ this.configWatchActive = true;
328
+ const configPath = getConfigPath();
329
+ try {
330
+ fs.watchFile(configPath, { interval: CONFIG_WATCH_INTERVAL_MS }, () => {
331
+ // Debounce reconciliation
332
+ if (this.reconcileTimer)
333
+ clearTimeout(this.reconcileTimer);
334
+ this.reconcileTimer = setTimeout(() => {
335
+ this.reconcileTimer = null;
336
+ this.reconcileConfig();
337
+ }, CONFIG_RECONCILE_DEBOUNCE_MS);
338
+ });
244
339
  }
245
- else {
246
- s.record = record;
247
- s.label = record.label;
340
+ catch {
341
+ // watchFile not available — skip hot-reload
248
342
  }
249
- this.startSSE(s);
250
- this.startHeartbeat(s);
251
- await this.refreshProfile(s).catch(() => false);
252
343
  }
253
- removeDevice(credentialId) {
254
- const s = this.devices.get(credentialId);
255
- if (s) {
256
- this.stopSSE(s);
257
- this.stopHeartbeat(s);
258
- this.devices.delete(credentialId);
344
+ stopConfigWatch() {
345
+ if (!this.configWatchActive)
346
+ return;
347
+ this.configWatchActive = false;
348
+ try {
349
+ fs.unwatchFile(getConfigPath());
259
350
  }
260
- configRemoveDevice(credentialId);
261
- this.scheduleListChanged();
262
- }
263
- renameDevice(credentialId, label) {
264
- configRenameDevice(credentialId, label);
265
- const s = this.devices.get(credentialId);
266
- if (s) {
267
- s.label = label;
268
- s.record.label = label;
351
+ catch {
352
+ // ignore
269
353
  }
270
354
  }
271
- setDefault(credentialId) {
272
- configSetDefault(credentialId);
273
- }
274
- shutdown() {
275
- for (const s of this.devices.values()) {
276
- this.stopSSE(s);
277
- this.stopHeartbeat(s);
355
+ reconcileConfig() {
356
+ const cfg = loadConfig();
357
+ const endpoint = resolveDefaultEndpoint();
358
+ const configUserIds = new Set(Object.keys(cfg.users));
359
+ // Remove users no longer in config
360
+ for (const [userId, us] of this.userStates) {
361
+ if (!configUserIds.has(userId)) {
362
+ us.stream?.stop();
363
+ us.stream = null;
364
+ this.userStates.delete(userId);
365
+ log.debug(`[registry] Removed user ${userId} (config hot-reload)`);
366
+ }
278
367
  }
279
- this.devices.clear();
280
- if (this.debounceTimer) {
281
- clearTimeout(this.debounceTimer);
282
- this.debounceTimer = null;
368
+ // Add new users / reconcile devices
369
+ for (const userRec of Object.values(cfg.users)) {
370
+ let us = this.userStates.get(userRec.user_id);
371
+ if (!us) {
372
+ // New user
373
+ us = this.createUserState(userRec, endpoint);
374
+ this.userStates.set(userRec.user_id, us);
375
+ this.populateDevicesFromConfig(us, userRec);
376
+ this.startUserStream(us);
377
+ log.debug(`[registry] Added user ${userRec.user_id} (config hot-reload)`);
378
+ }
379
+ else {
380
+ // Update token/label if changed
381
+ us.controllerToken = userRec.controller_token;
382
+ us.label = userRec.label;
383
+ // Reconcile devices
384
+ const configDevIds = new Set(userRec.devices.map((d) => d.credential_id));
385
+ // Remove devices no longer in config — but keep online devices
386
+ // that were added via WS credential.added but not yet persisted
387
+ // by the pairing CLI (race window).
388
+ for (const credId of us.devices.keys()) {
389
+ if (!configDevIds.has(credId)) {
390
+ const d = us.devices.get(credId);
391
+ if (d && d.online) {
392
+ log.debug(`[registry] Keeping online device ${credId} despite missing from config`);
393
+ continue;
394
+ }
395
+ us.devices.delete(credId);
396
+ }
397
+ }
398
+ // Add new devices
399
+ for (const dev of userRec.devices) {
400
+ if (!us.devices.has(dev.credential_id)) {
401
+ us.devices.set(dev.credential_id, this.makeDeviceState(dev, us.userId, us.label));
402
+ }
403
+ else {
404
+ // Update label/platform from config
405
+ const d = us.devices.get(dev.credential_id);
406
+ d.label = dev.label;
407
+ d.userLabel = us.label;
408
+ }
409
+ }
410
+ }
283
411
  }
284
- this.listChangedSubs.clear();
285
- this.initialized = false;
412
+ this.scheduleListChanged();
286
413
  }
287
414
  }
288
415
  export const registry = new Registry();
@@ -1,4 +1,5 @@
1
- import { dbg } from "../daemon/logger.js";
1
+ import { log } from "./logger.js";
2
+ const dbg = (msg) => log.debug(msg);
2
3
  // Snapshot is considered stale if the server-reported age exceeds this
3
4
  // threshold. Configurable via env ZHIHAND_SNAPSHOT_MAX_AGE_MS.
4
5
  // Default 5s: typical HID command + capture + upload is well under 2s;
@@ -29,7 +30,7 @@ export async function fetchScreenshot(config) {
29
30
  const response = await fetch(url, {
30
31
  method: "GET",
31
32
  headers: {
32
- "x-zhihand-controller-token": config.controllerToken,
33
+ "Authorization": `Bearer ${config.controllerToken}`,
33
34
  "Accept": "image/jpeg",
34
35
  },
35
36
  signal: controller.signal,
@@ -7,20 +7,45 @@ export interface SSEEvent {
7
7
  credential_id: string;
8
8
  command?: QueuedCommandRecord;
9
9
  device_profile?: Record<string, unknown>;
10
+ credential?: Record<string, unknown>;
10
11
  sequence: number;
11
12
  }
12
13
  export declare function handleSSEEvent(event: SSEEvent): void;
13
14
  export declare function subscribeToCommandAck(commandId: string, callback: (cmd: QueuedCommandRecord) => void): () => void;
14
- export interface SSEHandlers {
15
- onEvent: (e: SSEEvent) => void;
15
+ export interface UserEventStreamHandlers {
16
+ onDeviceOnline: (credentialId: string) => void;
17
+ onDeviceOffline: (credentialId: string) => void;
18
+ onDeviceProfileUpdated: (credentialId: string, profile: Record<string, unknown>) => void;
19
+ onCommandAcked: (event: SSEEvent) => void;
20
+ onCredentialAdded: (credential: Record<string, unknown>) => void;
21
+ onCredentialRemoved: (credentialId: string) => void;
16
22
  onConnected: () => void;
17
23
  onDisconnected: () => void;
18
24
  }
19
- /**
20
- * Open a per-credential SSE connection. Caller owns the returned AbortController.
21
- * The loop auto-reconnects with exponential backoff until aborted.
22
- */
23
- export declare function connectSSEForCredential(config: ZhiHandRuntimeConfig, handlers: SSEHandlers): AbortController;
25
+ export declare class UserEventStream {
26
+ private userId;
27
+ private controllerToken;
28
+ private endpoint;
29
+ private handlers;
30
+ private abortController;
31
+ private _connected;
32
+ constructor(userId: string, controllerToken: string, endpoint: string, handlers: UserEventStreamHandlers);
33
+ get connected(): boolean;
34
+ start(): void;
35
+ stop(): void;
36
+ private runLoop;
37
+ private dispatchEvent;
38
+ }
39
+ export interface CredentialResponse {
40
+ credential_id: string;
41
+ label?: string;
42
+ platform?: string;
43
+ online?: boolean;
44
+ paired_at?: string;
45
+ last_seen_at?: string;
46
+ device_profile?: Record<string, unknown>;
47
+ }
48
+ export declare function fetchUserCredentials(endpoint: string, userId: string, controllerToken: string, onlineFilter?: boolean): Promise<CredentialResponse[]>;
24
49
  /**
25
50
  * Wait for command ACK via SSE push (which should already be connected by the
26
51
  * registry). Falls back to polling.