@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/bin/zhihand +318 -106
- package/dist/core/command.js +4 -3
- package/dist/core/config.d.ts +35 -20
- package/dist/core/config.js +129 -55
- package/dist/core/device.d.ts +3 -3
- package/dist/core/device.js +22 -14
- package/dist/core/logger.d.ts +17 -0
- package/dist/core/logger.js +32 -0
- package/dist/core/pair.d.ts +39 -31
- package/dist/core/pair.js +188 -84
- package/dist/core/registry.d.ts +23 -30
- package/dist/core/registry.js +321 -194
- package/dist/core/screenshot.js +3 -2
- package/dist/core/sse.d.ts +32 -7
- package/dist/core/sse.js +90 -22
- package/dist/core/ws.d.ts +92 -0
- package/dist/core/ws.js +327 -0
- package/dist/daemon/dispatcher.js +1 -1
- package/dist/daemon/heartbeat.js +1 -1
- package/dist/daemon/index.js +3 -3
- package/dist/daemon/prompt-listener.d.ts +5 -6
- package/dist/daemon/prompt-listener.js +58 -94
- package/dist/index.d.ts +1 -1
- package/dist/index.js +18 -16
- package/dist/tools/control.js +1 -1
- package/dist/tools/pair.d.ts +1 -1
- package/dist/tools/pair.js +22 -25
- package/dist/tools/system.js +1 -1
- package/package.json +3 -1
package/dist/core/sse.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { getCommand } from "./command.js";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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>;
|
package/dist/core/ws.js
ADDED
|
@@ -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
|
-
"
|
|
782
|
+
"Authorization": `Bearer ${config.controllerToken}`,
|
|
783
783
|
},
|
|
784
784
|
body: JSON.stringify({ role: "assistant", text }),
|
|
785
785
|
signal: AbortSignal.timeout(30_000),
|
package/dist/daemon/heartbeat.js
CHANGED
|
@@ -25,7 +25,7 @@ async function sendHeartbeat(config, online) {
|
|
|
25
25
|
method: "POST",
|
|
26
26
|
headers: {
|
|
27
27
|
"Content-Type": "application/json",
|
|
28
|
-
"
|
|
28
|
+
"Authorization": `Bearer ${config.controllerToken}`,
|
|
29
29
|
},
|
|
30
30
|
body: JSON.stringify(body),
|
|
31
31
|
signal: AbortSignal.timeout(10_000),
|
package/dist/daemon/index.js
CHANGED
|
@@ -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
|
|
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
|
|
19
|
+
private rws;
|
|
20
20
|
private pollTimer;
|
|
21
|
-
private
|
|
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
|
|
28
|
-
private
|
|
29
|
-
private
|
|
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;
|