@zhihand/mcp 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,18 +31,46 @@ 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 loaded = false;
40
- export function getStaticContext() {
41
- return staticCtx;
42
- }
43
- export function getDynamicContext() {
44
- return dynamicCtx;
45
- }
46
- export function isDeviceProfileLoaded() {
47
- return loaded;
34
+ const PROFILE_STALE_THRESHOLD_MS = 60_000;
35
+ export function computeCapabilities(rawAttributes, profileReceivedAtMs) {
36
+ const a = rawAttributes;
37
+ const b = (k) => typeof a[k] === "boolean" ? a[k] : undefined;
38
+ const recordingActive = b("recording_active");
39
+ const hidConnected = b("hid_connected");
40
+ const hidBonded = b("hid_bonded");
41
+ const hidPairing = b("hid_pairing");
42
+ const hidSessionReady = b("hid_session_ready");
43
+ const liveSessionActive = b("live_session_active");
44
+ const pairedHostReady = b("paired_host_ready");
45
+ const screenSharingReady = recordingActive === true;
46
+ const hidReady = hidConnected === true && hidBonded === true && hidPairing !== true;
47
+ const liveReady = liveSessionActive === true && pairedHostReady === true;
48
+ const ageMs = profileReceivedAtMs === 0 ? Number.POSITIVE_INFINITY : Date.now() - profileReceivedAtMs;
49
+ const stale = ageMs > PROFILE_STALE_THRESHOLD_MS;
50
+ return {
51
+ screen_sharing: {
52
+ ready: screenSharingReady,
53
+ reason: screenSharingReady
54
+ ? "recording_active=true"
55
+ : `recording_active=${recordingActive ?? "unknown"} — phone is not screen-sharing; start sharing in the app to enable screenshots`,
56
+ },
57
+ hid: {
58
+ ready: hidReady,
59
+ reason: hidReady
60
+ ? `connected=true, bonded=true, session_ready=${hidSessionReady ?? "unknown"}`
61
+ : `connected=${hidConnected ?? "unknown"}, bonded=${hidBonded ?? "unknown"}, pairing=${hidPairing ?? "unknown"}, session_ready=${hidSessionReady ?? "unknown"} — connect the ZhiHand (BLE HID) to enable input`,
62
+ },
63
+ live_session: {
64
+ ready: liveReady,
65
+ reason: liveReady
66
+ ? `live_session_active=${liveSessionActive ?? "-"}, paired_host_ready=${pairedHostReady ?? "-"}`
67
+ : `live_session_active=${liveSessionActive ?? "unknown"}, paired_host_ready=${pairedHostReady ?? "unknown"}`,
68
+ },
69
+ profile: {
70
+ age_ms: Number.isFinite(ageMs) ? ageMs : -1,
71
+ stale,
72
+ },
73
+ };
48
74
  }
49
75
  // ── Extract helpers ───────────────────────────────────────
50
76
  function str(v, fallback) {
@@ -57,7 +83,6 @@ function bool(v, fallback) {
57
83
  return typeof v === "boolean" ? v : fallback;
58
84
  }
59
85
  export function extractStatic(profile) {
60
- // Build OS version string from platform + system_release + api_level
61
86
  const platform = str(profile.platform, DEFAULT_STATIC.platform);
62
87
  const sysRelease = str(profile.system_release, "");
63
88
  const apiLevel = typeof profile.api_level === "number" ? profile.api_level : null;
@@ -71,12 +96,9 @@ export function extractStatic(profile) {
71
96
  else {
72
97
  osVersion = sysRelease || DEFAULT_STATIC.osVersion;
73
98
  }
74
- // Screen size: Android uses display_width_px/display_height_px, iOS uses display_width_pixels/display_height_pixels
75
99
  const screenW = num(profile.display_width_px, num(profile.display_width_pixels, DEFAULT_STATIC.screenWidthPx));
76
100
  const screenH = num(profile.display_height_px, num(profile.display_height_pixels, DEFAULT_STATIC.screenHeightPx));
77
- // Density: Android uses density, iOS uses display_scale
78
101
  const density = num(profile.density, num(profile.display_scale, DEFAULT_STATIC.density));
79
- // Text direction: rtl is boolean
80
102
  const textDirection = profile.rtl === true ? "rtl" : "ltr";
81
103
  return {
82
104
  platform,
@@ -108,24 +130,11 @@ export function extractDynamic(profile) {
108
130
  fontScale: num(profile.font_scale, DEFAULT_DYNAMIC.fontScale),
109
131
  };
110
132
  }
111
- // ── Update from SSE event ─────────────────────────────────
112
- export function updateDeviceProfile(raw) {
113
- // SSE events may also wrap in { platform, attributes: {...} } — flatten if needed
114
- let profile;
115
- if (typeof raw.attributes === "object" && raw.attributes !== null) {
116
- const attrs = raw.attributes;
117
- profile = { ...attrs, platform: raw.platform ?? attrs.platform };
118
- }
119
- else {
120
- profile = raw;
121
- }
122
- staticCtx = extractStatic(profile);
123
- dynamicCtx = extractDynamic(profile);
124
- loaded = true;
125
- dbg(`[device] Profile updated: platform=${staticCtx.platform}, model=${staticCtx.model}, screen=${staticCtx.screenWidthPx}x${staticCtx.screenHeightPx}`);
126
- }
127
- // ── Fetch initial profile from API ────────────────────────
128
- 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) {
129
138
  const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/device-profile`;
130
139
  dbg(`[device] Fetching profile: GET ${url}`);
131
140
  try {
@@ -135,78 +144,187 @@ export async function fetchDeviceProfile(config) {
135
144
  });
136
145
  if (!response.ok) {
137
146
  dbg(`[device] Profile fetch failed: ${response.status} ${response.statusText}`);
138
- return;
147
+ return null;
139
148
  }
140
- const data = await response.json();
141
- // API returns { profile: { credential_id, platform, attributes: {...} } }
149
+ const data = (await response.json());
142
150
  const wrapper = (typeof data.profile === "object" && data.profile !== null)
143
151
  ? data.profile
144
152
  : data;
145
- // Merge top-level fields (platform, edge_id) with attributes for flat extraction
146
153
  const attrs = (typeof wrapper.attributes === "object" && wrapper.attributes !== null)
147
154
  ? wrapper.attributes
148
155
  : {};
149
- const profile = { ...attrs, platform: wrapper.platform ?? attrs.platform };
150
- dbg(`[device] Raw profile keys: ${Object.keys(profile).join(", ")}`);
151
- updateDeviceProfile(profile);
156
+ const rawAttrs = { ...attrs, platform: wrapper.platform ?? attrs.platform };
157
+ return { rawAttrs, receivedAtMs: Date.now() };
152
158
  }
153
159
  catch (err) {
154
160
  dbg(`[device] Profile fetch error: ${err.message}`);
161
+ return null;
162
+ }
163
+ }
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 };
171
+ }
172
+ return raw;
173
+ }
174
+ // ── Allowlist for zhihand_status raw attributes ───────────
175
+ const RAW_ATTRIBUTE_ALLOWLIST = [
176
+ "brand", "manufacturer", "model", "rom_family", "rom_version",
177
+ "system_release", "api_level", "app_version", "app_build",
178
+ "display_width_px", "display_height_px", "density", "density_dpi",
179
+ "screen_width_dp", "screen_height_dp", "smallest_width_dp",
180
+ "form_factor", "orientation", "touchscreen", "navigation_mode",
181
+ "locale", "language", "timezone", "rtl", "dark_mode", "font_scale",
182
+ "battery_level", "battery_state", "available_storage_mb",
183
+ "thermal_state", "low_ram_device",
184
+ "network_type",
185
+ "hid_connected", "hid_bonded", "hid_pairing", "hid_session_ready",
186
+ "live_session_active", "paired_host_ready", "recording_active",
187
+ "recording_archive_enabled", "app_in_foreground", "task_running",
188
+ "emergency_stop_armed", "firmware_update_in_progress",
189
+ "hardware_keyboard_present", "hard_keyboard_hidden",
190
+ "supports_keyboard_prompt_navigation",
191
+ ];
192
+ export function pickAllowlistedRawAttributes(rawAttributes) {
193
+ const out = {};
194
+ for (const k of RAW_ATTRIBUTE_ALLOWLIST) {
195
+ if (k in rawAttributes && rawAttributes[k] !== undefined) {
196
+ out[k] = rawAttributes[k];
197
+ }
198
+ }
199
+ return out;
200
+ }
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.";
155
209
  }
210
+ return "";
156
211
  }
157
- // ── Build tool description with device info ───────────────
158
- export function buildControlToolDescription() {
159
- if (!loaded || staticCtx.platform === "unknown") {
160
- return "Control the connected mobile device. Supports click, swipe, type, scroll, open_app, back, home, and more. All coordinates use normalized ratios [0,1].";
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].`;
161
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;
162
244
  const parts = [
163
- `Control a ${staticCtx.platform} device`,
164
- `(${staticCtx.model}, ${staticCtx.osVersion}`,
165
- `${staticCtx.screenWidthPx}x${staticCtx.screenHeightPx}`,
166
- `${staticCtx.formFactor}, ${staticCtx.locale})`,
245
+ `Control a ${ctx.platform} device`,
246
+ `(${ctx.model}, ${ctx.osVersion}`,
247
+ `${ctx.screenWidthPx}x${ctx.screenHeightPx}`,
248
+ `${ctx.formFactor}, ${ctx.locale})`,
167
249
  ];
168
250
  let desc = parts.join(", ") + ".";
169
251
  desc += " All coordinates use normalized ratios [0,1].";
170
- // Platform-specific open_app guidance
171
- if (staticCtx.platform === "android") {
172
- desc += " For open_app, use appPackage (e.g. 'com.tencent.mm'). Do NOT send bundleId or urlScheme.";
173
- }
174
- else if (staticCtx.platform === "ios") {
175
- desc += " For open_app, use bundleId (e.g. 'com.tencent.xin') or urlScheme (e.g. 'weixin://'). Do NOT send appPackage.";
176
- }
252
+ desc += singleDeviceOpenAppGuidance(ctx.platform);
177
253
  return desc;
178
254
  }
179
- export function buildSystemToolDescription() {
180
- if (!loaded || staticCtx.platform === "unknown") {
181
- 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.";
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;
182
278
  }
183
- const platform = staticCtx.platform;
279
+ if (!state || !state.profile || state.profile.platform === "unknown") {
280
+ return genericBase;
281
+ }
282
+ const platform = state.profile.platform;
184
283
  const parts = [
185
- `System navigation and media controls for ${platform} device (${staticCtx.model}).`,
284
+ `System navigation and media controls for ${platform} device (${state.profile.model}).`,
186
285
  ];
187
- // Navigation
188
286
  parts.push("Navigation: notification, recent, search (optional text query), switch_input.");
189
- if (platform === "ios") {
287
+ if (platform === "ios")
190
288
  parts.push("iOS: siri, control_center.");
191
- }
192
- else if (platform === "android") {
289
+ else if (platform === "android")
193
290
  parts.push("Android: open_browser, shortcut_help.");
194
- }
195
- // Media
196
291
  parts.push("Media: volume_up, volume_down, mute, play_pause, stop, next_track, prev_track, fast_forward, rewind.");
197
- // Hardware
198
292
  parts.push("Hardware: brightness_up, brightness_down, power.");
199
293
  return parts.join(" ");
200
294
  }
201
- export function buildScreenshotToolDescription() {
202
- if (!loaded || staticCtx.platform === "unknown") {
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") {
203
312
  return "Take a screenshot of the phone screen.";
204
313
  }
205
- return `Take a screenshot of the ${staticCtx.platform} device (${staticCtx.model}, ${staticCtx.screenWidthPx}x${staticCtx.screenHeightPx}).`;
314
+ const ctx = state.profile;
315
+ return `Take a screenshot of the ${ctx.platform} device (${ctx.model}, ${ctx.screenWidthPx}x${ctx.screenHeightPx}).`;
206
316
  }
207
317
  // ── Format status for zhihand_status tool ─────────────────
208
- export function formatDeviceStatus() {
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);
209
324
  return {
325
+ credential_id: state.credentialId,
326
+ label: state.label,
327
+ online: state.online,
210
328
  platform: staticCtx.platform,
211
329
  model: staticCtx.model,
212
330
  os_version: staticCtx.osVersion,
@@ -225,5 +343,7 @@ export function formatDeviceStatus() {
225
343
  storage_available_mb: dynamicCtx.availableStorageMb,
226
344
  thermal: dynamicCtx.thermalState ?? "normal",
227
345
  font_scale: dynamicCtx.fontScale,
346
+ capabilities: caps,
347
+ raw: pickAllowlistedRawAttributes(state.rawAttributes ?? {}),
228
348
  };
229
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 {};