@zhihand/mcp 0.29.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 +448 -212
- package/dist/core/command.d.ts +5 -5
- package/dist/core/command.js +6 -8
- package/dist/core/config.d.ts +48 -21
- package/dist/core/config.js +178 -42
- package/dist/core/device.d.ts +28 -19
- package/dist/core/device.js +168 -145
- 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 +205 -77
- package/dist/core/registry.d.ts +60 -0
- package/dist/core/registry.js +415 -0
- package/dist/core/screenshot.d.ts +3 -3
- package/dist/core/screenshot.js +3 -2
- package/dist/core/sse.d.ts +40 -18
- package/dist/core/sse.js +122 -62
- package/dist/core/ws.d.ts +92 -0
- package/dist/core/ws.js +327 -0
- package/dist/daemon/dispatcher.d.ts +3 -1
- package/dist/daemon/dispatcher.js +4 -3
- package/dist/daemon/heartbeat.d.ts +4 -4
- package/dist/daemon/heartbeat.js +1 -1
- package/dist/daemon/index.js +10 -8
- package/dist/daemon/prompt-listener.d.ts +8 -7
- package/dist/daemon/prompt-listener.js +59 -99
- package/dist/index.d.ts +3 -3
- package/dist/index.js +104 -40
- package/dist/openclaw.adapter.js +10 -2
- package/dist/tools/control.d.ts +10 -3
- package/dist/tools/control.js +18 -24
- package/dist/tools/pair.d.ts +1 -1
- package/dist/tools/pair.js +22 -28
- 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 +19 -6
- package/package.json +3 -1
package/dist/core/pair.js
CHANGED
|
@@ -1,66 +1,78 @@
|
|
|
1
1
|
import QRCode from "qrcode";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
"screen.read",
|
|
7
|
-
"screen.capture",
|
|
8
|
-
"ble.control",
|
|
9
|
-
];
|
|
2
|
+
import { addUser, addDeviceToUser, ensureZhiHandDir, saveState, resolveDefaultEndpoint, getUserRecord, } from "./config.js";
|
|
3
|
+
import { fetchDeviceProfileOnce, extractStatic } from "./device.js";
|
|
4
|
+
import { fetchUserCredentials } from "./ws.js";
|
|
5
|
+
// ── Server API helpers ─────────────────────────────────────
|
|
10
6
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* Idempotent — re-registering with the same stable_identity returns the existing plugin.
|
|
7
|
+
* Create a new user on the server.
|
|
8
|
+
* POST /v1/users { label } → { user_id, controller_token, label, created_at }
|
|
14
9
|
*/
|
|
15
|
-
export async function
|
|
16
|
-
const response = await fetch(`${endpoint}/v1/
|
|
10
|
+
export async function createUser(endpoint, label) {
|
|
11
|
+
const response = await fetch(`${endpoint}/v1/users`, {
|
|
17
12
|
method: "POST",
|
|
18
13
|
headers: { "Content-Type": "application/json" },
|
|
19
|
-
body: JSON.stringify({
|
|
20
|
-
adapter_kind: options.adapterKind ?? "mcp",
|
|
21
|
-
display_name: options.displayName ?? "ZhiHand MCP Server",
|
|
22
|
-
stable_identity: options.stableIdentity,
|
|
23
|
-
}),
|
|
14
|
+
body: JSON.stringify({ label }),
|
|
24
15
|
});
|
|
25
16
|
if (!response.ok) {
|
|
26
|
-
throw new Error(`
|
|
17
|
+
throw new Error(`Create user failed: ${response.status} ${await response.text()}`);
|
|
27
18
|
}
|
|
28
|
-
|
|
29
|
-
return payload.plugin;
|
|
19
|
+
return (await response.json());
|
|
30
20
|
}
|
|
31
|
-
|
|
32
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Create a pairing session for a user.
|
|
23
|
+
* POST /v1/users/{id}/pairing/sessions { edge_id, ttl_seconds } → PairingSession
|
|
24
|
+
*/
|
|
25
|
+
export async function createPairingSession(endpoint, userId, controllerToken, edgeId, ttlSeconds = 300) {
|
|
26
|
+
const response = await fetch(`${endpoint}/v1/users/${encodeURIComponent(userId)}/pairing/sessions`, {
|
|
33
27
|
method: "POST",
|
|
34
|
-
headers: {
|
|
28
|
+
headers: {
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
"Authorization": `Bearer ${controllerToken}`,
|
|
31
|
+
},
|
|
35
32
|
body: JSON.stringify({
|
|
36
|
-
edge_id:
|
|
37
|
-
ttl_seconds:
|
|
38
|
-
requested_scopes:
|
|
33
|
+
edge_id: edgeId,
|
|
34
|
+
ttl_seconds: ttlSeconds,
|
|
35
|
+
requested_scopes: ["observe", "session.control", "screen.read", "screen.capture", "ble.control"],
|
|
39
36
|
}),
|
|
40
37
|
});
|
|
41
38
|
if (!response.ok) {
|
|
42
39
|
throw new Error(`Create pairing session failed: ${response.status}`);
|
|
43
40
|
}
|
|
44
41
|
const payload = (await response.json());
|
|
45
|
-
return
|
|
46
|
-
...payload.session,
|
|
47
|
-
controller_token: payload.controller_token ?? payload.session.controller_token,
|
|
48
|
-
};
|
|
42
|
+
return payload;
|
|
49
43
|
}
|
|
50
|
-
|
|
51
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Register a plugin (edge). Kept for backward compat with edge registration.
|
|
46
|
+
*/
|
|
47
|
+
export async function registerPlugin(endpoint, options) {
|
|
48
|
+
const response = await fetch(`${endpoint}/v1/plugins`, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
adapter_kind: options.adapterKind ?? "mcp",
|
|
53
|
+
display_name: options.displayName ?? "ZhiHand MCP Server",
|
|
54
|
+
stable_identity: options.stableIdentity,
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
52
57
|
if (!response.ok) {
|
|
53
|
-
throw new Error(`
|
|
58
|
+
throw new Error(`Register plugin failed: ${response.status} ${await response.text()}`);
|
|
54
59
|
}
|
|
55
60
|
const payload = (await response.json());
|
|
56
|
-
return payload.
|
|
61
|
+
return { edge_id: payload.plugin.edge_id };
|
|
57
62
|
}
|
|
58
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Poll pairing session until claimed or expired.
|
|
65
|
+
*/
|
|
66
|
+
export async function waitForPairingClaim(endpoint, userId, controllerToken, sessionId, timeoutMs = 600_000) {
|
|
59
67
|
const deadline = Date.now() + timeoutMs;
|
|
60
68
|
while (Date.now() < deadline) {
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
|
|
69
|
+
const response = await fetch(`${endpoint}/v1/users/${encodeURIComponent(userId)}/pairing/sessions/${encodeURIComponent(sessionId)}`, { headers: { "Authorization": `Bearer ${controllerToken}` } });
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
throw new Error(`Get pairing session failed: ${response.status}`);
|
|
72
|
+
}
|
|
73
|
+
const session = (await response.json());
|
|
74
|
+
if (session.status === "claimed") {
|
|
75
|
+
return;
|
|
64
76
|
}
|
|
65
77
|
if (session.status === "expired") {
|
|
66
78
|
throw new Error("Pairing session expired.");
|
|
@@ -72,59 +84,175 @@ export async function waitForPairingClaim(endpoint, sessionId, timeoutMs = 600_0
|
|
|
72
84
|
export async function renderPairingQRCode(url) {
|
|
73
85
|
return QRCode.toString(url, { type: "utf8", margin: 2 });
|
|
74
86
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
87
|
+
// ── Pairing flows ──────────────────────────────────────────
|
|
88
|
+
/**
|
|
89
|
+
* New user pairing: create user → create pairing session → wait → fetch credentials → save config.
|
|
90
|
+
*/
|
|
91
|
+
export async function executePairingNewUser(preferredLabel) {
|
|
92
|
+
const endpoint = resolveDefaultEndpoint();
|
|
93
|
+
const label = preferredLabel ?? `User-${Date.now().toString(36)}`;
|
|
94
|
+
// 1. Create user
|
|
95
|
+
const userResp = await createUser(endpoint, label);
|
|
96
|
+
const userId = userResp.user_id;
|
|
97
|
+
const controllerToken = userResp.controller_token;
|
|
98
|
+
// 2. Register plugin (get edge_id)
|
|
99
|
+
const stableIdentity = `mcp-${Date.now().toString(36)}`;
|
|
100
|
+
const plugin = await registerPlugin(endpoint, { stableIdentity });
|
|
101
|
+
const edgeId = plugin.edge_id;
|
|
102
|
+
// 3. Create pairing session
|
|
103
|
+
const session = await createPairingSession(endpoint, userId, controllerToken, edgeId, 300);
|
|
85
104
|
saveState({
|
|
86
|
-
sessionId: session.
|
|
87
|
-
|
|
88
|
-
|
|
105
|
+
sessionId: session.session_id,
|
|
106
|
+
userId,
|
|
107
|
+
controllerToken,
|
|
108
|
+
edgeId,
|
|
89
109
|
pairUrl: session.pair_url,
|
|
90
110
|
status: "pending",
|
|
91
111
|
expiresAt: session.expires_at,
|
|
92
112
|
});
|
|
93
|
-
//
|
|
113
|
+
// 4. Show QR + wait
|
|
94
114
|
const qr = await renderPairingQRCode(session.pair_url);
|
|
95
115
|
console.log(qr);
|
|
96
116
|
console.log(`Open this URL on your phone to pair:\n ${session.pair_url}\n`);
|
|
97
117
|
console.log(`Expires at: ${session.expires_at}`);
|
|
98
118
|
console.log("Waiting for phone to scan...\n");
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
119
|
+
await waitForPairingClaim(endpoint, userId, controllerToken, session.session_id);
|
|
120
|
+
// 5. Fetch credentials to get device info
|
|
121
|
+
const creds = await fetchUserCredentials(endpoint, userId, controllerToken);
|
|
122
|
+
const cred = creds[0]; // Just-paired device should be the first/only
|
|
123
|
+
if (!cred)
|
|
124
|
+
throw new Error("Pairing claimed but no credentials found");
|
|
125
|
+
// 6. Try to get label/platform from profile
|
|
126
|
+
let deviceLabel = cred.label ?? "";
|
|
127
|
+
let platform = cred.platform ?? "unknown";
|
|
128
|
+
try {
|
|
129
|
+
const runtimeCfg = {
|
|
130
|
+
controlPlaneEndpoint: endpoint,
|
|
131
|
+
credentialId: cred.credential_id,
|
|
132
|
+
controllerToken,
|
|
133
|
+
timeoutMs: 10_000,
|
|
134
|
+
};
|
|
135
|
+
const fetched = await fetchDeviceProfileOnce(runtimeCfg);
|
|
136
|
+
if (fetched) {
|
|
137
|
+
const st = extractStatic(fetched.rawAttrs);
|
|
138
|
+
if (!deviceLabel || deviceLabel === cred.credential_id) {
|
|
139
|
+
deviceLabel = st.model && st.model !== "unknown" ? st.model : "";
|
|
140
|
+
}
|
|
141
|
+
if (st.platform === "ios" || st.platform === "android")
|
|
142
|
+
platform = st.platform;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// fall through
|
|
147
|
+
}
|
|
148
|
+
if (!deviceLabel)
|
|
149
|
+
deviceLabel = `device-${Date.now().toString(36)}`;
|
|
150
|
+
const now = new Date().toISOString();
|
|
151
|
+
const deviceRecord = {
|
|
152
|
+
credential_id: cred.credential_id,
|
|
153
|
+
label: deviceLabel,
|
|
154
|
+
platform,
|
|
155
|
+
paired_at: cred.paired_at ?? now,
|
|
156
|
+
last_seen_at: now,
|
|
157
|
+
};
|
|
158
|
+
const userRecord = {
|
|
159
|
+
user_id: userId,
|
|
160
|
+
controller_token: controllerToken,
|
|
161
|
+
label,
|
|
162
|
+
created_at: userResp.created_at ?? now,
|
|
163
|
+
devices: [deviceRecord],
|
|
107
164
|
};
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
// Update state
|
|
165
|
+
addUser(userRecord);
|
|
166
|
+
ensureZhiHandDir();
|
|
111
167
|
saveState({
|
|
112
|
-
sessionId: session.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
168
|
+
sessionId: session.session_id,
|
|
169
|
+
userId,
|
|
170
|
+
controllerToken,
|
|
171
|
+
edgeId,
|
|
172
|
+
credentialId: cred.credential_id,
|
|
116
173
|
pairUrl: session.pair_url,
|
|
117
174
|
status: "claimed",
|
|
118
175
|
});
|
|
119
|
-
return {
|
|
176
|
+
return { userRecord, deviceRecord };
|
|
120
177
|
}
|
|
121
|
-
|
|
122
|
-
|
|
178
|
+
/**
|
|
179
|
+
* Add device to existing user: create pairing session → wait → fetch new credential → save.
|
|
180
|
+
*/
|
|
181
|
+
export async function executePairingAddDevice(userId, preferredLabel) {
|
|
182
|
+
const endpoint = resolveDefaultEndpoint();
|
|
183
|
+
const user = getUserRecord(userId);
|
|
184
|
+
if (!user)
|
|
185
|
+
throw new Error(`User '${userId}' not found in config`);
|
|
186
|
+
const controllerToken = user.controller_token;
|
|
187
|
+
// Register plugin (get edge_id)
|
|
188
|
+
const stableIdentity = `mcp-${Date.now().toString(36)}`;
|
|
189
|
+
const plugin = await registerPlugin(endpoint, { stableIdentity });
|
|
190
|
+
const edgeId = plugin.edge_id;
|
|
191
|
+
// Get existing credential IDs before pairing
|
|
192
|
+
const existingCreds = await fetchUserCredentials(endpoint, userId, controllerToken);
|
|
193
|
+
const existingIds = new Set(existingCreds.map((c) => c.credential_id));
|
|
194
|
+
// Create pairing session
|
|
195
|
+
const session = await createPairingSession(endpoint, userId, controllerToken, edgeId, 300);
|
|
196
|
+
const qr = await renderPairingQRCode(session.pair_url);
|
|
197
|
+
console.log(qr);
|
|
198
|
+
console.log(`Open this URL on your phone to pair:\n ${session.pair_url}\n`);
|
|
199
|
+
console.log(`Expires at: ${session.expires_at}`);
|
|
200
|
+
console.log("Waiting for phone to scan...\n");
|
|
201
|
+
await waitForPairingClaim(endpoint, userId, controllerToken, session.session_id);
|
|
202
|
+
// Fetch credentials and find the new one
|
|
203
|
+
const updatedCreds = await fetchUserCredentials(endpoint, userId, controllerToken);
|
|
204
|
+
const newCred = updatedCreds.find((c) => !existingIds.has(c.credential_id));
|
|
205
|
+
if (!newCred)
|
|
206
|
+
throw new Error("Pairing claimed but no new credential found");
|
|
207
|
+
// Try to get label/platform
|
|
208
|
+
let deviceLabel = preferredLabel ?? newCred.label ?? "";
|
|
209
|
+
let platform = newCred.platform ?? "unknown";
|
|
210
|
+
try {
|
|
211
|
+
const runtimeCfg = {
|
|
212
|
+
controlPlaneEndpoint: endpoint,
|
|
213
|
+
credentialId: newCred.credential_id,
|
|
214
|
+
controllerToken,
|
|
215
|
+
timeoutMs: 10_000,
|
|
216
|
+
};
|
|
217
|
+
const fetched = await fetchDeviceProfileOnce(runtimeCfg);
|
|
218
|
+
if (fetched) {
|
|
219
|
+
const st = extractStatic(fetched.rawAttrs);
|
|
220
|
+
if (!deviceLabel || deviceLabel === newCred.credential_id) {
|
|
221
|
+
deviceLabel = st.model && st.model !== "unknown" ? st.model : "";
|
|
222
|
+
}
|
|
223
|
+
if (st.platform === "ios" || st.platform === "android")
|
|
224
|
+
platform = st.platform;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// fall through
|
|
229
|
+
}
|
|
230
|
+
if (!deviceLabel)
|
|
231
|
+
deviceLabel = `device-${Date.now().toString(36)}`;
|
|
232
|
+
const now = new Date().toISOString();
|
|
233
|
+
const deviceRecord = {
|
|
234
|
+
credential_id: newCred.credential_id,
|
|
235
|
+
label: deviceLabel,
|
|
236
|
+
platform,
|
|
237
|
+
paired_at: newCred.paired_at ?? now,
|
|
238
|
+
last_seen_at: now,
|
|
239
|
+
};
|
|
240
|
+
addDeviceToUser(userId, deviceRecord);
|
|
241
|
+
return deviceRecord;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Legacy: format pairing status (kept for backward compat).
|
|
245
|
+
*/
|
|
246
|
+
export function formatPairingStatus(userId) {
|
|
247
|
+
if (!userId)
|
|
248
|
+
return "Not paired. Run 'zhihand pair' to connect a device.";
|
|
249
|
+
const user = getUserRecord(userId);
|
|
250
|
+
if (!user)
|
|
123
251
|
return "Not paired. Run 'zhihand pair' to connect a device.";
|
|
124
|
-
|
|
125
|
-
`
|
|
126
|
-
`
|
|
127
|
-
`
|
|
128
|
-
|
|
129
|
-
|
|
252
|
+
const lines = [
|
|
253
|
+
`User: ${user.label} (${user.user_id})`,
|
|
254
|
+
`Devices: ${user.devices.length}`,
|
|
255
|
+
...user.devices.map((d) => ` - ${d.credential_id} (${d.label}, ${d.platform})`),
|
|
256
|
+
];
|
|
257
|
+
return lines.join("\n");
|
|
130
258
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device registry — the single source of truth for all paired devices,
|
|
3
|
+
* their live state, and multi-user WebSocket streams.
|
|
4
|
+
*
|
|
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
|
+
*/
|
|
9
|
+
import { type DeviceRecord, type DevicePlatform, type ZhiHandRuntimeConfig } from "./config.ts";
|
|
10
|
+
import { type StaticContext, type Capabilities } from "./device.ts";
|
|
11
|
+
export interface DeviceState {
|
|
12
|
+
credentialId: string;
|
|
13
|
+
userId: string;
|
|
14
|
+
userLabel: string;
|
|
15
|
+
label: string;
|
|
16
|
+
platform: DevicePlatform;
|
|
17
|
+
online: boolean;
|
|
18
|
+
lastSeenAtMs: number;
|
|
19
|
+
profile: StaticContext | null;
|
|
20
|
+
capabilities: Capabilities | null;
|
|
21
|
+
profileReceivedAtMs: number;
|
|
22
|
+
rawAttributes: Record<string, unknown>;
|
|
23
|
+
record: DeviceRecord;
|
|
24
|
+
}
|
|
25
|
+
type ListChangedCb = () => void;
|
|
26
|
+
declare class Registry {
|
|
27
|
+
private userStates;
|
|
28
|
+
private listChangedSubs;
|
|
29
|
+
private debounceTimer;
|
|
30
|
+
private lastOnlineSet;
|
|
31
|
+
private initialized;
|
|
32
|
+
private configWatchActive;
|
|
33
|
+
private reconcileTimer;
|
|
34
|
+
get(credentialId: string): DeviceState | null;
|
|
35
|
+
list(): DeviceState[];
|
|
36
|
+
listOnline(): DeviceState[];
|
|
37
|
+
/**
|
|
38
|
+
* Most-recently-active online device across all users.
|
|
39
|
+
*/
|
|
40
|
+
resolveDefault(): DeviceState | null;
|
|
41
|
+
isMultiUser(): boolean;
|
|
42
|
+
toRuntimeConfig(state: DeviceState): ZhiHandRuntimeConfig;
|
|
43
|
+
subscribe(cb: ListChangedCb): () => void;
|
|
44
|
+
init(): Promise<void>;
|
|
45
|
+
shutdown(): void;
|
|
46
|
+
private computeOnlineSet;
|
|
47
|
+
private setsEqual;
|
|
48
|
+
private scheduleListChanged;
|
|
49
|
+
private createUserState;
|
|
50
|
+
private makeDeviceState;
|
|
51
|
+
private populateDevicesFromConfig;
|
|
52
|
+
private fetchAndPopulateDevices;
|
|
53
|
+
private startUserStream;
|
|
54
|
+
private touchLastSeen;
|
|
55
|
+
private startConfigWatch;
|
|
56
|
+
private stopConfigWatch;
|
|
57
|
+
private reconcileConfig;
|
|
58
|
+
}
|
|
59
|
+
export declare const registry: Registry;
|
|
60
|
+
export {};
|