@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.
Files changed (42) hide show
  1. package/bin/zhihand +448 -212
  2. package/dist/core/command.d.ts +5 -5
  3. package/dist/core/command.js +6 -8
  4. package/dist/core/config.d.ts +48 -21
  5. package/dist/core/config.js +178 -42
  6. package/dist/core/device.d.ts +28 -19
  7. package/dist/core/device.js +168 -145
  8. package/dist/core/logger.d.ts +17 -0
  9. package/dist/core/logger.js +32 -0
  10. package/dist/core/pair.d.ts +39 -31
  11. package/dist/core/pair.js +205 -77
  12. package/dist/core/registry.d.ts +60 -0
  13. package/dist/core/registry.js +415 -0
  14. package/dist/core/screenshot.d.ts +3 -3
  15. package/dist/core/screenshot.js +3 -2
  16. package/dist/core/sse.d.ts +40 -18
  17. package/dist/core/sse.js +122 -62
  18. package/dist/core/ws.d.ts +92 -0
  19. package/dist/core/ws.js +327 -0
  20. package/dist/daemon/dispatcher.d.ts +3 -1
  21. package/dist/daemon/dispatcher.js +4 -3
  22. package/dist/daemon/heartbeat.d.ts +4 -4
  23. package/dist/daemon/heartbeat.js +1 -1
  24. package/dist/daemon/index.js +10 -8
  25. package/dist/daemon/prompt-listener.d.ts +8 -7
  26. package/dist/daemon/prompt-listener.js +59 -99
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.js +104 -40
  29. package/dist/openclaw.adapter.js +10 -2
  30. package/dist/tools/control.d.ts +10 -3
  31. package/dist/tools/control.js +18 -24
  32. package/dist/tools/pair.d.ts +1 -1
  33. package/dist/tools/pair.js +22 -28
  34. package/dist/tools/resolve.d.ts +7 -0
  35. package/dist/tools/resolve.js +22 -0
  36. package/dist/tools/schemas.d.ts +9 -1
  37. package/dist/tools/schemas.js +10 -8
  38. package/dist/tools/screenshot.d.ts +3 -2
  39. package/dist/tools/screenshot.js +2 -2
  40. package/dist/tools/system.d.ts +3 -5
  41. package/dist/tools/system.js +19 -6
  42. package/package.json +3 -1
package/dist/core/pair.js CHANGED
@@ -1,66 +1,78 @@
1
1
  import QRCode from "qrcode";
2
- import { saveCredential, saveState } from "./config.js";
3
- const DEFAULT_SCOPES = [
4
- "observe",
5
- "session.control",
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
- * Register this MCP instance as a plugin with the server.
12
- * Server requires a registered plugin (edge_id) before pairing can begin.
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 registerPlugin(endpoint, options) {
16
- const response = await fetch(`${endpoint}/v1/plugins`, {
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(`Register plugin failed: ${response.status} ${await response.text()}`);
17
+ throw new Error(`Create user failed: ${response.status} ${await response.text()}`);
27
18
  }
28
- const payload = (await response.json());
29
- return payload.plugin;
19
+ return (await response.json());
30
20
  }
31
- export async function createPairingSession(endpoint, options) {
32
- const response = await fetch(`${endpoint}/v1/pairing/sessions`, {
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: { "Content-Type": "application/json" },
28
+ headers: {
29
+ "Content-Type": "application/json",
30
+ "Authorization": `Bearer ${controllerToken}`,
31
+ },
35
32
  body: JSON.stringify({
36
- edge_id: options.edgeId,
37
- ttl_seconds: options.ttlSeconds ?? 600,
38
- requested_scopes: options.requestedScopes ?? DEFAULT_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
- export async function getPairingSession(endpoint, sessionId) {
51
- const response = await fetch(`${endpoint}/v1/pairing/sessions/${encodeURIComponent(sessionId)}`);
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(`Get pairing session failed: ${response.status}`);
58
+ throw new Error(`Register plugin failed: ${response.status} ${await response.text()}`);
54
59
  }
55
60
  const payload = (await response.json());
56
- return payload.session;
61
+ return { edge_id: payload.plugin.edge_id };
57
62
  }
58
- export async function waitForPairingClaim(endpoint, sessionId, timeoutMs = 600_000) {
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 session = await getPairingSession(endpoint, sessionId);
62
- if (session.status === "claimed" && session.credential_id) {
63
- return session;
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
- export async function executePairing(endpoint, edgeId, deviceName) {
76
- // Step 0: Register plugin first — server requires a known edge_id before pairing.
77
- // Uses edgeId as stable_identity so re-runs are idempotent.
78
- const plugin = await registerPlugin(endpoint, {
79
- stableIdentity: edgeId,
80
- displayName: deviceName ? `ZhiHand MCP — ${deviceName}` : "ZhiHand MCP Server",
81
- });
82
- const registeredEdgeId = plugin.edge_id;
83
- const session = await createPairingSession(endpoint, { edgeId: registeredEdgeId });
84
- // Save pending state
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.id,
87
- controllerToken: session.controller_token,
88
- edgeId: session.edge_id,
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
- // Display QR code and pairing URL
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
- // Wait for phone to scan
100
- const claimed = await waitForPairingClaim(endpoint, session.id);
101
- const credential = {
102
- credentialId: claimed.credential_id,
103
- controllerToken: claimed.controller_token ?? session.controller_token,
104
- endpoint,
105
- deviceName: deviceName ?? `device_${Date.now()}`,
106
- pairedAt: new Date().toISOString(),
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
- const name = deviceName ?? credential.deviceName;
109
- saveCredential(name, credential, true);
110
- // Update state
165
+ addUser(userRecord);
166
+ ensureZhiHandDir();
111
167
  saveState({
112
- sessionId: session.id,
113
- controllerToken: credential.controllerToken,
114
- edgeId: session.edge_id,
115
- credentialId: credential.credentialId,
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 { session: claimed, credential };
176
+ return { userRecord, deviceRecord };
120
177
  }
121
- export function formatPairingStatus(cred) {
122
- if (!cred)
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
- return [
125
- `Paired to: ${cred.deviceName ?? "unknown device"}`,
126
- `Endpoint: ${cred.endpoint}`,
127
- `Credential: ${cred.credentialId}`,
128
- `Paired at: ${cred.pairedAt ?? "unknown"}`,
129
- ].join("\n");
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 {};