@zhihand/mcp 0.29.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 +209 -185
- 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 +28 -19
- package/dist/core/device.js +155 -140
- 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 +3 -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 +17 -23
- 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
package/dist/core/device.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Device
|
|
2
|
+
* Device profile extraction & formatting — stateless.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* through the zhihand_status tool and device://profile resource.
|
|
4
|
+
* Per-device state (profile, raw attributes, timestamps) lives in the
|
|
5
|
+
* device registry (see ./registry.ts). This module exposes pure helpers
|
|
6
|
+
* to extract, classify, and format device data so the same logic can be
|
|
7
|
+
* applied to any number of devices.
|
|
9
8
|
*/
|
|
10
9
|
import { dbg } from "../daemon/logger.js";
|
|
11
|
-
// ── Default values ────────────────────────────────────────
|
|
12
10
|
const DEFAULT_STATIC = {
|
|
13
11
|
platform: "unknown",
|
|
14
12
|
model: "unknown",
|
|
@@ -33,36 +31,8 @@ const DEFAULT_DYNAMIC = {
|
|
|
33
31
|
availableStorageMb: -1,
|
|
34
32
|
fontScale: 1,
|
|
35
33
|
};
|
|
36
|
-
// ── Module state ──────────────────────────────────────────
|
|
37
|
-
let staticCtx = { ...DEFAULT_STATIC };
|
|
38
|
-
let dynamicCtx = { ...DEFAULT_DYNAMIC };
|
|
39
|
-
let rawAttributes = {};
|
|
40
|
-
// Local monotonic timestamp (Date.now()) captured when the profile was last
|
|
41
|
-
// updated. Used for age calculations — avoids distributed clock skew vs.
|
|
42
|
-
// reading server-side `updated_at`.
|
|
43
|
-
let profileReceivedAtMs = 0;
|
|
44
|
-
let loaded = false;
|
|
45
|
-
export function getStaticContext() {
|
|
46
|
-
return staticCtx;
|
|
47
|
-
}
|
|
48
|
-
export function getDynamicContext() {
|
|
49
|
-
return dynamicCtx;
|
|
50
|
-
}
|
|
51
|
-
export function getRawAttributes() {
|
|
52
|
-
return rawAttributes;
|
|
53
|
-
}
|
|
54
|
-
export function getProfileAgeMs() {
|
|
55
|
-
if (!loaded || profileReceivedAtMs === 0)
|
|
56
|
-
return Number.POSITIVE_INFINITY;
|
|
57
|
-
return Date.now() - profileReceivedAtMs;
|
|
58
|
-
}
|
|
59
|
-
export function isDeviceProfileLoaded() {
|
|
60
|
-
return loaded;
|
|
61
|
-
}
|
|
62
|
-
// Max age (ms) before the device profile is considered stale. Bounds to
|
|
63
|
-
// 60s: profile updates are pushed ~every 10–30s by the phone app.
|
|
64
34
|
const PROFILE_STALE_THRESHOLD_MS = 60_000;
|
|
65
|
-
export function
|
|
35
|
+
export function computeCapabilities(rawAttributes, profileReceivedAtMs) {
|
|
66
36
|
const a = rawAttributes;
|
|
67
37
|
const b = (k) => typeof a[k] === "boolean" ? a[k] : undefined;
|
|
68
38
|
const recordingActive = b("recording_active");
|
|
@@ -73,15 +43,9 @@ export function getCapabilities() {
|
|
|
73
43
|
const liveSessionActive = b("live_session_active");
|
|
74
44
|
const pairedHostReady = b("paired_host_ready");
|
|
75
45
|
const screenSharingReady = recordingActive === true;
|
|
76
|
-
// HID is "ready" when we have a connected bonded peripheral and aren't
|
|
77
|
-
// mid-pairing. `hid_session_ready` is advisory — some devices keep it
|
|
78
|
-
// false while HID still works, so we don't require it.
|
|
79
46
|
const hidReady = hidConnected === true && hidBonded === true && hidPairing !== true;
|
|
80
|
-
// Strict AND: a "ready" live session requires both an active socket
|
|
81
|
-
// and a paired host. Using OR here would mask a dead session when a
|
|
82
|
-
// host is still paired from a previous run.
|
|
83
47
|
const liveReady = liveSessionActive === true && pairedHostReady === true;
|
|
84
|
-
const ageMs =
|
|
48
|
+
const ageMs = profileReceivedAtMs === 0 ? Number.POSITIVE_INFINITY : Date.now() - profileReceivedAtMs;
|
|
85
49
|
const stale = ageMs > PROFILE_STALE_THRESHOLD_MS;
|
|
86
50
|
return {
|
|
87
51
|
screen_sharing: {
|
|
@@ -119,7 +83,6 @@ function bool(v, fallback) {
|
|
|
119
83
|
return typeof v === "boolean" ? v : fallback;
|
|
120
84
|
}
|
|
121
85
|
export function extractStatic(profile) {
|
|
122
|
-
// Build OS version string from platform + system_release + api_level
|
|
123
86
|
const platform = str(profile.platform, DEFAULT_STATIC.platform);
|
|
124
87
|
const sysRelease = str(profile.system_release, "");
|
|
125
88
|
const apiLevel = typeof profile.api_level === "number" ? profile.api_level : null;
|
|
@@ -133,12 +96,9 @@ export function extractStatic(profile) {
|
|
|
133
96
|
else {
|
|
134
97
|
osVersion = sysRelease || DEFAULT_STATIC.osVersion;
|
|
135
98
|
}
|
|
136
|
-
// Screen size: Android uses display_width_px/display_height_px, iOS uses display_width_pixels/display_height_pixels
|
|
137
99
|
const screenW = num(profile.display_width_px, num(profile.display_width_pixels, DEFAULT_STATIC.screenWidthPx));
|
|
138
100
|
const screenH = num(profile.display_height_px, num(profile.display_height_pixels, DEFAULT_STATIC.screenHeightPx));
|
|
139
|
-
// Density: Android uses density, iOS uses display_scale
|
|
140
101
|
const density = num(profile.density, num(profile.display_scale, DEFAULT_STATIC.density));
|
|
141
|
-
// Text direction: rtl is boolean
|
|
142
102
|
const textDirection = profile.rtl === true ? "rtl" : "ltr";
|
|
143
103
|
return {
|
|
144
104
|
platform,
|
|
@@ -170,26 +130,11 @@ export function extractDynamic(profile) {
|
|
|
170
130
|
fontScale: num(profile.font_scale, DEFAULT_DYNAMIC.fontScale),
|
|
171
131
|
};
|
|
172
132
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const attrs = raw.attributes;
|
|
179
|
-
profile = { ...attrs, platform: raw.platform ?? attrs.platform };
|
|
180
|
-
}
|
|
181
|
-
else {
|
|
182
|
-
profile = raw;
|
|
183
|
-
}
|
|
184
|
-
staticCtx = extractStatic(profile);
|
|
185
|
-
dynamicCtx = extractDynamic(profile);
|
|
186
|
-
rawAttributes = profile;
|
|
187
|
-
profileReceivedAtMs = Date.now();
|
|
188
|
-
loaded = true;
|
|
189
|
-
dbg(`[device] Profile updated: platform=${staticCtx.platform}, model=${staticCtx.model}, screen=${staticCtx.screenWidthPx}x${staticCtx.screenHeightPx}`);
|
|
190
|
-
}
|
|
191
|
-
// ── Fetch initial profile from API ────────────────────────
|
|
192
|
-
export async function fetchDeviceProfile(config) {
|
|
133
|
+
/**
|
|
134
|
+
* Fetch and normalize the device profile from the control plane once.
|
|
135
|
+
* Returns null on failure (HTTP or network).
|
|
136
|
+
*/
|
|
137
|
+
export async function fetchDeviceProfileOnce(config) {
|
|
193
138
|
const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/device-profile`;
|
|
194
139
|
dbg(`[device] Fetching profile: GET ${url}`);
|
|
195
140
|
try {
|
|
@@ -199,96 +144,44 @@ export async function fetchDeviceProfile(config) {
|
|
|
199
144
|
});
|
|
200
145
|
if (!response.ok) {
|
|
201
146
|
dbg(`[device] Profile fetch failed: ${response.status} ${response.statusText}`);
|
|
202
|
-
return;
|
|
147
|
+
return null;
|
|
203
148
|
}
|
|
204
|
-
const data = await response.json();
|
|
205
|
-
// API returns { profile: { credential_id, platform, attributes: {...} } }
|
|
149
|
+
const data = (await response.json());
|
|
206
150
|
const wrapper = (typeof data.profile === "object" && data.profile !== null)
|
|
207
151
|
? data.profile
|
|
208
152
|
: data;
|
|
209
|
-
// Merge top-level fields (platform, edge_id) with attributes for flat extraction
|
|
210
153
|
const attrs = (typeof wrapper.attributes === "object" && wrapper.attributes !== null)
|
|
211
154
|
? wrapper.attributes
|
|
212
155
|
: {};
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
updateDeviceProfile(profile);
|
|
156
|
+
const rawAttrs = { ...attrs, platform: wrapper.platform ?? attrs.platform };
|
|
157
|
+
return { rawAttrs, receivedAtMs: Date.now() };
|
|
216
158
|
}
|
|
217
159
|
catch (err) {
|
|
218
160
|
dbg(`[device] Profile fetch error: ${err.message}`);
|
|
161
|
+
return null;
|
|
219
162
|
}
|
|
220
163
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
`(${staticCtx.model}, ${staticCtx.osVersion}`,
|
|
229
|
-
`${staticCtx.screenWidthPx}x${staticCtx.screenHeightPx}`,
|
|
230
|
-
`${staticCtx.formFactor}, ${staticCtx.locale})`,
|
|
231
|
-
];
|
|
232
|
-
let desc = parts.join(", ") + ".";
|
|
233
|
-
desc += " All coordinates use normalized ratios [0,1].";
|
|
234
|
-
// Platform-specific open_app guidance
|
|
235
|
-
if (staticCtx.platform === "android") {
|
|
236
|
-
desc += " For open_app, use appPackage (e.g. 'com.tencent.mm'). Do NOT send bundleId or urlScheme.";
|
|
237
|
-
}
|
|
238
|
-
else if (staticCtx.platform === "ios") {
|
|
239
|
-
desc += " For open_app, use bundleId (e.g. 'com.tencent.xin') or urlScheme (e.g. 'weixin://'). Do NOT send appPackage.";
|
|
240
|
-
}
|
|
241
|
-
return desc;
|
|
242
|
-
}
|
|
243
|
-
export function buildSystemToolDescription() {
|
|
244
|
-
if (!loaded || staticCtx.platform === "unknown") {
|
|
245
|
-
return "System navigation and media controls. Actions: notification, recent, search, switch_input, siri (iOS), control_center (iOS), open_browser (Android), shortcut_help (Android), volume_up/down, mute, play_pause, stop, next/prev_track, fast_forward, rewind, brightness_up/down, power.";
|
|
246
|
-
}
|
|
247
|
-
const platform = staticCtx.platform;
|
|
248
|
-
const parts = [
|
|
249
|
-
`System navigation and media controls for ${platform} device (${staticCtx.model}).`,
|
|
250
|
-
];
|
|
251
|
-
// Navigation
|
|
252
|
-
parts.push("Navigation: notification, recent, search (optional text query), switch_input.");
|
|
253
|
-
if (platform === "ios") {
|
|
254
|
-
parts.push("iOS: siri, control_center.");
|
|
255
|
-
}
|
|
256
|
-
else if (platform === "android") {
|
|
257
|
-
parts.push("Android: open_browser, shortcut_help.");
|
|
258
|
-
}
|
|
259
|
-
// Media
|
|
260
|
-
parts.push("Media: volume_up, volume_down, mute, play_pause, stop, next_track, prev_track, fast_forward, rewind.");
|
|
261
|
-
// Hardware
|
|
262
|
-
parts.push("Hardware: brightness_up, brightness_down, power.");
|
|
263
|
-
return parts.join(" ");
|
|
264
|
-
}
|
|
265
|
-
export function buildScreenshotToolDescription() {
|
|
266
|
-
if (!loaded || staticCtx.platform === "unknown") {
|
|
267
|
-
return "Take a screenshot of the phone screen.";
|
|
164
|
+
/**
|
|
165
|
+
* Normalize an SSE device_profile.updated payload into rawAttrs shape.
|
|
166
|
+
*/
|
|
167
|
+
export function normalizeProfilePayload(raw) {
|
|
168
|
+
if (typeof raw.attributes === "object" && raw.attributes !== null) {
|
|
169
|
+
const attrs = raw.attributes;
|
|
170
|
+
return { ...attrs, platform: raw.platform ?? attrs.platform };
|
|
268
171
|
}
|
|
269
|
-
return
|
|
172
|
+
return raw;
|
|
270
173
|
}
|
|
271
|
-
// ──
|
|
272
|
-
// Allowlist of raw attribute keys exposed via zhihand_status.
|
|
273
|
-
// Keeps context window manageable and blocks sensitive/internal fields
|
|
274
|
-
// (e.g. credential_status, full_access_*). Wire-format names are kept
|
|
275
|
-
// verbatim so the LLM can cite them consistently with the server logs.
|
|
174
|
+
// ── Allowlist for zhihand_status raw attributes ───────────
|
|
276
175
|
const RAW_ATTRIBUTE_ALLOWLIST = [
|
|
277
|
-
// Device identity
|
|
278
176
|
"brand", "manufacturer", "model", "rom_family", "rom_version",
|
|
279
177
|
"system_release", "api_level", "app_version", "app_build",
|
|
280
|
-
// Display / form factor
|
|
281
178
|
"display_width_px", "display_height_px", "density", "density_dpi",
|
|
282
179
|
"screen_width_dp", "screen_height_dp", "smallest_width_dp",
|
|
283
180
|
"form_factor", "orientation", "touchscreen", "navigation_mode",
|
|
284
|
-
// Locale / UI
|
|
285
181
|
"locale", "language", "timezone", "rtl", "dark_mode", "font_scale",
|
|
286
|
-
// Power / thermal / storage
|
|
287
182
|
"battery_level", "battery_state", "available_storage_mb",
|
|
288
183
|
"thermal_state", "low_ram_device",
|
|
289
|
-
// Network
|
|
290
184
|
"network_type",
|
|
291
|
-
// Capability / readiness signals (most important for LLM diagnosis)
|
|
292
185
|
"hid_connected", "hid_bonded", "hid_pairing", "hid_session_ready",
|
|
293
186
|
"live_session_active", "paired_host_ready", "recording_active",
|
|
294
187
|
"recording_archive_enabled", "app_in_foreground", "task_running",
|
|
@@ -296,7 +189,7 @@ const RAW_ATTRIBUTE_ALLOWLIST = [
|
|
|
296
189
|
"hardware_keyboard_present", "hard_keyboard_hidden",
|
|
297
190
|
"supports_keyboard_prompt_navigation",
|
|
298
191
|
];
|
|
299
|
-
function pickAllowlistedRawAttributes() {
|
|
192
|
+
export function pickAllowlistedRawAttributes(rawAttributes) {
|
|
300
193
|
const out = {};
|
|
301
194
|
for (const k of RAW_ATTRIBUTE_ALLOWLIST) {
|
|
302
195
|
if (k in rawAttributes && rawAttributes[k] !== undefined) {
|
|
@@ -305,9 +198,133 @@ function pickAllowlistedRawAttributes() {
|
|
|
305
198
|
}
|
|
306
199
|
return out;
|
|
307
200
|
}
|
|
308
|
-
export
|
|
201
|
+
// ── Default static/dynamic export for empty-state rendering ──
|
|
202
|
+
export { DEFAULT_STATIC, DEFAULT_DYNAMIC };
|
|
203
|
+
function singleDeviceOpenAppGuidance(platform) {
|
|
204
|
+
if (platform === "android") {
|
|
205
|
+
return " For open_app, use appPackage (e.g. 'com.tencent.mm'). Do NOT send bundleId or urlScheme.";
|
|
206
|
+
}
|
|
207
|
+
if (platform === "ios") {
|
|
208
|
+
return " For open_app, use bundleId (e.g. 'com.tencent.xin') or urlScheme (e.g. 'weixin://'). Do NOT send appPackage.";
|
|
209
|
+
}
|
|
210
|
+
return "";
|
|
211
|
+
}
|
|
212
|
+
export function buildControlToolDescription(state, onlineStates) {
|
|
213
|
+
const baseGeneric = "Control the connected mobile device. Supports click, swipe, type, scroll, open_app, back, home, and more. All coordinates use normalized ratios [0,1]. Call zhihand_list_devices to see online devices, then pass device_id.";
|
|
214
|
+
if (onlineStates) {
|
|
215
|
+
if (onlineStates.length === 0) {
|
|
216
|
+
return "No devices online — ask user to open the ZhiHand app. " + baseGeneric;
|
|
217
|
+
}
|
|
218
|
+
if (onlineStates.length === 1) {
|
|
219
|
+
const s = onlineStates[0];
|
|
220
|
+
const ctx = s.profile;
|
|
221
|
+
if (!ctx || ctx.platform === "unknown") {
|
|
222
|
+
return `Control the connected mobile device (${s.label}). device_id is optional (single device online). All coordinates use normalized ratios [0,1].`;
|
|
223
|
+
}
|
|
224
|
+
const parts = [
|
|
225
|
+
`Control a ${ctx.platform} device`,
|
|
226
|
+
`(${ctx.model}, ${ctx.osVersion}`,
|
|
227
|
+
`${ctx.screenWidthPx}x${ctx.screenHeightPx}`,
|
|
228
|
+
`${ctx.formFactor}, ${ctx.locale})`,
|
|
229
|
+
];
|
|
230
|
+
let desc = parts.join(", ") + `. device_id is optional (single device online: ${s.credentialId}).`;
|
|
231
|
+
desc += " All coordinates use normalized ratios [0,1].";
|
|
232
|
+
desc += singleDeviceOpenAppGuidance(ctx.platform);
|
|
233
|
+
return desc;
|
|
234
|
+
}
|
|
235
|
+
// 2+ devices
|
|
236
|
+
const ids = onlineStates.map((d) => `${d.credentialId} (${d.label}, ${d.platform})`).join("; ");
|
|
237
|
+
return `Control a mobile device. device_id is REQUIRED (multiple online). Online devices: ${ids}. Call zhihand_list_devices first. All coordinates use normalized ratios [0,1].`;
|
|
238
|
+
}
|
|
239
|
+
// No explicit onlineStates: describe single state or generic
|
|
240
|
+
if (!state || !state.profile || state.profile.platform === "unknown") {
|
|
241
|
+
return baseGeneric;
|
|
242
|
+
}
|
|
243
|
+
const ctx = state.profile;
|
|
244
|
+
const parts = [
|
|
245
|
+
`Control a ${ctx.platform} device`,
|
|
246
|
+
`(${ctx.model}, ${ctx.osVersion}`,
|
|
247
|
+
`${ctx.screenWidthPx}x${ctx.screenHeightPx}`,
|
|
248
|
+
`${ctx.formFactor}, ${ctx.locale})`,
|
|
249
|
+
];
|
|
250
|
+
let desc = parts.join(", ") + ".";
|
|
251
|
+
desc += " All coordinates use normalized ratios [0,1].";
|
|
252
|
+
desc += singleDeviceOpenAppGuidance(ctx.platform);
|
|
253
|
+
return desc;
|
|
254
|
+
}
|
|
255
|
+
export function buildSystemToolDescription(state, onlineStates) {
|
|
256
|
+
const genericBase = "System navigation and media controls. Actions: notification, recent, search, switch_input, siri (iOS), control_center (iOS), open_browser (Android), shortcut_help (Android), volume_up/down, mute, play_pause, stop, next/prev_track, fast_forward, rewind, brightness_up/down, power.";
|
|
257
|
+
if (onlineStates) {
|
|
258
|
+
if (onlineStates.length === 0) {
|
|
259
|
+
return "No devices online — ask user to open the ZhiHand app. " + genericBase;
|
|
260
|
+
}
|
|
261
|
+
if (onlineStates.length === 1) {
|
|
262
|
+
const s = onlineStates[0];
|
|
263
|
+
const platform = s.profile?.platform ?? s.platform;
|
|
264
|
+
const parts = [
|
|
265
|
+
`System navigation and media controls for ${platform} device (${s.profile?.model ?? s.label}). device_id is optional (single device online).`,
|
|
266
|
+
];
|
|
267
|
+
parts.push("Navigation: notification, recent, search (optional text query), switch_input.");
|
|
268
|
+
if (platform === "ios")
|
|
269
|
+
parts.push("iOS: siri, control_center.");
|
|
270
|
+
else if (platform === "android")
|
|
271
|
+
parts.push("Android: open_browser, shortcut_help.");
|
|
272
|
+
parts.push("Media: volume_up, volume_down, mute, play_pause, stop, next_track, prev_track, fast_forward, rewind.");
|
|
273
|
+
parts.push("Hardware: brightness_up, brightness_down, power.");
|
|
274
|
+
return parts.join(" ");
|
|
275
|
+
}
|
|
276
|
+
const ids = onlineStates.map((d) => `${d.credentialId} (${d.label}, ${d.platform})`).join("; ");
|
|
277
|
+
return `System navigation and media controls for mobile device. device_id is REQUIRED (multiple online). Online: ${ids}. ` + genericBase;
|
|
278
|
+
}
|
|
279
|
+
if (!state || !state.profile || state.profile.platform === "unknown") {
|
|
280
|
+
return genericBase;
|
|
281
|
+
}
|
|
282
|
+
const platform = state.profile.platform;
|
|
283
|
+
const parts = [
|
|
284
|
+
`System navigation and media controls for ${platform} device (${state.profile.model}).`,
|
|
285
|
+
];
|
|
286
|
+
parts.push("Navigation: notification, recent, search (optional text query), switch_input.");
|
|
287
|
+
if (platform === "ios")
|
|
288
|
+
parts.push("iOS: siri, control_center.");
|
|
289
|
+
else if (platform === "android")
|
|
290
|
+
parts.push("Android: open_browser, shortcut_help.");
|
|
291
|
+
parts.push("Media: volume_up, volume_down, mute, play_pause, stop, next_track, prev_track, fast_forward, rewind.");
|
|
292
|
+
parts.push("Hardware: brightness_up, brightness_down, power.");
|
|
293
|
+
return parts.join(" ");
|
|
294
|
+
}
|
|
295
|
+
export function buildScreenshotToolDescription(state, onlineStates) {
|
|
296
|
+
if (onlineStates) {
|
|
297
|
+
if (onlineStates.length === 0) {
|
|
298
|
+
return "Take a screenshot of the phone screen. No devices online — ask user to open the ZhiHand app.";
|
|
299
|
+
}
|
|
300
|
+
if (onlineStates.length === 1) {
|
|
301
|
+
const s = onlineStates[0];
|
|
302
|
+
const ctx = s.profile;
|
|
303
|
+
if (!ctx || ctx.platform === "unknown") {
|
|
304
|
+
return `Take a screenshot of the phone screen (${s.label}). device_id is optional (single device online).`;
|
|
305
|
+
}
|
|
306
|
+
return `Take a screenshot of the ${ctx.platform} device (${ctx.model}, ${ctx.screenWidthPx}x${ctx.screenHeightPx}). device_id is optional (single device online).`;
|
|
307
|
+
}
|
|
308
|
+
const ids = onlineStates.map((d) => `${d.credentialId} (${d.label})`).join("; ");
|
|
309
|
+
return `Take a screenshot of a mobile device. device_id is REQUIRED (multiple online). Online: ${ids}.`;
|
|
310
|
+
}
|
|
311
|
+
if (!state || !state.profile || state.profile.platform === "unknown") {
|
|
312
|
+
return "Take a screenshot of the phone screen.";
|
|
313
|
+
}
|
|
314
|
+
const ctx = state.profile;
|
|
315
|
+
return `Take a screenshot of the ${ctx.platform} device (${ctx.model}, ${ctx.screenWidthPx}x${ctx.screenHeightPx}).`;
|
|
316
|
+
}
|
|
317
|
+
// ── Format status for zhihand_status tool ─────────────────
|
|
318
|
+
export function formatDeviceStatus(state) {
|
|
319
|
+
const staticCtx = state.profile ?? DEFAULT_STATIC;
|
|
320
|
+
const dynamicCtx = state.rawAttributes
|
|
321
|
+
? extractDynamic(state.rawAttributes)
|
|
322
|
+
: DEFAULT_DYNAMIC;
|
|
323
|
+
const caps = state.capabilities ?? computeCapabilities(state.rawAttributes ?? {}, state.profileReceivedAtMs);
|
|
309
324
|
return {
|
|
310
|
-
|
|
325
|
+
credential_id: state.credentialId,
|
|
326
|
+
label: state.label,
|
|
327
|
+
online: state.online,
|
|
311
328
|
platform: staticCtx.platform,
|
|
312
329
|
model: staticCtx.model,
|
|
313
330
|
os_version: staticCtx.osVersion,
|
|
@@ -326,9 +343,7 @@ export function formatDeviceStatus() {
|
|
|
326
343
|
storage_available_mb: dynamicCtx.availableStorageMb,
|
|
327
344
|
thermal: dynamicCtx.thermalState ?? "normal",
|
|
328
345
|
font_scale: dynamicCtx.fontScale,
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
// Full (allowlisted) attributes from the device — wire-format names
|
|
332
|
-
raw: pickAllowlistedRawAttributes(),
|
|
346
|
+
capabilities: caps,
|
|
347
|
+
raw: pickAllowlistedRawAttributes(state.rawAttributes ?? {}),
|
|
333
348
|
};
|
|
334
349
|
}
|
package/dist/core/pair.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { DeviceRecord } from "./config.ts";
|
|
2
2
|
export interface PluginRecord {
|
|
3
3
|
id: string;
|
|
4
4
|
edge_id: string;
|
|
@@ -24,11 +24,6 @@ export interface CreatePairingOptions {
|
|
|
24
24
|
ttlSeconds?: number;
|
|
25
25
|
requestedScopes?: string[];
|
|
26
26
|
}
|
|
27
|
-
/**
|
|
28
|
-
* Register this MCP instance as a plugin with the server.
|
|
29
|
-
* Server requires a registered plugin (edge_id) before pairing can begin.
|
|
30
|
-
* Idempotent — re-registering with the same stable_identity returns the existing plugin.
|
|
31
|
-
*/
|
|
32
27
|
export declare function registerPlugin(endpoint: string, options: {
|
|
33
28
|
stableIdentity: string;
|
|
34
29
|
displayName?: string;
|
|
@@ -38,8 +33,13 @@ export declare function createPairingSession(endpoint: string, options: CreatePa
|
|
|
38
33
|
export declare function getPairingSession(endpoint: string, sessionId: string): Promise<PairingSession>;
|
|
39
34
|
export declare function waitForPairingClaim(endpoint: string, sessionId: string, timeoutMs?: number): Promise<PairingSession>;
|
|
40
35
|
export declare function renderPairingQRCode(url: string): Promise<string>;
|
|
41
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Drive the full interactive pairing flow. Saves a new device record into the
|
|
38
|
+
* v2 config on success. Label defaults to the device model (fetched post-claim)
|
|
39
|
+
* and falls back to the supplied preferredLabel or timestamp.
|
|
40
|
+
*/
|
|
41
|
+
export declare function executePairing(endpoint: string, edgeId: string, preferredLabel?: string): Promise<{
|
|
42
42
|
session: PairingSession;
|
|
43
|
-
|
|
43
|
+
record: DeviceRecord;
|
|
44
44
|
}>;
|
|
45
|
-
export declare function formatPairingStatus(
|
|
45
|
+
export declare function formatPairingStatus(record: DeviceRecord | null): string;
|
package/dist/core/pair.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import QRCode from "qrcode";
|
|
2
|
-
import {
|
|
2
|
+
import { addDevice, ensureZhiHandDir, saveState } from "./config.js";
|
|
3
|
+
import { fetchDeviceProfileOnce, extractStatic } from "./device.js";
|
|
3
4
|
const DEFAULT_SCOPES = [
|
|
4
5
|
"observe",
|
|
5
6
|
"session.control",
|
|
@@ -7,11 +8,6 @@ const DEFAULT_SCOPES = [
|
|
|
7
8
|
"screen.capture",
|
|
8
9
|
"ble.control",
|
|
9
10
|
];
|
|
10
|
-
/**
|
|
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.
|
|
14
|
-
*/
|
|
15
11
|
export async function registerPlugin(endpoint, options) {
|
|
16
12
|
const response = await fetch(`${endpoint}/v1/plugins`, {
|
|
17
13
|
method: "POST",
|
|
@@ -72,16 +68,18 @@ export async function waitForPairingClaim(endpoint, sessionId, timeoutMs = 600_0
|
|
|
72
68
|
export async function renderPairingQRCode(url) {
|
|
73
69
|
return QRCode.toString(url, { type: "utf8", margin: 2 });
|
|
74
70
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Drive the full interactive pairing flow. Saves a new device record into the
|
|
73
|
+
* v2 config on success. Label defaults to the device model (fetched post-claim)
|
|
74
|
+
* and falls back to the supplied preferredLabel or timestamp.
|
|
75
|
+
*/
|
|
76
|
+
export async function executePairing(endpoint, edgeId, preferredLabel) {
|
|
78
77
|
const plugin = await registerPlugin(endpoint, {
|
|
79
78
|
stableIdentity: edgeId,
|
|
80
|
-
displayName:
|
|
79
|
+
displayName: preferredLabel ? `ZhiHand MCP — ${preferredLabel}` : "ZhiHand MCP Server",
|
|
81
80
|
});
|
|
82
81
|
const registeredEdgeId = plugin.edge_id;
|
|
83
82
|
const session = await createPairingSession(endpoint, { edgeId: registeredEdgeId });
|
|
84
|
-
// Save pending state
|
|
85
83
|
saveState({
|
|
86
84
|
sessionId: session.id,
|
|
87
85
|
controllerToken: session.controller_token,
|
|
@@ -90,41 +88,67 @@ export async function executePairing(endpoint, edgeId, deviceName) {
|
|
|
90
88
|
status: "pending",
|
|
91
89
|
expiresAt: session.expires_at,
|
|
92
90
|
});
|
|
93
|
-
// Display QR code and pairing URL
|
|
94
91
|
const qr = await renderPairingQRCode(session.pair_url);
|
|
95
92
|
console.log(qr);
|
|
96
93
|
console.log(`Open this URL on your phone to pair:\n ${session.pair_url}\n`);
|
|
97
94
|
console.log(`Expires at: ${session.expires_at}`);
|
|
98
95
|
console.log("Waiting for phone to scan...\n");
|
|
99
|
-
// Wait for phone to scan
|
|
100
96
|
const claimed = await waitForPairingClaim(endpoint, session.id);
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
97
|
+
const credentialId = claimed.credential_id;
|
|
98
|
+
const controllerToken = claimed.controller_token ?? session.controller_token;
|
|
99
|
+
const runtimeCfg = {
|
|
100
|
+
controlPlaneEndpoint: endpoint,
|
|
101
|
+
credentialId,
|
|
102
|
+
controllerToken,
|
|
103
|
+
timeoutMs: 10_000,
|
|
104
|
+
};
|
|
105
|
+
// Try to fetch profile to infer label/platform
|
|
106
|
+
let label = preferredLabel ?? "";
|
|
107
|
+
let platform = "unknown";
|
|
108
|
+
try {
|
|
109
|
+
const fetched = await fetchDeviceProfileOnce(runtimeCfg);
|
|
110
|
+
if (fetched) {
|
|
111
|
+
const st = extractStatic(fetched.rawAttrs);
|
|
112
|
+
if (!label)
|
|
113
|
+
label = st.model && st.model !== "unknown" ? st.model : "";
|
|
114
|
+
if (st.platform === "ios" || st.platform === "android")
|
|
115
|
+
platform = st.platform;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// fall through
|
|
120
|
+
}
|
|
121
|
+
if (!label)
|
|
122
|
+
label = `device-${Date.now().toString(36)}`;
|
|
123
|
+
const now = new Date().toISOString();
|
|
124
|
+
const record = {
|
|
125
|
+
credential_id: credentialId,
|
|
126
|
+
controller_token: controllerToken,
|
|
104
127
|
endpoint,
|
|
105
|
-
|
|
106
|
-
|
|
128
|
+
label,
|
|
129
|
+
platform,
|
|
130
|
+
paired_at: now,
|
|
131
|
+
last_seen_at: now,
|
|
107
132
|
};
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
// Update state
|
|
133
|
+
addDevice(record, true);
|
|
134
|
+
ensureZhiHandDir();
|
|
111
135
|
saveState({
|
|
112
136
|
sessionId: session.id,
|
|
113
|
-
controllerToken
|
|
137
|
+
controllerToken,
|
|
114
138
|
edgeId: session.edge_id,
|
|
115
|
-
credentialId
|
|
139
|
+
credentialId,
|
|
116
140
|
pairUrl: session.pair_url,
|
|
117
141
|
status: "claimed",
|
|
118
142
|
});
|
|
119
|
-
return { session: claimed,
|
|
143
|
+
return { session: claimed, record };
|
|
120
144
|
}
|
|
121
|
-
export function formatPairingStatus(
|
|
122
|
-
if (!
|
|
145
|
+
export function formatPairingStatus(record) {
|
|
146
|
+
if (!record)
|
|
123
147
|
return "Not paired. Run 'zhihand pair' to connect a device.";
|
|
124
148
|
return [
|
|
125
|
-
`Paired
|
|
126
|
-
`
|
|
127
|
-
`
|
|
128
|
-
`Paired at: ${
|
|
149
|
+
`Paired: ${record.label} (${record.platform})`,
|
|
150
|
+
`Credential: ${record.credential_id}`,
|
|
151
|
+
`Endpoint: ${record.endpoint}`,
|
|
152
|
+
`Paired at: ${record.paired_at}`,
|
|
129
153
|
].join("\n");
|
|
130
154
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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 { type DeviceRecord, type ZhiHandRuntimeConfig } from "./config.ts";
|
|
10
|
+
import { type StaticContext, type Capabilities } from "./device.ts";
|
|
11
|
+
export interface DeviceState {
|
|
12
|
+
credentialId: string;
|
|
13
|
+
label: string;
|
|
14
|
+
platform: "ios" | "android" | "unknown";
|
|
15
|
+
online: boolean;
|
|
16
|
+
lastSeenAtMs: number;
|
|
17
|
+
profile: StaticContext | null;
|
|
18
|
+
capabilities: Capabilities | null;
|
|
19
|
+
profileReceivedAtMs: number;
|
|
20
|
+
rawAttributes: Record<string, unknown>;
|
|
21
|
+
sseController: AbortController | null;
|
|
22
|
+
sseConnected: boolean;
|
|
23
|
+
heartbeatTimer: ReturnType<typeof setInterval> | null;
|
|
24
|
+
record: DeviceRecord;
|
|
25
|
+
}
|
|
26
|
+
type ListChangedCb = () => void;
|
|
27
|
+
declare class Registry {
|
|
28
|
+
private devices;
|
|
29
|
+
private listChangedSubs;
|
|
30
|
+
private debounceTimer;
|
|
31
|
+
private lastOnlineSet;
|
|
32
|
+
private initialized;
|
|
33
|
+
get(credentialId: string): DeviceState | null;
|
|
34
|
+
list(): DeviceState[];
|
|
35
|
+
listOnline(): DeviceState[];
|
|
36
|
+
/**
|
|
37
|
+
* Priority:
|
|
38
|
+
* 1. If the user has explicitly set a default via `zhihand default <id>`
|
|
39
|
+
* AND that device is online → return it. Honoring an explicit user
|
|
40
|
+
* preference is the least-surprising UX.
|
|
41
|
+
* 2. Otherwise → most-recently-active online device (online[0] is sorted
|
|
42
|
+
* desc by lastSeenAtMs).
|
|
43
|
+
* 3. No online devices → null.
|
|
44
|
+
*/
|
|
45
|
+
resolveDefault(): DeviceState | null;
|
|
46
|
+
toRuntimeConfig(state: DeviceState): ZhiHandRuntimeConfig;
|
|
47
|
+
subscribe(cb: ListChangedCb): () => void;
|
|
48
|
+
private computeOnlineSet;
|
|
49
|
+
private setsEqual;
|
|
50
|
+
private scheduleListChanged;
|
|
51
|
+
private updateOnlineFlag;
|
|
52
|
+
private touchLastSeen;
|
|
53
|
+
private refreshProfile;
|
|
54
|
+
private startHeartbeat;
|
|
55
|
+
private stopHeartbeat;
|
|
56
|
+
private startSSE;
|
|
57
|
+
private stopSSE;
|
|
58
|
+
private makeState;
|
|
59
|
+
init(): Promise<void>;
|
|
60
|
+
addDevice(record: DeviceRecord): Promise<void>;
|
|
61
|
+
removeDevice(credentialId: string): void;
|
|
62
|
+
renameDevice(credentialId: string, label: string): void;
|
|
63
|
+
setDefault(credentialId: string): void;
|
|
64
|
+
shutdown(): void;
|
|
65
|
+
}
|
|
66
|
+
export declare const registry: Registry;
|
|
67
|
+
export {};
|