@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.
package/dist/core/sse.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { getCommand } from "./command.js";
2
- import { dbg } from "../daemon/logger.js";
2
+ import { log } from "./logger.js";
3
3
  // Per-commandId callback registry for SSE-based ACK (global — ids are globally unique)
4
4
  const ackCallbacks = new Map();
5
5
  export function handleSSEEvent(event) {
6
- dbg(`[sse-cmd] Event: kind=${event.kind}, command=${event.command?.id ?? "-"}`);
6
+ log.debug(`[sse-cmd] Event: kind=${event.kind}, command=${event.command?.id ?? "-"}`);
7
7
  if (event.kind === "command.acked" && event.command) {
8
8
  const callback = ackCallbacks.get(event.command.id);
9
9
  if (callback) {
10
- dbg(`[sse-cmd] ACK callback for ${event.command.id}, ack_status=${event.command.ack_status}, ack_result=${JSON.stringify(event.command.ack_result ?? null)}`);
10
+ log.debug(`[sse-cmd] ACK callback for ${event.command.id}, ack_status=${event.command.ack_status}, ack_result=${JSON.stringify(event.command.ack_result ?? null)}`);
11
11
  callback(event.command);
12
12
  ackCallbacks.delete(event.command.id);
13
13
  }
@@ -17,30 +17,52 @@ export function subscribeToCommandAck(commandId, callback) {
17
17
  ackCallbacks.set(commandId, callback);
18
18
  return () => { ackCallbacks.delete(commandId); };
19
19
  }
20
- /**
21
- * Open a per-credential SSE connection. Caller owns the returned AbortController.
22
- * The loop auto-reconnects with exponential backoff until aborted.
23
- */
24
- export function connectSSEForCredential(config, handlers) {
25
- const controller = new AbortController();
26
- const { signal } = controller;
27
- const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/events/stream`;
28
- let backoffMs = 1000;
29
- const BACKOFF_MAX = 30_000;
30
- (async () => {
20
+ export class UserEventStream {
21
+ userId;
22
+ controllerToken;
23
+ endpoint;
24
+ handlers;
25
+ abortController = null;
26
+ _connected = false;
27
+ constructor(userId, controllerToken, endpoint, handlers) {
28
+ this.userId = userId;
29
+ this.controllerToken = controllerToken;
30
+ this.endpoint = endpoint;
31
+ this.handlers = handlers;
32
+ }
33
+ get connected() {
34
+ return this._connected;
35
+ }
36
+ start() {
37
+ if (this.abortController)
38
+ return;
39
+ this.abortController = new AbortController();
40
+ this.runLoop(this.abortController.signal);
41
+ }
42
+ stop() {
43
+ this.abortController?.abort();
44
+ this.abortController = null;
45
+ this._connected = false;
46
+ }
47
+ async runLoop(signal) {
48
+ let backoffMs = 1000;
49
+ const BACKOFF_MAX = 30_000;
50
+ const topics = "commands,device_profile,device.online,device.offline,credential.added,credential.removed";
51
+ const url = `${this.endpoint}/v1/users/${encodeURIComponent(this.userId)}/events/stream?topic=${topics}`;
31
52
  while (!signal.aborted) {
32
53
  try {
33
54
  const response = await fetch(url, {
34
55
  headers: {
35
56
  "Accept": "text/event-stream",
36
- "x-zhihand-controller-token": config.controllerToken,
57
+ "Authorization": `Bearer ${this.controllerToken}`,
37
58
  },
38
59
  signal,
39
60
  });
40
61
  if (!response.ok) {
41
62
  throw new Error(`SSE connect failed: ${response.status}`);
42
63
  }
43
- handlers.onConnected();
64
+ this._connected = true;
65
+ this.handlers.onConnected();
44
66
  backoffMs = 1000;
45
67
  const reader = response.body?.getReader();
46
68
  if (!reader)
@@ -62,7 +84,7 @@ export function connectSSEForCredential(config, handlers) {
62
84
  else if (line === "" && eventData) {
63
85
  try {
64
86
  const ev = JSON.parse(eventData);
65
- handlers.onEvent(ev);
87
+ this.dispatchEvent(ev);
66
88
  }
67
89
  catch {
68
90
  // malformed, skip
@@ -75,14 +97,60 @@ export function connectSSEForCredential(config, handlers) {
75
97
  catch (err) {
76
98
  if (signal.aborted)
77
99
  break;
78
- handlers.onDisconnected();
100
+ this._connected = false;
101
+ this.handlers.onDisconnected();
79
102
  await new Promise((r) => setTimeout(r, backoffMs));
80
103
  backoffMs = Math.min(backoffMs * 2, BACKOFF_MAX);
81
104
  }
82
105
  }
83
- handlers.onDisconnected();
84
- })();
85
- return controller;
106
+ this._connected = false;
107
+ this.handlers.onDisconnected();
108
+ }
109
+ dispatchEvent(ev) {
110
+ // Always dispatch command ACKs globally
111
+ handleSSEEvent(ev);
112
+ switch (ev.kind) {
113
+ case "device.online":
114
+ this.handlers.onDeviceOnline(ev.credential_id);
115
+ break;
116
+ case "device.offline":
117
+ this.handlers.onDeviceOffline(ev.credential_id);
118
+ break;
119
+ case "device_profile.updated":
120
+ if (ev.device_profile) {
121
+ this.handlers.onDeviceProfileUpdated(ev.credential_id, ev.device_profile);
122
+ }
123
+ break;
124
+ case "command.acked":
125
+ this.handlers.onCommandAcked(ev);
126
+ break;
127
+ case "credential.added":
128
+ // The credential.added event carries the new credential metadata
129
+ // in ev.credential (with credential_id, label, platform, etc.).
130
+ // Fall back to the root event if ev.credential is absent, since
131
+ // credential_id is always on the root SSEEvent.
132
+ this.handlers.onCredentialAdded(ev.credential ?? { credential_id: ev.credential_id });
133
+ break;
134
+ case "credential.removed":
135
+ this.handlers.onCredentialRemoved(ev.credential_id);
136
+ break;
137
+ }
138
+ }
139
+ }
140
+ export async function fetchUserCredentials(endpoint, userId, controllerToken, onlineFilter) {
141
+ let url = `${endpoint}/v1/users/${encodeURIComponent(userId)}/credentials`;
142
+ if (onlineFilter !== undefined) {
143
+ url += `?online=${onlineFilter}`;
144
+ }
145
+ const response = await fetch(url, {
146
+ headers: { "Authorization": `Bearer ${controllerToken}` },
147
+ signal: AbortSignal.timeout(10_000),
148
+ });
149
+ if (!response.ok) {
150
+ throw new Error(`Fetch credentials failed: ${response.status}`);
151
+ }
152
+ const data = (await response.json());
153
+ return data.items ?? [];
86
154
  }
87
155
  /**
88
156
  * Wait for command ACK via SSE push (which should already be connected by the
@@ -90,7 +158,7 @@ export function connectSSEForCredential(config, handlers) {
90
158
  */
91
159
  export async function waitForCommandAck(config, options) {
92
160
  const timeoutMs = options.timeoutMs ?? 15_000;
93
- dbg(`[sse-cmd] Waiting for ACK: commandId=${options.commandId}, timeout=${timeoutMs}ms`);
161
+ log.debug(`[sse-cmd] Waiting for ACK: commandId=${options.commandId}, timeout=${timeoutMs}ms`);
94
162
  return new Promise((resolve, reject) => {
95
163
  let resolved = false;
96
164
  let pollInterval;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * WebSocket transport — replaces SSE for all real-time event streams.
3
+ *
4
+ * Provides:
5
+ * - ReconnectingWebSocket: shared base with exponential backoff + jitter,
6
+ * protocol-level ping/pong watchdog, and Bearer auth via HTTP upgrade header.
7
+ * - UserEventWebSocket: per-user stream for device registry events.
8
+ * - Command ACK infrastructure (handleWSEvent, subscribeToCommandAck, waitForCommandAck).
9
+ * - fetchUserCredentials: HTTP REST helper (unchanged from sse.ts).
10
+ */
11
+ import type { ZhiHandRuntimeConfig } from "./config.ts";
12
+ import type { QueuedCommandRecord, WaitForCommandAckResult } from "./command.ts";
13
+ export interface ReconnectingWSOptions {
14
+ url: string;
15
+ headers?: Record<string, string>;
16
+ onOpen?: () => void;
17
+ onClose?: (code: number, reason: string) => void;
18
+ onMessage?: (data: unknown) => void;
19
+ onError?: (err: Error) => void;
20
+ }
21
+ export declare class ReconnectingWebSocket {
22
+ private opts;
23
+ private ws;
24
+ private backoffMs;
25
+ private aborted;
26
+ private watchdogTimer;
27
+ private reconnectTimer;
28
+ private hadOpen;
29
+ private consecutiveFailures;
30
+ private static readonly MAX_CONSECUTIVE_FAILURES;
31
+ constructor(opts: ReconnectingWSOptions);
32
+ start(): void;
33
+ stop(): void;
34
+ send(data: string): void;
35
+ get connected(): boolean;
36
+ private connect;
37
+ private scheduleReconnect;
38
+ private resetWatchdog;
39
+ private clearWatchdog;
40
+ }
41
+ export interface WSEvent {
42
+ id: string;
43
+ topic: string;
44
+ kind: string;
45
+ credential_id: string;
46
+ command?: QueuedCommandRecord;
47
+ device_profile?: Record<string, unknown>;
48
+ credential?: Record<string, unknown>;
49
+ sequence: number;
50
+ }
51
+ export declare function handleWSEvent(event: WSEvent): void;
52
+ export declare function subscribeToCommandAck(commandId: string, callback: (cmd: QueuedCommandRecord) => void): () => void;
53
+ export interface UserEventStreamHandlers {
54
+ onDeviceOnline: (credentialId: string) => void;
55
+ onDeviceOffline: (credentialId: string) => void;
56
+ onDeviceProfileUpdated: (credentialId: string, profile: Record<string, unknown>) => void;
57
+ onCommandAcked: (event: WSEvent) => void;
58
+ onCredentialAdded: (credential: Record<string, unknown>) => void;
59
+ onCredentialRemoved: (credentialId: string) => void;
60
+ onConnected: () => void;
61
+ onDisconnected: () => void;
62
+ }
63
+ export declare class UserEventWebSocket {
64
+ private handlers;
65
+ private rws;
66
+ private lastProcessedSeq;
67
+ constructor(userId: string, controllerToken: string, endpoint: string, handlers: UserEventStreamHandlers);
68
+ get connected(): boolean;
69
+ start(): void;
70
+ stop(): void;
71
+ private handleMessage;
72
+ private dispatchEvent;
73
+ }
74
+ export interface CredentialResponse {
75
+ credential_id: string;
76
+ label?: string;
77
+ platform?: string;
78
+ online?: boolean;
79
+ paired_at?: string;
80
+ last_seen_at?: string;
81
+ device_profile?: Record<string, unknown>;
82
+ }
83
+ export declare function fetchUserCredentials(endpoint: string, userId: string, controllerToken: string, onlineFilter?: boolean): Promise<CredentialResponse[]>;
84
+ /**
85
+ * Wait for command ACK via WS push (which should already be connected by the
86
+ * registry). Falls back to polling.
87
+ */
88
+ export declare function waitForCommandAck(config: ZhiHandRuntimeConfig, options: {
89
+ commandId: string;
90
+ timeoutMs?: number;
91
+ signal?: AbortSignal;
92
+ }): Promise<WaitForCommandAckResult>;
@@ -0,0 +1,327 @@
1
+ /**
2
+ * WebSocket transport — replaces SSE for all real-time event streams.
3
+ *
4
+ * Provides:
5
+ * - ReconnectingWebSocket: shared base with exponential backoff + jitter,
6
+ * protocol-level ping/pong watchdog, and Bearer auth via HTTP upgrade header.
7
+ * - UserEventWebSocket: per-user stream for device registry events.
8
+ * - Command ACK infrastructure (handleWSEvent, subscribeToCommandAck, waitForCommandAck).
9
+ * - fetchUserCredentials: HTTP REST helper (unchanged from sse.ts).
10
+ */
11
+ import WebSocket from "ws";
12
+ import { getCommand } from "./command.js";
13
+ import { log } from "./logger.js";
14
+ // ── Shared reconnecting base ─────────────────────────────
15
+ const BACKOFF_INITIAL_MS = 1000;
16
+ const BACKOFF_MAX_MS = 30_000;
17
+ const WATCHDOG_TIMEOUT_MS = 35_000; // slightly > server ping interval (expect ~30s)
18
+ export class ReconnectingWebSocket {
19
+ opts;
20
+ ws = null;
21
+ backoffMs = BACKOFF_INITIAL_MS;
22
+ aborted = false;
23
+ watchdogTimer = null;
24
+ reconnectTimer = null;
25
+ hadOpen = false;
26
+ consecutiveFailures = 0;
27
+ static MAX_CONSECUTIVE_FAILURES = 10;
28
+ constructor(opts) {
29
+ this.opts = opts;
30
+ }
31
+ start() {
32
+ this.aborted = false;
33
+ this.connect();
34
+ }
35
+ stop() {
36
+ this.aborted = true;
37
+ this.clearWatchdog();
38
+ if (this.reconnectTimer) {
39
+ clearTimeout(this.reconnectTimer);
40
+ this.reconnectTimer = null;
41
+ }
42
+ if (this.ws) {
43
+ this.ws.removeAllListeners();
44
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
45
+ this.ws.close(4000, "client shutdown");
46
+ }
47
+ this.ws = null;
48
+ }
49
+ }
50
+ send(data) {
51
+ if (this.ws?.readyState === WebSocket.OPEN) {
52
+ this.ws.send(data);
53
+ }
54
+ }
55
+ get connected() {
56
+ return this.ws?.readyState === WebSocket.OPEN;
57
+ }
58
+ connect() {
59
+ if (this.aborted)
60
+ return;
61
+ try {
62
+ this.ws = new WebSocket(this.opts.url, {
63
+ headers: this.opts.headers,
64
+ });
65
+ }
66
+ catch (err) {
67
+ log.error(`[ws] Failed to create WebSocket: ${err.message}`);
68
+ this.scheduleReconnect();
69
+ return;
70
+ }
71
+ this.ws.on("open", () => {
72
+ this.hadOpen = true;
73
+ this.consecutiveFailures = 0;
74
+ this.backoffMs = BACKOFF_INITIAL_MS;
75
+ this.resetWatchdog();
76
+ this.opts.onOpen?.();
77
+ });
78
+ this.ws.on("message", (raw) => {
79
+ this.resetWatchdog();
80
+ try {
81
+ const data = JSON.parse(raw.toString());
82
+ this.opts.onMessage?.(data);
83
+ }
84
+ catch {
85
+ // malformed message — ignore
86
+ }
87
+ });
88
+ this.ws.on("ping", () => {
89
+ // Protocol-level ping from server — ws lib auto-sends pong.
90
+ // Reset watchdog on any activity.
91
+ this.resetWatchdog();
92
+ });
93
+ this.ws.on("close", (code, reason) => {
94
+ this.clearWatchdog();
95
+ const reasonStr = reason.toString();
96
+ // Detect HTTP upgrade rejection (401/403 → close 1006 without prior open)
97
+ if (!this.hadOpen) {
98
+ this.consecutiveFailures++;
99
+ if (this.consecutiveFailures >= ReconnectingWebSocket.MAX_CONSECUTIVE_FAILURES) {
100
+ log.error(`[ws] ${this.consecutiveFailures} consecutive connection failures — stopping retries (likely auth rejection)`);
101
+ this.opts.onClose?.(code, reasonStr);
102
+ return; // Don't reconnect
103
+ }
104
+ }
105
+ this.hadOpen = false;
106
+ this.opts.onClose?.(code, reasonStr);
107
+ if (!this.aborted && code !== 4001) {
108
+ // Don't reconnect on explicit auth failure (4001)
109
+ this.scheduleReconnect();
110
+ }
111
+ });
112
+ this.ws.on("error", (err) => {
113
+ this.opts.onError?.(err);
114
+ });
115
+ }
116
+ scheduleReconnect() {
117
+ if (this.aborted)
118
+ return;
119
+ // Jitter: ±25% of current backoff
120
+ const jitter = this.backoffMs * 0.25 * (Math.random() * 2 - 1);
121
+ const delay = Math.round(this.backoffMs + jitter);
122
+ this.reconnectTimer = setTimeout(() => {
123
+ this.reconnectTimer = null;
124
+ this.backoffMs = Math.min(this.backoffMs * 2, BACKOFF_MAX_MS);
125
+ this.connect();
126
+ }, delay);
127
+ }
128
+ resetWatchdog() {
129
+ this.clearWatchdog();
130
+ this.watchdogTimer = setTimeout(() => {
131
+ log.warn("[ws] Watchdog timeout — no activity in 35s, reconnecting");
132
+ if (this.ws) {
133
+ this.ws.removeAllListeners();
134
+ this.ws.close(4000, "watchdog timeout");
135
+ this.ws = null;
136
+ }
137
+ this.opts.onClose?.(4000, "watchdog timeout");
138
+ this.scheduleReconnect();
139
+ }, WATCHDOG_TIMEOUT_MS);
140
+ }
141
+ clearWatchdog() {
142
+ if (this.watchdogTimer) {
143
+ clearTimeout(this.watchdogTimer);
144
+ this.watchdogTimer = null;
145
+ }
146
+ }
147
+ }
148
+ // ── Command ACK infrastructure (migrated from sse.ts) ────
149
+ const ackCallbacks = new Map();
150
+ export function handleWSEvent(event) {
151
+ log.debug(`[ws-cmd] Event: kind=${event.kind}, command=${event.command?.id ?? "-"}`);
152
+ if (event.kind === "command.acked" && event.command) {
153
+ const callback = ackCallbacks.get(event.command.id);
154
+ if (callback) {
155
+ log.debug(`[ws-cmd] ACK callback for ${event.command.id}, ack_status=${event.command.ack_status}, ack_result=${JSON.stringify(event.command.ack_result ?? null)}`);
156
+ callback(event.command);
157
+ ackCallbacks.delete(event.command.id);
158
+ }
159
+ }
160
+ }
161
+ export function subscribeToCommandAck(commandId, callback) {
162
+ ackCallbacks.set(commandId, callback);
163
+ return () => { ackCallbacks.delete(commandId); };
164
+ }
165
+ export class UserEventWebSocket {
166
+ handlers;
167
+ rws;
168
+ lastProcessedSeq = new Map();
169
+ constructor(userId, controllerToken, endpoint, handlers) {
170
+ this.handlers = handlers;
171
+ const topics = "commands,device_profile,device.online,device.offline,credential.added,credential.removed";
172
+ const wsUrl = `${endpoint.replace(/^http/, "ws")}/v1/users/${encodeURIComponent(userId)}/ws?topic=${topics}`;
173
+ this.rws = new ReconnectingWebSocket({
174
+ url: wsUrl,
175
+ headers: { "Authorization": `Bearer ${controllerToken}` },
176
+ onOpen: () => {
177
+ this.handlers.onConnected();
178
+ },
179
+ onClose: (_code, _reason) => {
180
+ this.handlers.onDisconnected();
181
+ },
182
+ onMessage: (data) => {
183
+ this.handleMessage(data);
184
+ },
185
+ onError: (err) => {
186
+ log.error(`[ws] UserEventWebSocket error: ${err.message}`);
187
+ },
188
+ });
189
+ }
190
+ get connected() {
191
+ return this.rws.connected;
192
+ }
193
+ start() {
194
+ this.rws.start();
195
+ }
196
+ stop() {
197
+ this.rws.stop();
198
+ }
199
+ handleMessage(data) {
200
+ const msg = data;
201
+ // Application-level ping (if server sends these alongside protocol pings)
202
+ if (msg.type === "ping") {
203
+ this.rws.send(JSON.stringify({ type: "pong" }));
204
+ return;
205
+ }
206
+ // Auth responses (if server uses message-based auth instead of/in addition to header auth)
207
+ if (msg.type === "auth_ok")
208
+ return;
209
+ if (msg.type === "auth_error") {
210
+ log.error(`[ws] Auth failed: ${msg.error}`);
211
+ this.rws.stop(); // Don't retry with invalid credentials
212
+ this.handlers.onDisconnected();
213
+ return;
214
+ }
215
+ // Event dispatch
216
+ if (msg.type === "event" || msg.kind) {
217
+ const ev = msg;
218
+ this.dispatchEvent(ev);
219
+ }
220
+ }
221
+ dispatchEvent(ev) {
222
+ // Sequence dedup per credential
223
+ if (ev.credential_id && ev.sequence != null) {
224
+ const lastSeq = this.lastProcessedSeq.get(ev.credential_id) ?? -1;
225
+ if (ev.sequence <= lastSeq)
226
+ return;
227
+ this.lastProcessedSeq.set(ev.credential_id, ev.sequence);
228
+ }
229
+ // Global command ACK dispatch
230
+ handleWSEvent(ev);
231
+ switch (ev.kind) {
232
+ case "device.online":
233
+ this.handlers.onDeviceOnline(ev.credential_id);
234
+ break;
235
+ case "device.offline":
236
+ this.handlers.onDeviceOffline(ev.credential_id);
237
+ break;
238
+ case "device_profile.updated":
239
+ if (ev.device_profile) {
240
+ this.handlers.onDeviceProfileUpdated(ev.credential_id, ev.device_profile);
241
+ }
242
+ break;
243
+ case "command.acked":
244
+ this.handlers.onCommandAcked(ev);
245
+ break;
246
+ case "credential.added":
247
+ this.handlers.onCredentialAdded(ev.credential ?? { credential_id: ev.credential_id });
248
+ break;
249
+ case "credential.removed":
250
+ this.handlers.onCredentialRemoved(ev.credential_id);
251
+ break;
252
+ }
253
+ }
254
+ }
255
+ export async function fetchUserCredentials(endpoint, userId, controllerToken, onlineFilter) {
256
+ let url = `${endpoint}/v1/users/${encodeURIComponent(userId)}/credentials`;
257
+ if (onlineFilter !== undefined) {
258
+ url += `?online=${onlineFilter}`;
259
+ }
260
+ const response = await fetch(url, {
261
+ headers: { "Authorization": `Bearer ${controllerToken}` },
262
+ signal: AbortSignal.timeout(10_000),
263
+ });
264
+ if (!response.ok) {
265
+ throw new Error(`Fetch credentials failed: ${response.status}`);
266
+ }
267
+ const data = (await response.json());
268
+ return data.items ?? [];
269
+ }
270
+ /**
271
+ * Wait for command ACK via WS push (which should already be connected by the
272
+ * registry). Falls back to polling.
273
+ */
274
+ export async function waitForCommandAck(config, options) {
275
+ const timeoutMs = options.timeoutMs ?? 15_000;
276
+ log.debug(`[ws-cmd] Waiting for ACK: commandId=${options.commandId}, timeout=${timeoutMs}ms`);
277
+ return new Promise((resolve, reject) => {
278
+ let resolved = false;
279
+ let pollInterval;
280
+ const timeout = setTimeout(() => {
281
+ cleanup();
282
+ resolve({ acked: false });
283
+ }, timeoutMs);
284
+ const unsubscribe = subscribeToCommandAck(options.commandId, (ackedCommand) => {
285
+ if (resolved)
286
+ return;
287
+ resolved = true;
288
+ cleanup();
289
+ resolve({ acked: true, command: ackedCommand });
290
+ });
291
+ // Delay polling startup by 2s so WS push ACK normally wins in the
292
+ // registry-connected path. CLI (zhihand test) still resolves via polling
293
+ // after the initial delay.
294
+ const POLL_START_DELAY_MS = 2000;
295
+ const POLL_INTERVAL_MS = 500;
296
+ const startPolling = setTimeout(() => {
297
+ if (resolved)
298
+ return;
299
+ pollInterval = setInterval(async () => {
300
+ if (resolved)
301
+ return;
302
+ try {
303
+ const cmd = await getCommand(config, options.commandId);
304
+ if (cmd.acked_at) {
305
+ resolved = true;
306
+ cleanup();
307
+ resolve({ acked: true, command: cmd });
308
+ }
309
+ }
310
+ catch {
311
+ // non-fatal
312
+ }
313
+ }, POLL_INTERVAL_MS);
314
+ }, POLL_START_DELAY_MS);
315
+ options.signal?.addEventListener("abort", () => {
316
+ cleanup();
317
+ reject(new Error("The operation was aborted"));
318
+ }, { once: true });
319
+ function cleanup() {
320
+ clearTimeout(timeout);
321
+ clearTimeout(startPolling);
322
+ unsubscribe();
323
+ if (pollInterval)
324
+ clearInterval(pollInterval);
325
+ }
326
+ });
327
+ }
@@ -779,7 +779,7 @@ export async function postReply(config, promptId, text) {
779
779
  method: "POST",
780
780
  headers: {
781
781
  "Content-Type": "application/json",
782
- "x-zhihand-controller-token": config.controllerToken,
782
+ "Authorization": `Bearer ${config.controllerToken}`,
783
783
  },
784
784
  body: JSON.stringify({ role: "assistant", text }),
785
785
  signal: AbortSignal.timeout(30_000),
@@ -25,7 +25,7 @@ async function sendHeartbeat(config, online) {
25
25
  method: "POST",
26
26
  headers: {
27
27
  "Content-Type": "application/json",
28
- "x-zhihand-controller-token": config.controllerToken,
28
+ "Authorization": `Bearer ${config.controllerToken}`,
29
29
  },
30
30
  body: JSON.stringify(body),
31
31
  signal: AbortSignal.timeout(10_000),
@@ -14,7 +14,7 @@ import { setDebugEnabled, dbg } from "./logger.js";
14
14
  import { registry } from "../core/registry.js";
15
15
  const DEFAULT_PORT = 18686;
16
16
  const PID_FILE = "daemon.pid";
17
- // ── State ──────────────────────────────────────────────────
17
+ // ── State ────────���─────────────────────────────────────────
18
18
  let activeBackend = null;
19
19
  let activeModel = null; // user-selected model alias, null = use default
20
20
  let isProcessing = false;
@@ -153,7 +153,7 @@ function readPid() {
153
153
  export function isAlreadyRunning() {
154
154
  return readPid();
155
155
  }
156
- // ── Main Daemon Entry ──────────────────────────────────────
156
+ // ── Main Daemon Entry ──────���───────────────────────────────
157
157
  export async function startDaemon(options) {
158
158
  if (options?.debug)
159
159
  setDebugEnabled(true);
@@ -190,7 +190,7 @@ export async function startDaemon(options) {
190
190
  else {
191
191
  log(`[config] No backend configured. Use: zhihand gemini / zhihand claude / zhihand codex`);
192
192
  }
193
- // Init the multi-device registry (single-device in daemon context) and log profile.
193
+ // Init the multi-device registry and log profile.
194
194
  await registry.init();
195
195
  const defaultState = registry.resolveDefault();
196
196
  if (defaultState?.profile) {
@@ -16,19 +16,18 @@ export declare class PromptListener {
16
16
  private handler;
17
17
  private log;
18
18
  private processedIds;
19
- private sseAbort;
19
+ private rws;
20
20
  private pollTimer;
21
- private sseConnected;
21
+ private wsConnected;
22
22
  private stopped;
23
23
  constructor(config: ZhiHandConfig, handler: PromptHandler, log: (msg: string) => void);
24
24
  start(): void;
25
25
  stop(): void;
26
26
  private dispatchPrompt;
27
- private connectSSE;
28
- private resetWatchdog;
29
- private handleSSEEvent;
27
+ private connectWS;
28
+ private handleWSMessage;
29
+ private handleEvent;
30
30
  private startPolling;
31
- /** Recursive setTimeout: waits for fetch to complete before scheduling next poll. */
32
31
  private schedulePoll;
33
32
  private stopPolling;
34
33
  private poll;