@zhihand/mcp 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/core/pair.js CHANGED
@@ -1,62 +1,78 @@
1
1
  import QRCode from "qrcode";
2
- import { addDevice, ensureZhiHandDir, saveState } from "./config.js";
2
+ import { addUser, addDeviceToUser, ensureZhiHandDir, saveState, resolveDefaultEndpoint, getUserRecord, } from "./config.js";
3
3
  import { fetchDeviceProfileOnce, extractStatic } from "./device.js";
4
- const DEFAULT_SCOPES = [
5
- "observe",
6
- "session.control",
7
- "screen.read",
8
- "screen.capture",
9
- "ble.control",
10
- ];
11
- export async function registerPlugin(endpoint, options) {
12
- const response = await fetch(`${endpoint}/v1/plugins`, {
4
+ import { fetchUserCredentials } from "./ws.js";
5
+ // ── Server API helpers ─────────────────────────────────────
6
+ /**
7
+ * Create a new user on the server.
8
+ * POST /v1/users { label } → { user_id, controller_token, label, created_at }
9
+ */
10
+ export async function createUser(endpoint, label) {
11
+ const response = await fetch(`${endpoint}/v1/users`, {
13
12
  method: "POST",
14
13
  headers: { "Content-Type": "application/json" },
15
- body: JSON.stringify({
16
- adapter_kind: options.adapterKind ?? "mcp",
17
- display_name: options.displayName ?? "ZhiHand MCP Server",
18
- stable_identity: options.stableIdentity,
19
- }),
14
+ body: JSON.stringify({ label }),
20
15
  });
21
16
  if (!response.ok) {
22
- throw new Error(`Register plugin failed: ${response.status} ${await response.text()}`);
17
+ throw new Error(`Create user failed: ${response.status} ${await response.text()}`);
23
18
  }
24
- const payload = (await response.json());
25
- return payload.plugin;
19
+ return (await response.json());
26
20
  }
27
- export async function createPairingSession(endpoint, options) {
28
- 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`, {
29
27
  method: "POST",
30
- headers: { "Content-Type": "application/json" },
28
+ headers: {
29
+ "Content-Type": "application/json",
30
+ "Authorization": `Bearer ${controllerToken}`,
31
+ },
31
32
  body: JSON.stringify({
32
- edge_id: options.edgeId,
33
- ttl_seconds: options.ttlSeconds ?? 600,
34
- 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"],
35
36
  }),
36
37
  });
37
38
  if (!response.ok) {
38
39
  throw new Error(`Create pairing session failed: ${response.status}`);
39
40
  }
40
41
  const payload = (await response.json());
41
- return {
42
- ...payload.session,
43
- controller_token: payload.controller_token ?? payload.session.controller_token,
44
- };
42
+ return payload;
45
43
  }
46
- export async function getPairingSession(endpoint, sessionId) {
47
- 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
+ });
48
57
  if (!response.ok) {
49
- throw new Error(`Get pairing session failed: ${response.status}`);
58
+ throw new Error(`Register plugin failed: ${response.status} ${await response.text()}`);
50
59
  }
51
60
  const payload = (await response.json());
52
- return payload.session;
61
+ return { edge_id: payload.plugin.edge_id };
53
62
  }
54
- 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) {
55
67
  const deadline = Date.now() + timeoutMs;
56
68
  while (Date.now() < deadline) {
57
- const session = await getPairingSession(endpoint, sessionId);
58
- if (session.status === "claimed" && session.credential_id) {
59
- 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;
60
76
  }
61
77
  if (session.status === "expired") {
62
78
  throw new Error("Pairing session expired.");
@@ -68,49 +84,60 @@ export async function waitForPairingClaim(endpoint, sessionId, timeoutMs = 600_0
68
84
  export async function renderPairingQRCode(url) {
69
85
  return QRCode.toString(url, { type: "utf8", margin: 2 });
70
86
  }
87
+ // ── Pairing flows ──────────────────────────────────────────
71
88
  /**
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.
89
+ * New user pairing: create user → create pairing session wait fetch credentials save config.
75
90
  */
76
- export async function executePairing(endpoint, edgeId, preferredLabel) {
77
- const plugin = await registerPlugin(endpoint, {
78
- stableIdentity: edgeId,
79
- displayName: preferredLabel ? `ZhiHand MCP — ${preferredLabel}` : "ZhiHand MCP Server",
80
- });
81
- const registeredEdgeId = plugin.edge_id;
82
- const session = await createPairingSession(endpoint, { edgeId: registeredEdgeId });
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);
83
104
  saveState({
84
- sessionId: session.id,
85
- controllerToken: session.controller_token,
86
- edgeId: session.edge_id,
105
+ sessionId: session.session_id,
106
+ userId,
107
+ controllerToken,
108
+ edgeId,
87
109
  pairUrl: session.pair_url,
88
110
  status: "pending",
89
111
  expiresAt: session.expires_at,
90
112
  });
113
+ // 4. Show QR + wait
91
114
  const qr = await renderPairingQRCode(session.pair_url);
92
115
  console.log(qr);
93
116
  console.log(`Open this URL on your phone to pair:\n ${session.pair_url}\n`);
94
117
  console.log(`Expires at: ${session.expires_at}`);
95
118
  console.log("Waiting for phone to scan...\n");
96
- const claimed = await waitForPairingClaim(endpoint, session.id);
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";
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";
108
128
  try {
129
+ const runtimeCfg = {
130
+ controlPlaneEndpoint: endpoint,
131
+ credentialId: cred.credential_id,
132
+ controllerToken,
133
+ timeoutMs: 10_000,
134
+ };
109
135
  const fetched = await fetchDeviceProfileOnce(runtimeCfg);
110
136
  if (fetched) {
111
137
  const st = extractStatic(fetched.rawAttrs);
112
- if (!label)
113
- label = st.model && st.model !== "unknown" ? st.model : "";
138
+ if (!deviceLabel || deviceLabel === cred.credential_id) {
139
+ deviceLabel = st.model && st.model !== "unknown" ? st.model : "";
140
+ }
114
141
  if (st.platform === "ios" || st.platform === "android")
115
142
  platform = st.platform;
116
143
  }
@@ -118,37 +145,114 @@ export async function executePairing(endpoint, edgeId, preferredLabel) {
118
145
  catch {
119
146
  // fall through
120
147
  }
121
- if (!label)
122
- label = `device-${Date.now().toString(36)}`;
148
+ if (!deviceLabel)
149
+ deviceLabel = `device-${Date.now().toString(36)}`;
123
150
  const now = new Date().toISOString();
124
- const record = {
125
- credential_id: credentialId,
126
- controller_token: controllerToken,
127
- endpoint,
128
- label,
151
+ const deviceRecord = {
152
+ credential_id: cred.credential_id,
153
+ label: deviceLabel,
129
154
  platform,
130
- paired_at: now,
155
+ paired_at: cred.paired_at ?? now,
131
156
  last_seen_at: now,
132
157
  };
133
- addDevice(record, true);
158
+ const userRecord = {
159
+ user_id: userId,
160
+ controller_token: controllerToken,
161
+ label,
162
+ created_at: userResp.created_at ?? now,
163
+ devices: [deviceRecord],
164
+ };
165
+ addUser(userRecord);
134
166
  ensureZhiHandDir();
135
167
  saveState({
136
- sessionId: session.id,
168
+ sessionId: session.session_id,
169
+ userId,
137
170
  controllerToken,
138
- edgeId: session.edge_id,
139
- credentialId,
171
+ edgeId,
172
+ credentialId: cred.credential_id,
140
173
  pairUrl: session.pair_url,
141
174
  status: "claimed",
142
175
  });
143
- return { session: claimed, record };
176
+ return { userRecord, deviceRecord };
144
177
  }
145
- export function formatPairingStatus(record) {
146
- if (!record)
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)
147
251
  return "Not paired. Run 'zhihand pair' to connect a device.";
148
- return [
149
- `Paired: ${record.label} (${record.platform})`,
150
- `Credential: ${record.credential_id}`,
151
- `Endpoint: ${record.endpoint}`,
152
- `Paired at: ${record.paired_at}`,
153
- ].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");
154
258
  }
@@ -1,67 +1,60 @@
1
1
  /**
2
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.
3
+ * their live state, and multi-user WebSocket streams.
5
4
  *
6
- * Holds a per-credential AbortController for SSE, a per-device heartbeat
7
- * timer, and a single debounced notifier for list_changed.
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
8
  */
9
- import { type DeviceRecord, type ZhiHandRuntimeConfig } from "./config.ts";
9
+ import { type DeviceRecord, type DevicePlatform, type ZhiHandRuntimeConfig } from "./config.ts";
10
10
  import { type StaticContext, type Capabilities } from "./device.ts";
11
11
  export interface DeviceState {
12
12
  credentialId: string;
13
+ userId: string;
14
+ userLabel: string;
13
15
  label: string;
14
- platform: "ios" | "android" | "unknown";
16
+ platform: DevicePlatform;
15
17
  online: boolean;
16
18
  lastSeenAtMs: number;
17
19
  profile: StaticContext | null;
18
20
  capabilities: Capabilities | null;
19
21
  profileReceivedAtMs: number;
20
22
  rawAttributes: Record<string, unknown>;
21
- sseController: AbortController | null;
22
- sseConnected: boolean;
23
- heartbeatTimer: ReturnType<typeof setInterval> | null;
24
23
  record: DeviceRecord;
25
24
  }
26
25
  type ListChangedCb = () => void;
27
26
  declare class Registry {
28
- private devices;
27
+ private userStates;
29
28
  private listChangedSubs;
30
29
  private debounceTimer;
31
30
  private lastOnlineSet;
32
31
  private initialized;
32
+ private configWatchActive;
33
+ private reconcileTimer;
33
34
  get(credentialId: string): DeviceState | null;
34
35
  list(): DeviceState[];
35
36
  listOnline(): DeviceState[];
36
37
  /**
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.
38
+ * Most-recently-active online device across all users.
44
39
  */
45
40
  resolveDefault(): DeviceState | null;
41
+ isMultiUser(): boolean;
46
42
  toRuntimeConfig(state: DeviceState): ZhiHandRuntimeConfig;
47
43
  subscribe(cb: ListChangedCb): () => void;
44
+ init(): Promise<void>;
45
+ shutdown(): void;
48
46
  private computeOnlineSet;
49
47
  private setsEqual;
50
48
  private scheduleListChanged;
51
- private updateOnlineFlag;
49
+ private createUserState;
50
+ private makeDeviceState;
51
+ private populateDevicesFromConfig;
52
+ private fetchAndPopulateDevices;
53
+ private startUserStream;
52
54
  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;
55
+ private startConfigWatch;
56
+ private stopConfigWatch;
57
+ private reconcileConfig;
65
58
  }
66
59
  export declare const registry: Registry;
67
60
  export {};