@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.
@@ -1,14 +1,12 @@
1
1
  /**
2
- * Device Context static + dynamic device info fetched from control plane.
2
+ * Device profile extraction & formatting stateless.
3
3
  *
4
- * Static info (platform, model, screen size) is set once after pairing and
5
- * injected into MCP tool descriptions so the LLM always knows the device.
6
- *
7
- * Dynamic info (battery, network, BLE) is updated via SSE push and exposed
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 getCapabilities() {
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 = getProfileAgeMs();
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
- // ── Update from SSE event ─────────────────────────────────
174
- export function updateDeviceProfile(raw) {
175
- // SSE events may also wrap in { platform, attributes: {...} } — flatten if needed
176
- let profile;
177
- if (typeof raw.attributes === "object" && raw.attributes !== null) {
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 profile = { ...attrs, platform: wrapper.platform ?? attrs.platform };
214
- dbg(`[device] Raw profile keys: ${Object.keys(profile).join(", ")}`);
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
- // ── Build tool description with device info ───────────────
222
- export function buildControlToolDescription() {
223
- if (!loaded || staticCtx.platform === "unknown") {
224
- return "Control the connected mobile device. Supports click, swipe, type, scroll, open_app, back, home, and more. All coordinates use normalized ratios [0,1].";
225
- }
226
- const parts = [
227
- `Control a ${staticCtx.platform} device`,
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 `Take a screenshot of the ${staticCtx.platform} device (${staticCtx.model}, ${staticCtx.screenWidthPx}x${staticCtx.screenHeightPx}).`;
172
+ return raw;
270
173
  }
271
- // ── Format status for zhihand_status tool ─────────────────
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 function formatDeviceStatus() {
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
- // Curated summary (human-readable, stable schema)
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
- // Readiness — always present so LLM knows what works right now
330
- capabilities: getCapabilities(),
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
  }
@@ -1,4 +1,4 @@
1
- import type { DeviceCredential } from "./config.ts";
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
- export declare function executePairing(endpoint: string, edgeId: string, deviceName?: string): Promise<{
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
- credential: DeviceCredential;
43
+ record: DeviceRecord;
44
44
  }>;
45
- export declare function formatPairingStatus(cred: DeviceCredential | null): string;
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 { saveCredential, saveState } from "./config.js";
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
- 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.
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: deviceName ? `ZhiHand MCP — ${deviceName}` : "ZhiHand MCP Server",
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 credential = {
102
- credentialId: claimed.credential_id,
103
- controllerToken: claimed.controller_token ?? session.controller_token,
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
- deviceName: deviceName ?? `device_${Date.now()}`,
106
- pairedAt: new Date().toISOString(),
128
+ label,
129
+ platform,
130
+ paired_at: now,
131
+ last_seen_at: now,
107
132
  };
108
- const name = deviceName ?? credential.deviceName;
109
- saveCredential(name, credential, true);
110
- // Update state
133
+ addDevice(record, true);
134
+ ensureZhiHandDir();
111
135
  saveState({
112
136
  sessionId: session.id,
113
- controllerToken: credential.controllerToken,
137
+ controllerToken,
114
138
  edgeId: session.edge_id,
115
- credentialId: credential.credentialId,
139
+ credentialId,
116
140
  pairUrl: session.pair_url,
117
141
  status: "claimed",
118
142
  });
119
- return { session: claimed, credential };
143
+ return { session: claimed, record };
120
144
  }
121
- export function formatPairingStatus(cred) {
122
- if (!cred)
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 to: ${cred.deviceName ?? "unknown device"}`,
126
- `Endpoint: ${cred.endpoint}`,
127
- `Credential: ${cred.credentialId}`,
128
- `Paired at: ${cred.pairedAt ?? "unknown"}`,
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 {};