@zhihand/mcp 0.28.0 → 0.30.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 +254 -158
- package/dist/core/command.d.ts +5 -5
- package/dist/core/command.js +2 -5
- package/dist/core/config.d.ts +32 -20
- package/dist/core/config.js +102 -40
- package/dist/core/device.d.ts +41 -16
- package/dist/core/device.js +199 -79
- package/dist/core/pair.d.ts +9 -9
- package/dist/core/pair.js +54 -30
- package/dist/core/registry.d.ts +67 -0
- package/dist/core/registry.js +288 -0
- package/dist/core/screenshot.d.ts +13 -2
- package/dist/core/screenshot.js +43 -3
- package/dist/core/sse.d.ts +13 -16
- package/dist/core/sse.js +46 -54
- package/dist/daemon/dispatcher.d.ts +3 -1
- package/dist/daemon/dispatcher.js +3 -2
- package/dist/daemon/heartbeat.d.ts +4 -4
- package/dist/daemon/index.js +8 -6
- package/dist/daemon/prompt-listener.d.ts +3 -1
- package/dist/daemon/prompt-listener.js +2 -6
- package/dist/index.d.ts +3 -3
- package/dist/index.js +102 -40
- package/dist/openclaw.adapter.js +10 -2
- package/dist/tools/control.d.ts +10 -3
- package/dist/tools/control.js +58 -29
- package/dist/tools/pair.js +15 -18
- package/dist/tools/resolve.d.ts +7 -0
- package/dist/tools/resolve.js +22 -0
- package/dist/tools/schemas.d.ts +9 -1
- package/dist/tools/schemas.js +10 -8
- package/dist/tools/screenshot.d.ts +3 -2
- package/dist/tools/screenshot.js +2 -2
- package/dist/tools/system.d.ts +3 -5
- package/dist/tools/system.js +18 -5
- package/package.json +1 -1
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
5
|
+
*
|
|
6
|
+
* Holds a per-credential AbortController for SSE, a per-device heartbeat
|
|
7
|
+
* timer, and a single debounced notifier for list_changed.
|
|
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;
|
|
15
|
+
const LIST_CHANGED_DEBOUNCE_MS = 2500;
|
|
16
|
+
class Registry {
|
|
17
|
+
devices = new Map();
|
|
18
|
+
listChangedSubs = new Set();
|
|
19
|
+
debounceTimer = null;
|
|
20
|
+
lastOnlineSet = new Set();
|
|
21
|
+
initialized = false;
|
|
22
|
+
get(credentialId) {
|
|
23
|
+
return this.devices.get(credentialId) ?? null;
|
|
24
|
+
}
|
|
25
|
+
list() {
|
|
26
|
+
return Array.from(this.devices.values());
|
|
27
|
+
}
|
|
28
|
+
listOnline() {
|
|
29
|
+
return this.list()
|
|
30
|
+
.filter((d) => d.online)
|
|
31
|
+
.sort((a, b) => b.lastSeenAtMs - a.lastSeenAtMs);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
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.
|
|
41
|
+
*/
|
|
42
|
+
resolveDefault() {
|
|
43
|
+
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
|
+
}
|
|
54
|
+
toRuntimeConfig(state) {
|
|
55
|
+
return recordToRuntimeConfig(state.record);
|
|
56
|
+
}
|
|
57
|
+
subscribe(cb) {
|
|
58
|
+
this.listChangedSubs.add(cb);
|
|
59
|
+
return () => this.listChangedSubs.delete(cb);
|
|
60
|
+
}
|
|
61
|
+
computeOnlineSet() {
|
|
62
|
+
const out = new Set();
|
|
63
|
+
for (const d of this.devices.values()) {
|
|
64
|
+
if (d.online)
|
|
65
|
+
out.add(d.credentialId);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
setsEqual(a, b) {
|
|
70
|
+
if (a.size !== b.size)
|
|
71
|
+
return false;
|
|
72
|
+
for (const x of a)
|
|
73
|
+
if (!b.has(x))
|
|
74
|
+
return false;
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
scheduleListChanged() {
|
|
78
|
+
const now = this.computeOnlineSet();
|
|
79
|
+
if (this.setsEqual(now, this.lastOnlineSet)) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (this.debounceTimer)
|
|
83
|
+
return;
|
|
84
|
+
this.debounceTimer = setTimeout(() => {
|
|
85
|
+
this.debounceTimer = null;
|
|
86
|
+
const current = this.computeOnlineSet();
|
|
87
|
+
if (this.setsEqual(current, this.lastOnlineSet))
|
|
88
|
+
return;
|
|
89
|
+
this.lastOnlineSet = current;
|
|
90
|
+
for (const cb of this.listChangedSubs) {
|
|
91
|
+
try {
|
|
92
|
+
cb();
|
|
93
|
+
}
|
|
94
|
+
catch { /* swallow */ }
|
|
95
|
+
}
|
|
96
|
+
}, LIST_CHANGED_DEBOUNCE_MS);
|
|
97
|
+
}
|
|
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
|
+
}
|
|
107
|
+
}
|
|
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
|
+
}
|
|
118
|
+
}
|
|
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;
|
|
136
|
+
}
|
|
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
|
+
}
|
|
148
|
+
stopHeartbeat(state) {
|
|
149
|
+
if (state.heartbeatTimer) {
|
|
150
|
+
clearInterval(state.heartbeatTimer);
|
|
151
|
+
state.heartbeatTimer = null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
startSSE(state) {
|
|
155
|
+
if (state.sseController)
|
|
156
|
+
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;
|
|
169
|
+
if (plat === "ios" || plat === "android") {
|
|
170
|
+
state.platform = plat;
|
|
171
|
+
state.record.platform = plat;
|
|
172
|
+
}
|
|
173
|
+
this.touchLastSeen(state);
|
|
174
|
+
this.updateOnlineFlag(state);
|
|
175
|
+
}
|
|
176
|
+
else if (ev.kind === "credential.revoked") {
|
|
177
|
+
dbg(`[registry] ${state.credentialId} credential.revoked`);
|
|
178
|
+
state.online = false;
|
|
179
|
+
this.scheduleListChanged();
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
onConnected: () => {
|
|
183
|
+
dbg(`[registry] SSE connected: ${state.credentialId}`);
|
|
184
|
+
state.sseConnected = true;
|
|
185
|
+
this.updateOnlineFlag(state);
|
|
186
|
+
},
|
|
187
|
+
onDisconnected: () => {
|
|
188
|
+
dbg(`[registry] SSE disconnected: ${state.credentialId}`);
|
|
189
|
+
state.sseConnected = false;
|
|
190
|
+
this.updateOnlineFlag(state);
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
}
|
|
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);
|
|
227
|
+
}
|
|
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
|
+
}
|
|
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);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
s.record = record;
|
|
247
|
+
s.label = record.label;
|
|
248
|
+
}
|
|
249
|
+
this.startSSE(s);
|
|
250
|
+
this.startHeartbeat(s);
|
|
251
|
+
await this.refreshProfile(s).catch(() => false);
|
|
252
|
+
}
|
|
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);
|
|
259
|
+
}
|
|
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;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
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);
|
|
278
|
+
}
|
|
279
|
+
this.devices.clear();
|
|
280
|
+
if (this.debounceTimer) {
|
|
281
|
+
clearTimeout(this.debounceTimer);
|
|
282
|
+
this.debounceTimer = null;
|
|
283
|
+
}
|
|
284
|
+
this.listChangedSubs.clear();
|
|
285
|
+
this.initialized = false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
export const registry = new Registry();
|
|
@@ -1,2 +1,13 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export declare function
|
|
1
|
+
import type { ZhiHandRuntimeConfig } from "./config.ts";
|
|
2
|
+
export declare function getSnapshotStaleThresholdMs(): number;
|
|
3
|
+
export interface ScreenshotResult {
|
|
4
|
+
buffer: Buffer;
|
|
5
|
+
ageMs: number;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
capturedAt: string | null;
|
|
9
|
+
sequence: number;
|
|
10
|
+
stale: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function fetchScreenshot(config: ZhiHandRuntimeConfig): Promise<ScreenshotResult>;
|
|
13
|
+
export declare function fetchScreenshotBinary(config: ZhiHandRuntimeConfig): Promise<Buffer>;
|
package/dist/core/screenshot.js
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import { dbg } from "../daemon/logger.js";
|
|
2
|
-
|
|
2
|
+
// Snapshot is considered stale if the server-reported age exceeds this
|
|
3
|
+
// threshold. Configurable via env ZHIHAND_SNAPSHOT_MAX_AGE_MS.
|
|
4
|
+
// Default 5s: typical HID command + capture + upload is well under 2s;
|
|
5
|
+
// anything beyond 5s suggests the phone is no longer actively sharing.
|
|
6
|
+
export function getSnapshotStaleThresholdMs() {
|
|
7
|
+
const raw = process.env.ZHIHAND_SNAPSHOT_MAX_AGE_MS;
|
|
8
|
+
if (raw) {
|
|
9
|
+
const n = Number(raw);
|
|
10
|
+
if (Number.isFinite(n) && n > 0)
|
|
11
|
+
return n;
|
|
12
|
+
}
|
|
13
|
+
return 5000;
|
|
14
|
+
}
|
|
15
|
+
function parseIntHeader(h) {
|
|
16
|
+
if (!h)
|
|
17
|
+
return -1;
|
|
18
|
+
const n = Number(h);
|
|
19
|
+
return Number.isFinite(n) ? n : -1;
|
|
20
|
+
}
|
|
21
|
+
export async function fetchScreenshot(config) {
|
|
3
22
|
const controller = new AbortController();
|
|
4
23
|
const timeoutMs = config.timeoutMs ?? 10_000;
|
|
5
24
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -20,10 +39,31 @@ export async function fetchScreenshotBinary(config) {
|
|
|
20
39
|
throw new Error(`Screenshot fetch failed: ${response.status}`);
|
|
21
40
|
}
|
|
22
41
|
const buf = Buffer.from(await response.arrayBuffer());
|
|
23
|
-
|
|
24
|
-
|
|
42
|
+
const ageMs = parseIntHeader(response.headers.get("x-snapshot-age"));
|
|
43
|
+
const width = parseIntHeader(response.headers.get("x-snapshot-width"));
|
|
44
|
+
const height = parseIntHeader(response.headers.get("x-snapshot-height"));
|
|
45
|
+
const sequence = parseIntHeader(response.headers.get("x-snapshot-sequence"));
|
|
46
|
+
const capturedAt = response.headers.get("x-snapshot-captured-at");
|
|
47
|
+
const threshold = getSnapshotStaleThresholdMs();
|
|
48
|
+
const stale = ageMs >= 0 && ageMs > threshold;
|
|
49
|
+
dbg(`[screenshot] OK: ${(buf.length / 1024).toFixed(0)}KB in ${Date.now() - t0}ms, age=${ageMs}ms, stale=${stale}`);
|
|
50
|
+
return {
|
|
51
|
+
buffer: buf,
|
|
52
|
+
ageMs,
|
|
53
|
+
width: Math.max(width, 0),
|
|
54
|
+
height: Math.max(height, 0),
|
|
55
|
+
capturedAt,
|
|
56
|
+
sequence,
|
|
57
|
+
stale,
|
|
58
|
+
};
|
|
25
59
|
}
|
|
26
60
|
finally {
|
|
27
61
|
clearTimeout(timeout);
|
|
28
62
|
}
|
|
29
63
|
}
|
|
64
|
+
// Backward-compatible wrapper — returns only the Buffer.
|
|
65
|
+
// New code should prefer fetchScreenshot() for staleness info.
|
|
66
|
+
export async function fetchScreenshotBinary(config) {
|
|
67
|
+
const res = await fetchScreenshot(config);
|
|
68
|
+
return res.buffer;
|
|
69
|
+
}
|
package/dist/core/sse.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ZhiHandRuntimeConfig } from "./config.ts";
|
|
2
2
|
import type { QueuedCommandRecord, WaitForCommandAckResult } from "./command.ts";
|
|
3
3
|
export interface SSEEvent {
|
|
4
4
|
id: string;
|
|
@@ -6,29 +6,26 @@ export interface SSEEvent {
|
|
|
6
6
|
kind: string;
|
|
7
7
|
credential_id: string;
|
|
8
8
|
command?: QueuedCommandRecord;
|
|
9
|
+
device_profile?: Record<string, unknown>;
|
|
9
10
|
sequence: number;
|
|
10
11
|
}
|
|
11
12
|
export declare function handleSSEEvent(event: SSEEvent): void;
|
|
12
13
|
export declare function subscribeToCommandAck(commandId: string, callback: (cmd: QueuedCommandRecord) => void): () => void;
|
|
14
|
+
export interface SSEHandlers {
|
|
15
|
+
onEvent: (e: SSEEvent) => void;
|
|
16
|
+
onConnected: () => void;
|
|
17
|
+
onDisconnected: () => void;
|
|
18
|
+
}
|
|
13
19
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* Reconnects automatically on connection loss.
|
|
17
|
-
*/
|
|
18
|
-
export declare function connectSSE(config: ZhiHandConfig): void;
|
|
19
|
-
/**
|
|
20
|
-
* Disconnect the SSE event stream.
|
|
21
|
-
*/
|
|
22
|
-
export declare function disconnectSSE(): void;
|
|
23
|
-
/**
|
|
24
|
-
* Whether the SSE stream is currently connected.
|
|
20
|
+
* Open a per-credential SSE connection. Caller owns the returned AbortController.
|
|
21
|
+
* The loop auto-reconnects with exponential backoff until aborted.
|
|
25
22
|
*/
|
|
26
|
-
export declare function
|
|
23
|
+
export declare function connectSSEForCredential(config: ZhiHandRuntimeConfig, handlers: SSEHandlers): AbortController;
|
|
27
24
|
/**
|
|
28
|
-
* Wait for command ACK via SSE push
|
|
29
|
-
* Falls back to polling
|
|
25
|
+
* Wait for command ACK via SSE push (which should already be connected by the
|
|
26
|
+
* registry). Falls back to polling.
|
|
30
27
|
*/
|
|
31
|
-
export declare function waitForCommandAck(config:
|
|
28
|
+
export declare function waitForCommandAck(config: ZhiHandRuntimeConfig, options: {
|
|
32
29
|
commandId: string;
|
|
33
30
|
timeoutMs?: number;
|
|
34
31
|
signal?: AbortSignal;
|
package/dist/core/sse.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { getCommand } from "./command.js";
|
|
2
2
|
import { dbg } from "../daemon/logger.js";
|
|
3
|
-
// Per-commandId callback registry for SSE-based ACK
|
|
3
|
+
// Per-commandId callback registry for SSE-based ACK (global — ids are globally unique)
|
|
4
4
|
const ackCallbacks = new Map();
|
|
5
|
-
// Active SSE connection state
|
|
6
|
-
let sseAbortController = null;
|
|
7
|
-
let sseConnected = false;
|
|
8
5
|
export function handleSSEEvent(event) {
|
|
9
6
|
dbg(`[sse-cmd] Event: kind=${event.kind}, command=${event.command?.id ?? "-"}`);
|
|
10
7
|
if (event.kind === "command.acked" && event.command) {
|
|
@@ -21,16 +18,15 @@ export function subscribeToCommandAck(commandId, callback) {
|
|
|
21
18
|
return () => { ackCallbacks.delete(commandId); };
|
|
22
19
|
}
|
|
23
20
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* Reconnects automatically on connection loss.
|
|
21
|
+
* Open a per-credential SSE connection. Caller owns the returned AbortController.
|
|
22
|
+
* The loop auto-reconnects with exponential backoff until aborted.
|
|
27
23
|
*/
|
|
28
|
-
export function
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
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;
|
|
34
30
|
(async () => {
|
|
35
31
|
while (!signal.aborted) {
|
|
36
32
|
try {
|
|
@@ -44,12 +40,14 @@ export function connectSSE(config) {
|
|
|
44
40
|
if (!response.ok) {
|
|
45
41
|
throw new Error(`SSE connect failed: ${response.status}`);
|
|
46
42
|
}
|
|
47
|
-
|
|
43
|
+
handlers.onConnected();
|
|
44
|
+
backoffMs = 1000;
|
|
48
45
|
const reader = response.body?.getReader();
|
|
49
46
|
if (!reader)
|
|
50
47
|
throw new Error("No response body for SSE");
|
|
51
48
|
const decoder = new TextDecoder();
|
|
52
49
|
let buffer = "";
|
|
50
|
+
let eventData = "";
|
|
53
51
|
while (!signal.aborted) {
|
|
54
52
|
const { done, value } = await reader.read();
|
|
55
53
|
if (done)
|
|
@@ -57,18 +55,17 @@ export function connectSSE(config) {
|
|
|
57
55
|
buffer += decoder.decode(value, { stream: true });
|
|
58
56
|
const lines = buffer.split("\n");
|
|
59
57
|
buffer = lines.pop() ?? "";
|
|
60
|
-
let eventData = "";
|
|
61
58
|
for (const line of lines) {
|
|
62
59
|
if (line.startsWith("data: ")) {
|
|
63
|
-
eventData += line.slice(6);
|
|
60
|
+
eventData += (eventData ? "\n" : "") + line.slice(6);
|
|
64
61
|
}
|
|
65
62
|
else if (line === "" && eventData) {
|
|
66
63
|
try {
|
|
67
|
-
const
|
|
68
|
-
|
|
64
|
+
const ev = JSON.parse(eventData);
|
|
65
|
+
handlers.onEvent(ev);
|
|
69
66
|
}
|
|
70
67
|
catch {
|
|
71
|
-
//
|
|
68
|
+
// malformed, skip
|
|
72
69
|
}
|
|
73
70
|
eventData = "";
|
|
74
71
|
}
|
|
@@ -78,37 +75,22 @@ export function connectSSE(config) {
|
|
|
78
75
|
catch (err) {
|
|
79
76
|
if (signal.aborted)
|
|
80
77
|
break;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
78
|
+
handlers.onDisconnected();
|
|
79
|
+
await new Promise((r) => setTimeout(r, backoffMs));
|
|
80
|
+
backoffMs = Math.min(backoffMs * 2, BACKOFF_MAX);
|
|
84
81
|
}
|
|
85
82
|
}
|
|
86
|
-
|
|
83
|
+
handlers.onDisconnected();
|
|
87
84
|
})();
|
|
85
|
+
return controller;
|
|
88
86
|
}
|
|
89
87
|
/**
|
|
90
|
-
*
|
|
91
|
-
|
|
92
|
-
export function disconnectSSE() {
|
|
93
|
-
sseAbortController?.abort();
|
|
94
|
-
sseAbortController = null;
|
|
95
|
-
sseConnected = false;
|
|
96
|
-
}
|
|
97
|
-
/**
|
|
98
|
-
* Whether the SSE stream is currently connected.
|
|
99
|
-
*/
|
|
100
|
-
export function isSSEConnected() {
|
|
101
|
-
return sseConnected;
|
|
102
|
-
}
|
|
103
|
-
/**
|
|
104
|
-
* Wait for command ACK via SSE push.
|
|
105
|
-
* Falls back to polling if SSE is not active.
|
|
88
|
+
* Wait for command ACK via SSE push (which should already be connected by the
|
|
89
|
+
* registry). Falls back to polling.
|
|
106
90
|
*/
|
|
107
91
|
export async function waitForCommandAck(config, options) {
|
|
108
92
|
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
109
93
|
dbg(`[sse-cmd] Waiting for ACK: commandId=${options.commandId}, timeout=${timeoutMs}ms`);
|
|
110
|
-
// Ensure SSE is connected for real-time ACKs
|
|
111
|
-
connectSSE(config);
|
|
112
94
|
return new Promise((resolve, reject) => {
|
|
113
95
|
let resolved = false;
|
|
114
96
|
let pollInterval;
|
|
@@ -123,28 +105,38 @@ export async function waitForCommandAck(config, options) {
|
|
|
123
105
|
cleanup();
|
|
124
106
|
resolve({ acked: true, command: ackedCommand });
|
|
125
107
|
});
|
|
126
|
-
//
|
|
127
|
-
|
|
108
|
+
// Delay polling startup by 2s so SSE push ACK normally wins in the
|
|
109
|
+
// registry-connected path. CLI (zhihand test) still resolves via polling
|
|
110
|
+
// after the initial delay. This avoids hammering the backend with 500ms
|
|
111
|
+
// HTTP polls for every command when SSE is healthy.
|
|
112
|
+
const POLL_START_DELAY_MS = 2000;
|
|
113
|
+
const POLL_INTERVAL_MS = 500;
|
|
114
|
+
const startPolling = setTimeout(() => {
|
|
128
115
|
if (resolved)
|
|
129
116
|
return;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
117
|
+
pollInterval = setInterval(async () => {
|
|
118
|
+
if (resolved)
|
|
119
|
+
return;
|
|
120
|
+
try {
|
|
121
|
+
const cmd = await getCommand(config, options.commandId);
|
|
122
|
+
if (cmd.acked_at) {
|
|
123
|
+
resolved = true;
|
|
124
|
+
cleanup();
|
|
125
|
+
resolve({ acked: true, command: cmd });
|
|
126
|
+
}
|
|
136
127
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
},
|
|
128
|
+
catch {
|
|
129
|
+
// non-fatal
|
|
130
|
+
}
|
|
131
|
+
}, POLL_INTERVAL_MS);
|
|
132
|
+
}, POLL_START_DELAY_MS);
|
|
142
133
|
options.signal?.addEventListener("abort", () => {
|
|
143
134
|
cleanup();
|
|
144
135
|
reject(new Error("The operation was aborted"));
|
|
145
136
|
}, { once: true });
|
|
146
137
|
function cleanup() {
|
|
147
138
|
clearTimeout(timeout);
|
|
139
|
+
clearTimeout(startPolling);
|
|
148
140
|
unsubscribe();
|
|
149
141
|
if (pollInterval)
|
|
150
142
|
clearInterval(pollInterval);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ZhiHandRuntimeConfig, BackendName } from "../core/config.ts";
|
|
2
|
+
type ZhiHandConfig = ZhiHandRuntimeConfig;
|
|
2
3
|
export interface DispatchResult {
|
|
3
4
|
text: string;
|
|
4
5
|
success: boolean;
|
|
@@ -10,3 +11,4 @@ export interface DispatchResult {
|
|
|
10
11
|
export declare function killActiveChild(): Promise<void>;
|
|
11
12
|
export declare function dispatchToCLI(backend: Exclude<BackendName, "openclaw">, prompt: string, log: (msg: string) => void, model?: string): Promise<DispatchResult>;
|
|
12
13
|
export declare function postReply(config: ZhiHandConfig, promptId: string, text: string): Promise<boolean>;
|
|
14
|
+
export {};
|
|
@@ -5,7 +5,7 @@ import os from "node:os";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { DEFAULT_MODELS } from "../core/config.js";
|
|
7
7
|
import { resolveGemini, resolveClaude, resolveCodex } from "../core/resolve-path.js";
|
|
8
|
-
import {
|
|
8
|
+
import { registry } from "../core/registry.js";
|
|
9
9
|
import { dbg } from "./logger.js";
|
|
10
10
|
const CLI_TIMEOUT = 300_000; // 300s (5min) per prompt — MCP tool chains need multiple turns
|
|
11
11
|
const SIGKILL_DELAY = 2_000; // 2s after SIGTERM
|
|
@@ -357,7 +357,8 @@ export async function killActiveChild() {
|
|
|
357
357
|
* so the AI sends correct platform-specific parameters (e.g. appPackage vs bundleId).
|
|
358
358
|
*/
|
|
359
359
|
function buildSystemContext() {
|
|
360
|
-
const
|
|
360
|
+
const defaultState = registry.resolveDefault();
|
|
361
|
+
const static_ = defaultState?.profile ?? null;
|
|
361
362
|
const deviceLine = static_
|
|
362
363
|
? `Connected device: ${static_.platform} ${static_.model} (${static_.osVersion}), ${static_.screenWidthPx}x${static_.screenHeightPx}, ${static_.formFactor}, ${static_.locale}`
|
|
363
364
|
: "Connected device: unknown platform";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ZhiHandRuntimeConfig } from "../core/config.ts";
|
|
2
2
|
/** Brain metadata included in every heartbeat, so the app always knows the current backend/model. */
|
|
3
3
|
export interface BrainMeta {
|
|
4
4
|
backend?: string | null;
|
|
@@ -6,7 +6,7 @@ export interface BrainMeta {
|
|
|
6
6
|
}
|
|
7
7
|
/** Update the backend/model metadata that will be sent with the next heartbeat. */
|
|
8
8
|
export declare function setBrainMeta(meta: BrainMeta): void;
|
|
9
|
-
export declare function sendBrainOnline(config:
|
|
10
|
-
export declare function sendBrainOffline(config:
|
|
11
|
-
export declare function startHeartbeatLoop(config:
|
|
9
|
+
export declare function sendBrainOnline(config: ZhiHandRuntimeConfig): Promise<boolean>;
|
|
10
|
+
export declare function sendBrainOffline(config: ZhiHandRuntimeConfig): Promise<boolean>;
|
|
11
|
+
export declare function startHeartbeatLoop(config: ZhiHandRuntimeConfig, log: (msg: string) => void): void;
|
|
12
12
|
export declare function stopHeartbeatLoop(): void;
|
package/dist/daemon/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import { startHeartbeatLoop, stopHeartbeatLoop, sendBrainOffline, setBrainMeta }
|
|
|
11
11
|
import { PromptListener } from "./prompt-listener.js";
|
|
12
12
|
import { dispatchToCLI, postReply, killActiveChild } from "./dispatcher.js";
|
|
13
13
|
import { setDebugEnabled, dbg } from "./logger.js";
|
|
14
|
-
import {
|
|
14
|
+
import { registry } from "../core/registry.js";
|
|
15
15
|
const DEFAULT_PORT = 18686;
|
|
16
16
|
const PID_FILE = "daemon.pid";
|
|
17
17
|
// ── State ──────────────────────────────────────────────────
|
|
@@ -190,10 +190,11 @@ 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
|
-
//
|
|
194
|
-
await
|
|
195
|
-
|
|
196
|
-
|
|
193
|
+
// Init the multi-device registry (single-device in daemon context) and log profile.
|
|
194
|
+
await registry.init();
|
|
195
|
+
const defaultState = registry.resolveDefault();
|
|
196
|
+
if (defaultState?.profile) {
|
|
197
|
+
const s = defaultState.profile;
|
|
197
198
|
log(`[device] ${s.platform} ${s.model} (${s.osVersion}), ${s.screenWidthPx}x${s.screenHeightPx}, ${s.locale}`);
|
|
198
199
|
}
|
|
199
200
|
else {
|
|
@@ -244,7 +245,7 @@ export async function startDaemon(options) {
|
|
|
244
245
|
}
|
|
245
246
|
else if (!sessionId) {
|
|
246
247
|
// New session: create dedicated McpServer + Transport
|
|
247
|
-
const server = createMcpServer(
|
|
248
|
+
const server = createMcpServer();
|
|
248
249
|
const transport = new StreamableHTTPServerTransport({
|
|
249
250
|
sessionIdGenerator: () => randomUUID(),
|
|
250
251
|
onsessioninitialized: (sid) => {
|
|
@@ -341,6 +342,7 @@ export async function startDaemon(options) {
|
|
|
341
342
|
catch { /* ignore */ }
|
|
342
343
|
}
|
|
343
344
|
mcpSessions.clear();
|
|
345
|
+
registry.shutdown();
|
|
344
346
|
httpServer.close();
|
|
345
347
|
removePid();
|
|
346
348
|
log("Daemon stopped.");
|