@zhihand/mcp 0.30.0 → 0.32.1

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,4 +1,5 @@
1
- import { dbg } from "../daemon/logger.js";
1
+ import { log } from "./logger.js";
2
+ const dbg = (msg) => log.debug(msg);
2
3
  let messageCounter = 0;
3
4
  function nextMessageId() {
4
5
  messageCounter = (messageCounter + 1) % 1000;
@@ -157,7 +158,7 @@ export async function enqueueCommand(config, command) {
157
158
  method: "POST",
158
159
  headers: {
159
160
  "Content-Type": "application/json",
160
- "x-zhihand-controller-token": config.controllerToken,
161
+ "Authorization": `Bearer ${config.controllerToken}`,
161
162
  },
162
163
  body: JSON.stringify(body),
163
164
  });
@@ -173,7 +174,7 @@ export async function getCommand(config, commandId) {
173
174
  const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/commands/${encodeURIComponent(commandId)}`;
174
175
  dbg(`[cmd] GET ${url}`);
175
176
  const response = await fetch(url, {
176
- headers: { "x-zhihand-controller-token": config.controllerToken },
177
+ headers: { "Authorization": `Bearer ${config.controllerToken}` },
177
178
  });
178
179
  if (!response.ok) {
179
180
  dbg(`[cmd] Get failed: ${response.status}`);
@@ -1,21 +1,25 @@
1
1
  export type DevicePlatform = "ios" | "android" | "unknown";
2
2
  export interface DeviceRecord {
3
3
  credential_id: string;
4
- controller_token: string;
5
- endpoint: string;
6
4
  label: string;
7
5
  platform: DevicePlatform;
8
6
  paired_at: string;
9
7
  last_seen_at: string;
10
8
  }
11
- export interface ZhihandConfig {
12
- schema_version: 2;
13
- default_credential_id: string | null;
14
- devices: Record<string, DeviceRecord>;
9
+ export interface UserRecord {
10
+ user_id: string;
11
+ controller_token: string;
12
+ label: string;
13
+ created_at: string;
14
+ devices: DeviceRecord[];
15
+ }
16
+ export interface ZhihandConfigV3 {
17
+ schema_version: 3;
18
+ users: Record<string, UserRecord>;
15
19
  }
16
20
  /**
17
- * Legacy-shaped config passed to HTTP callers (command/sse/device endpoints).
18
- * Corresponds to what the old single-device code called ZhiHandConfig.
21
+ * Runtime config passed to HTTP callers (command/sse/device endpoints).
22
+ * Derived from a UserRecord + DeviceRecord pair.
19
23
  */
20
24
  export interface ZhiHandRuntimeConfig {
21
25
  controlPlaneEndpoint: string;
@@ -32,19 +36,30 @@ export interface BackendConfig {
32
36
  export declare const DEFAULT_MODELS: Record<Exclude<BackendName, "openclaw">, string>;
33
37
  export declare function resolveZhiHandDir(): string;
34
38
  export declare function ensureZhiHandDir(): void;
35
- export declare function loadConfig(): ZhihandConfig;
36
- export declare function saveConfig(cfg: ZhihandConfig): void;
37
- export declare function addDevice(record: DeviceRecord, makeDefault?: boolean): void;
38
- export declare function removeDevice(credentialId: string): void;
39
- export declare function renameDevice(credentialId: string, label: string): void;
40
- export declare function setDefaultDevice(credentialId: string): void;
41
- export declare function updateLastSeen(credentialId: string, iso: string): void;
42
- export declare function getDeviceRecord(credentialId: string): DeviceRecord | null;
43
- export declare function listDeviceRecords(): DeviceRecord[];
44
- export declare function recordToRuntimeConfig(r: DeviceRecord): ZhiHandRuntimeConfig;
39
+ export declare function getConfigPath(): string;
40
+ export declare function loadConfig(): ZhihandConfigV3;
41
+ /**
42
+ * Atomically write config: write to .tmp, then rename. Prevents corruption
43
+ * when the daemon and CLI write concurrently (Gemini code review v0.31).
44
+ */
45
+ export declare function saveConfig(cfg: ZhihandConfigV3): void;
46
+ export declare function addUser(user: UserRecord): void;
47
+ export declare function removeUser(userId: string): void;
48
+ export declare function addDeviceToUser(userId: string, device: DeviceRecord): void;
49
+ export declare function removeDeviceFromUser(userId: string, credentialId: string): void;
50
+ export declare function updateDeviceLabel(userId: string, credentialId: string, label: string): void;
51
+ export declare function updateControllerToken(userId: string, newToken: string): void;
52
+ export declare function updateDeviceLastSeen(userId: string, credentialId: string, iso: string): void;
53
+ export declare function getUserRecord(userId: string): UserRecord | null;
54
+ export declare function findDeviceOwner(credentialId: string): {
55
+ user: UserRecord;
56
+ device: DeviceRecord;
57
+ } | null;
58
+ export declare function listUsers(): UserRecord[];
59
+ export declare function resolveDefaultEndpoint(): string;
45
60
  /**
46
- * Resolve a runtime config for HTTP calls. If credentialId provided, look it up;
47
- * else use default_credential_id; else throw.
61
+ * Resolve a runtime config for HTTP calls. Find which user owns the
62
+ * credential and use the user's controller_token.
48
63
  */
49
64
  export declare function resolveConfig(credentialId?: string): ZhiHandRuntimeConfig;
50
65
  export declare function loadState<T = unknown>(): T | null;
@@ -9,7 +9,6 @@ export const DEFAULT_MODELS = {
9
9
  // ── Paths ──────────────────────────────────────────────────
10
10
  const ZHIHAND_DIR = path.join(os.homedir(), ".zhihand");
11
11
  const CONFIG_PATH = path.join(ZHIHAND_DIR, "config.json");
12
- const LEGACY_CREDENTIALS_PATH = path.join(ZHIHAND_DIR, "credentials.json");
13
12
  const STATE_PATH = path.join(ZHIHAND_DIR, "state.json");
14
13
  const BACKEND_PATH = path.join(ZHIHAND_DIR, "backend.json");
15
14
  export function resolveZhiHandDir() {
@@ -18,112 +17,187 @@ export function resolveZhiHandDir() {
18
17
  export function ensureZhiHandDir() {
19
18
  fs.mkdirSync(ZHIHAND_DIR, { recursive: true, mode: 0o700 });
20
19
  }
21
- // ── v2 config I/O ──────────────────────────────────────────
20
+ export function getConfigPath() {
21
+ return CONFIG_PATH;
22
+ }
23
+ // ── v3 config I/O ──────────────────────────────────────────
22
24
  let legacyWarningPrinted = false;
23
25
  function emptyConfig() {
24
- return { schema_version: 2, default_credential_id: null, devices: {} };
26
+ return { schema_version: 3, users: {} };
25
27
  }
26
28
  export function loadConfig() {
27
29
  if (!fs.existsSync(CONFIG_PATH)) {
28
- if (!legacyWarningPrinted && fs.existsSync(LEGACY_CREDENTIALS_PATH)) {
30
+ // Check for v2 or legacy credentials
31
+ const legacyCredentials = path.join(ZHIHAND_DIR, "credentials.json");
32
+ if (!legacyWarningPrinted && (fs.existsSync(legacyCredentials) || checkForV2Config())) {
29
33
  legacyWarningPrinted = true;
30
- process.stderr.write("[zhihand] legacy credentials.json detected — run 'zhihand pair' to re-pair on v0.30 schema\n");
34
+ process.stderr.write("[zhihand] old config detected (v2 or legacy) — run 'zhihand pair' to re-pair on v0.31 schema\n");
31
35
  }
32
36
  return emptyConfig();
33
37
  }
34
38
  try {
35
39
  const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
36
- if (raw && raw.schema_version === 2) {
40
+ if (raw && raw.schema_version === 3) {
37
41
  return {
38
- schema_version: 2,
39
- default_credential_id: raw.default_credential_id ?? null,
40
- devices: raw.devices ?? {},
42
+ schema_version: 3,
43
+ users: raw.users ?? {},
41
44
  };
42
45
  }
46
+ // Old schema version detected
47
+ if (!legacyWarningPrinted) {
48
+ legacyWarningPrinted = true;
49
+ process.stderr.write("[zhihand] old config detected (schema v" + (raw.schema_version ?? "?") + ") — run 'zhihand pair' to re-pair on v0.31 schema\n");
50
+ }
43
51
  }
44
52
  catch {
45
53
  // fall through
46
54
  }
47
55
  return emptyConfig();
48
56
  }
57
+ function checkForV2Config() {
58
+ if (!fs.existsSync(CONFIG_PATH))
59
+ return false;
60
+ try {
61
+ const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
62
+ return raw && raw.schema_version === 2;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ /**
69
+ * Atomically write config: write to .tmp, then rename. Prevents corruption
70
+ * when the daemon and CLI write concurrently (Gemini code review v0.31).
71
+ */
49
72
  export function saveConfig(cfg) {
50
73
  ensureZhiHandDir();
51
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
74
+ const tmpPath = CONFIG_PATH + ".tmp";
75
+ fs.writeFileSync(tmpPath, JSON.stringify(cfg, null, 2), { mode: 0o600 });
76
+ fs.renameSync(tmpPath, CONFIG_PATH);
52
77
  }
53
- export function addDevice(record, makeDefault) {
78
+ // ── User helpers ──────────────────────────────────────────
79
+ export function addUser(user) {
54
80
  const cfg = loadConfig();
55
- cfg.devices[record.credential_id] = record;
56
- if (makeDefault || cfg.default_credential_id === null) {
57
- cfg.default_credential_id = record.credential_id;
58
- }
81
+ cfg.users[user.user_id] = user;
82
+ saveConfig(cfg);
83
+ }
84
+ export function removeUser(userId) {
85
+ const cfg = loadConfig();
86
+ delete cfg.users[userId];
59
87
  saveConfig(cfg);
60
88
  }
61
- export function removeDevice(credentialId) {
89
+ export function addDeviceToUser(userId, device) {
62
90
  const cfg = loadConfig();
63
- delete cfg.devices[credentialId];
64
- if (cfg.default_credential_id === credentialId) {
65
- const remaining = Object.keys(cfg.devices);
66
- cfg.default_credential_id = remaining[0] ?? null;
91
+ const user = cfg.users[userId];
92
+ if (!user)
93
+ throw new Error(`User '${userId}' not found in config`);
94
+ // Replace if exists, else append
95
+ const idx = user.devices.findIndex((d) => d.credential_id === device.credential_id);
96
+ if (idx >= 0) {
97
+ user.devices[idx] = device;
98
+ }
99
+ else {
100
+ user.devices.push(device);
67
101
  }
68
102
  saveConfig(cfg);
69
103
  }
70
- export function renameDevice(credentialId, label) {
104
+ export function removeDeviceFromUser(userId, credentialId) {
71
105
  const cfg = loadConfig();
72
- const r = cfg.devices[credentialId];
73
- if (!r)
74
- throw new Error(`Device '${credentialId}' not found`);
75
- r.label = label;
106
+ const user = cfg.users[userId];
107
+ if (!user)
108
+ return;
109
+ user.devices = user.devices.filter((d) => d.credential_id !== credentialId);
76
110
  saveConfig(cfg);
77
111
  }
78
- export function setDefaultDevice(credentialId) {
112
+ export function updateDeviceLabel(userId, credentialId, label) {
79
113
  const cfg = loadConfig();
80
- if (!cfg.devices[credentialId]) {
81
- throw new Error(`Device '${credentialId}' not found`);
82
- }
83
- cfg.default_credential_id = credentialId;
114
+ const user = cfg.users[userId];
115
+ if (!user)
116
+ throw new Error(`User '${userId}' not found`);
117
+ const dev = user.devices.find((d) => d.credential_id === credentialId);
118
+ if (!dev)
119
+ throw new Error(`Device '${credentialId}' not found under user '${userId}'`);
120
+ dev.label = label;
121
+ saveConfig(cfg);
122
+ }
123
+ export function updateControllerToken(userId, newToken) {
124
+ const cfg = loadConfig();
125
+ const user = cfg.users[userId];
126
+ if (!user)
127
+ throw new Error(`User '${userId}' not found`);
128
+ user.controller_token = newToken;
84
129
  saveConfig(cfg);
85
130
  }
86
- export function updateLastSeen(credentialId, iso) {
131
+ export function updateDeviceLastSeen(userId, credentialId, iso) {
87
132
  const cfg = loadConfig();
88
- const r = cfg.devices[credentialId];
89
- if (!r)
133
+ const user = cfg.users[userId];
134
+ if (!user)
90
135
  return;
91
- r.last_seen_at = iso;
136
+ const dev = user.devices.find((d) => d.credential_id === credentialId);
137
+ if (!dev)
138
+ return;
139
+ dev.last_seen_at = iso;
92
140
  saveConfig(cfg);
93
141
  }
94
- export function getDeviceRecord(credentialId) {
142
+ export function getUserRecord(userId) {
95
143
  const cfg = loadConfig();
96
- return cfg.devices[credentialId] ?? null;
144
+ return cfg.users[userId] ?? null;
97
145
  }
98
- export function listDeviceRecords() {
146
+ export function findDeviceOwner(credentialId) {
99
147
  const cfg = loadConfig();
100
- return Object.values(cfg.devices);
148
+ for (const user of Object.values(cfg.users)) {
149
+ const device = user.devices.find((d) => d.credential_id === credentialId);
150
+ if (device)
151
+ return { user, device };
152
+ }
153
+ return null;
154
+ }
155
+ export function listUsers() {
156
+ const cfg = loadConfig();
157
+ return Object.values(cfg.users);
101
158
  }
102
159
  // ── Runtime config resolution ─────────────────────────────
103
- export function recordToRuntimeConfig(r) {
104
- return {
105
- controlPlaneEndpoint: r.endpoint,
106
- credentialId: r.credential_id,
107
- controllerToken: r.controller_token,
108
- timeoutMs: 10_000,
109
- };
160
+ export function resolveDefaultEndpoint() {
161
+ return process.env.ZHIHAND_ENDPOINT ?? "https://api.zhihand.com";
110
162
  }
111
163
  /**
112
- * Resolve a runtime config for HTTP calls. If credentialId provided, look it up;
113
- * else use default_credential_id; else throw.
164
+ * Resolve a runtime config for HTTP calls. Find which user owns the
165
+ * credential and use the user's controller_token.
114
166
  */
115
167
  export function resolveConfig(credentialId) {
116
168
  const cfg = loadConfig();
117
- const id = credentialId ?? cfg.default_credential_id;
118
- if (!id) {
119
- throw new Error("No default device — run zhihand pair");
169
+ const endpoint = resolveDefaultEndpoint();
170
+ if (credentialId) {
171
+ const owner = findDeviceOwner(credentialId);
172
+ if (!owner) {
173
+ const known = Object.values(cfg.users)
174
+ .flatMap((u) => u.devices.map((d) => d.credential_id))
175
+ .join(", ") || "(none)";
176
+ throw new Error(`Device '${credentialId}' not found. Known: ${known}`);
177
+ }
178
+ return {
179
+ controlPlaneEndpoint: endpoint,
180
+ credentialId,
181
+ controllerToken: owner.user.controller_token,
182
+ timeoutMs: 10_000,
183
+ };
184
+ }
185
+ // No explicit credential — pick first device of first user
186
+ const users = Object.values(cfg.users);
187
+ if (users.length === 0) {
188
+ throw new Error("No users configured — run zhihand pair");
120
189
  }
121
- const r = cfg.devices[id];
122
- if (!r) {
123
- const known = Object.keys(cfg.devices).join(", ") || "(none)";
124
- throw new Error(`Device '${id}' not found. Known: ${known}`);
190
+ for (const user of users) {
191
+ if (user.devices.length > 0) {
192
+ return {
193
+ controlPlaneEndpoint: endpoint,
194
+ credentialId: user.devices[0].credential_id,
195
+ controllerToken: user.controller_token,
196
+ timeoutMs: 10_000,
197
+ };
198
+ }
125
199
  }
126
- return recordToRuntimeConfig(r);
200
+ throw new Error("No devices configured — run zhihand pair");
127
201
  }
128
202
  // ── State / backend ───────────────────────────────────────
129
203
  export function loadState() {
@@ -67,7 +67,7 @@ export declare function normalizeProfilePayload(raw: Record<string, unknown>): R
67
67
  export declare function pickAllowlistedRawAttributes(rawAttributes: Record<string, unknown>): Record<string, unknown>;
68
68
  export { DEFAULT_STATIC, DEFAULT_DYNAMIC };
69
69
  import type { DeviceState } from "./registry.ts";
70
- export declare function buildControlToolDescription(state: DeviceState | null, onlineStates?: DeviceState[]): string;
71
- export declare function buildSystemToolDescription(state: DeviceState | null, onlineStates?: DeviceState[]): string;
72
- export declare function buildScreenshotToolDescription(state: DeviceState | null, onlineStates?: DeviceState[]): string;
70
+ export declare function buildControlToolDescription(state: DeviceState | null, onlineStates?: DeviceState[], multiUser?: boolean): string;
71
+ export declare function buildSystemToolDescription(state: DeviceState | null, onlineStates?: DeviceState[], multiUser?: boolean): string;
72
+ export declare function buildScreenshotToolDescription(state: DeviceState | null, onlineStates?: DeviceState[], multiUser?: boolean): string;
73
73
  export declare function formatDeviceStatus(state: DeviceState): Record<string, unknown>;
@@ -6,7 +6,7 @@
6
6
  * to extract, classify, and format device data so the same logic can be
7
7
  * applied to any number of devices.
8
8
  */
9
- import { dbg } from "../daemon/logger.js";
9
+ import { log } from "./logger.js";
10
10
  const DEFAULT_STATIC = {
11
11
  platform: "unknown",
12
12
  model: "unknown",
@@ -136,14 +136,14 @@ export function extractDynamic(profile) {
136
136
  */
137
137
  export async function fetchDeviceProfileOnce(config) {
138
138
  const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/device-profile`;
139
- dbg(`[device] Fetching profile: GET ${url}`);
139
+ log.debug(`[device] Fetching profile: GET ${url}`);
140
140
  try {
141
141
  const response = await fetch(url, {
142
- headers: { "x-zhihand-controller-token": config.controllerToken },
142
+ headers: { "Authorization": `Bearer ${config.controllerToken}` },
143
143
  signal: AbortSignal.timeout(10_000),
144
144
  });
145
145
  if (!response.ok) {
146
- dbg(`[device] Profile fetch failed: ${response.status} ${response.statusText}`);
146
+ log.debug(`[device] Profile fetch failed: ${response.status} ${response.statusText}`);
147
147
  return null;
148
148
  }
149
149
  const data = (await response.json());
@@ -157,7 +157,7 @@ export async function fetchDeviceProfileOnce(config) {
157
157
  return { rawAttrs, receivedAtMs: Date.now() };
158
158
  }
159
159
  catch (err) {
160
- dbg(`[device] Profile fetch error: ${err.message}`);
160
+ log.debug(`[device] Profile fetch error: ${err.message}`);
161
161
  return null;
162
162
  }
163
163
  }
@@ -200,6 +200,9 @@ export function pickAllowlistedRawAttributes(rawAttributes) {
200
200
  }
201
201
  // ── Default static/dynamic export for empty-state rendering ──
202
202
  export { DEFAULT_STATIC, DEFAULT_DYNAMIC };
203
+ function formatDeviceLabel(d, multiUser) {
204
+ return multiUser ? `[${d.userLabel}] ${d.label}` : d.label;
205
+ }
203
206
  function singleDeviceOpenAppGuidance(platform) {
204
207
  if (platform === "android") {
205
208
  return " For open_app, use appPackage (e.g. 'com.tencent.mm'). Do NOT send bundleId or urlScheme.";
@@ -209,7 +212,7 @@ function singleDeviceOpenAppGuidance(platform) {
209
212
  }
210
213
  return "";
211
214
  }
212
- export function buildControlToolDescription(state, onlineStates) {
215
+ export function buildControlToolDescription(state, onlineStates, multiUser) {
213
216
  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
217
  if (onlineStates) {
215
218
  if (onlineStates.length === 0) {
@@ -218,8 +221,9 @@ export function buildControlToolDescription(state, onlineStates) {
218
221
  if (onlineStates.length === 1) {
219
222
  const s = onlineStates[0];
220
223
  const ctx = s.profile;
224
+ const label = formatDeviceLabel(s, multiUser ?? false);
221
225
  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].`;
226
+ return `Control the connected mobile device (${label}). device_id is optional (single device online). All coordinates use normalized ratios [0,1].`;
223
227
  }
224
228
  const parts = [
225
229
  `Control a ${ctx.platform} device`,
@@ -233,7 +237,7 @@ export function buildControlToolDescription(state, onlineStates) {
233
237
  return desc;
234
238
  }
235
239
  // 2+ devices
236
- const ids = onlineStates.map((d) => `${d.credentialId} (${d.label}, ${d.platform})`).join("; ");
240
+ const ids = onlineStates.map((d) => `${d.credentialId} (${formatDeviceLabel(d, multiUser ?? false)}, ${d.platform})`).join("; ");
237
241
  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
242
  }
239
243
  // No explicit onlineStates: describe single state or generic
@@ -252,7 +256,7 @@ export function buildControlToolDescription(state, onlineStates) {
252
256
  desc += singleDeviceOpenAppGuidance(ctx.platform);
253
257
  return desc;
254
258
  }
255
- export function buildSystemToolDescription(state, onlineStates) {
259
+ export function buildSystemToolDescription(state, onlineStates, multiUser) {
256
260
  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
261
  if (onlineStates) {
258
262
  if (onlineStates.length === 0) {
@@ -261,8 +265,9 @@ export function buildSystemToolDescription(state, onlineStates) {
261
265
  if (onlineStates.length === 1) {
262
266
  const s = onlineStates[0];
263
267
  const platform = s.profile?.platform ?? s.platform;
268
+ const label = formatDeviceLabel(s, multiUser ?? false);
264
269
  const parts = [
265
- `System navigation and media controls for ${platform} device (${s.profile?.model ?? s.label}). device_id is optional (single device online).`,
270
+ `System navigation and media controls for ${platform} device (${s.profile?.model ?? label}). device_id is optional (single device online).`,
266
271
  ];
267
272
  parts.push("Navigation: notification, recent, search (optional text query), switch_input.");
268
273
  if (platform === "ios")
@@ -273,7 +278,7 @@ export function buildSystemToolDescription(state, onlineStates) {
273
278
  parts.push("Hardware: brightness_up, brightness_down, power.");
274
279
  return parts.join(" ");
275
280
  }
276
- const ids = onlineStates.map((d) => `${d.credentialId} (${d.label}, ${d.platform})`).join("; ");
281
+ const ids = onlineStates.map((d) => `${d.credentialId} (${formatDeviceLabel(d, multiUser ?? false)}, ${d.platform})`).join("; ");
277
282
  return `System navigation and media controls for mobile device. device_id is REQUIRED (multiple online). Online: ${ids}. ` + genericBase;
278
283
  }
279
284
  if (!state || !state.profile || state.profile.platform === "unknown") {
@@ -292,7 +297,7 @@ export function buildSystemToolDescription(state, onlineStates) {
292
297
  parts.push("Hardware: brightness_up, brightness_down, power.");
293
298
  return parts.join(" ");
294
299
  }
295
- export function buildScreenshotToolDescription(state, onlineStates) {
300
+ export function buildScreenshotToolDescription(state, onlineStates, multiUser) {
296
301
  if (onlineStates) {
297
302
  if (onlineStates.length === 0) {
298
303
  return "Take a screenshot of the phone screen. No devices online — ask user to open the ZhiHand app.";
@@ -300,12 +305,13 @@ export function buildScreenshotToolDescription(state, onlineStates) {
300
305
  if (onlineStates.length === 1) {
301
306
  const s = onlineStates[0];
302
307
  const ctx = s.profile;
308
+ const label = formatDeviceLabel(s, multiUser ?? false);
303
309
  if (!ctx || ctx.platform === "unknown") {
304
- return `Take a screenshot of the phone screen (${s.label}). device_id is optional (single device online).`;
310
+ return `Take a screenshot of the phone screen (${label}). device_id is optional (single device online).`;
305
311
  }
306
312
  return `Take a screenshot of the ${ctx.platform} device (${ctx.model}, ${ctx.screenWidthPx}x${ctx.screenHeightPx}). device_id is optional (single device online).`;
307
313
  }
308
- const ids = onlineStates.map((d) => `${d.credentialId} (${d.label})`).join("; ");
314
+ const ids = onlineStates.map((d) => `${d.credentialId} (${formatDeviceLabel(d, multiUser ?? false)})`).join("; ");
309
315
  return `Take a screenshot of a mobile device. device_id is REQUIRED (multiple online). Online: ${ids}.`;
310
316
  }
311
317
  if (!state || !state.profile || state.profile.platform === "unknown") {
@@ -324,6 +330,8 @@ export function formatDeviceStatus(state) {
324
330
  return {
325
331
  credential_id: state.credentialId,
326
332
  label: state.label,
333
+ user_id: state.userId,
334
+ user_label: state.userLabel,
327
335
  online: state.online,
328
336
  platform: staticCtx.platform,
329
337
  model: staticCtx.model,
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Unified logger — all log output goes to stderr so stdout stays clean
3
+ * for MCP JSON-RPC. Replaces ad-hoc process.stderr.write and dbg() calls
4
+ * in core/ and tools/ code.
5
+ *
6
+ * The daemon has its own stdout-based log() in daemon/index.ts — that is
7
+ * intentional (it writes to daemon.log). The daemon's debug logger
8
+ * (daemon/logger.ts) remains for daemon-specific verbose output.
9
+ */
10
+ export declare const log: {
11
+ info: (...args: unknown[]) => void;
12
+ warn: (...args: unknown[]) => void;
13
+ error: (...args: unknown[]) => void;
14
+ debug: (...args: unknown[]) => void;
15
+ };
16
+ export declare function setDebugEnabled(v: boolean): void;
17
+ export declare function isDebugEnabled(): boolean;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Unified logger — all log output goes to stderr so stdout stays clean
3
+ * for MCP JSON-RPC. Replaces ad-hoc process.stderr.write and dbg() calls
4
+ * in core/ and tools/ code.
5
+ *
6
+ * The daemon has its own stdout-based log() in daemon/index.ts — that is
7
+ * intentional (it writes to daemon.log). The daemon's debug logger
8
+ * (daemon/logger.ts) remains for daemon-specific verbose output.
9
+ */
10
+ let debugEnabled = false;
11
+ export const log = {
12
+ info: (...args) => {
13
+ process.stderr.write(`[info] ${args.map(String).join(" ")}\n`);
14
+ },
15
+ warn: (...args) => {
16
+ process.stderr.write(`[warn] ${args.map(String).join(" ")}\n`);
17
+ },
18
+ error: (...args) => {
19
+ process.stderr.write(`[error] ${args.map(String).join(" ")}\n`);
20
+ },
21
+ debug: (...args) => {
22
+ if (debugEnabled) {
23
+ process.stderr.write(`[debug] ${args.map(String).join(" ")}\n`);
24
+ }
25
+ },
26
+ };
27
+ export function setDebugEnabled(v) {
28
+ debugEnabled = v;
29
+ }
30
+ export function isDebugEnabled() {
31
+ return debugEnabled;
32
+ }
@@ -1,45 +1,53 @@
1
- import type { DeviceRecord } from "./config.ts";
2
- export interface PluginRecord {
3
- id: string;
4
- edge_id: string;
5
- adapter_kind: string;
6
- display_name?: string;
7
- stable_identity?: string;
8
- status: string;
9
- created_at: string;
10
- }
1
+ import type { DeviceRecord, UserRecord } from "./config.ts";
11
2
  export interface PairingSession {
12
- id: string;
3
+ session_id: string;
13
4
  pair_url: string;
14
5
  qr_payload: string;
15
- controller_token?: string;
16
- edge_id: string;
17
- status: "pending" | "claimed" | "expired" | string;
18
- credential_id?: string;
19
6
  expires_at: string;
20
- requested_scopes?: string[];
21
7
  }
22
- export interface CreatePairingOptions {
23
- edgeId: string;
24
- ttlSeconds?: number;
25
- requestedScopes?: string[];
8
+ export interface CreateUserResponse {
9
+ user_id: string;
10
+ controller_token: string;
11
+ label: string;
12
+ created_at: string;
26
13
  }
14
+ /**
15
+ * Create a new user on the server.
16
+ * POST /v1/users { label } → { user_id, controller_token, label, created_at }
17
+ */
18
+ export declare function createUser(endpoint: string, label: string): Promise<CreateUserResponse>;
19
+ /**
20
+ * Create a pairing session for a user.
21
+ * POST /v1/users/{id}/pairing/sessions { edge_id, ttl_seconds } → PairingSession
22
+ */
23
+ export declare function createPairingSession(endpoint: string, userId: string, controllerToken: string, edgeId: string, ttlSeconds?: number): Promise<PairingSession>;
24
+ /**
25
+ * Register a plugin (edge). Kept for backward compat with edge registration.
26
+ */
27
27
  export declare function registerPlugin(endpoint: string, options: {
28
28
  stableIdentity: string;
29
29
  displayName?: string;
30
30
  adapterKind?: string;
31
- }): Promise<PluginRecord>;
32
- export declare function createPairingSession(endpoint: string, options: CreatePairingOptions): Promise<PairingSession>;
33
- export declare function getPairingSession(endpoint: string, sessionId: string): Promise<PairingSession>;
34
- export declare function waitForPairingClaim(endpoint: string, sessionId: string, timeoutMs?: number): Promise<PairingSession>;
31
+ }): Promise<{
32
+ edge_id: string;
33
+ }>;
34
+ /**
35
+ * Poll pairing session until claimed or expired.
36
+ */
37
+ export declare function waitForPairingClaim(endpoint: string, userId: string, controllerToken: string, sessionId: string, timeoutMs?: number): Promise<void>;
35
38
  export declare function renderPairingQRCode(url: string): Promise<string>;
36
39
  /**
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
+ * New user pairing: create user → create pairing session wait fetch credentials save config.
40
41
  */
41
- export declare function executePairing(endpoint: string, edgeId: string, preferredLabel?: string): Promise<{
42
- session: PairingSession;
43
- record: DeviceRecord;
42
+ export declare function executePairingNewUser(preferredLabel?: string): Promise<{
43
+ userRecord: UserRecord;
44
+ deviceRecord: DeviceRecord;
44
45
  }>;
45
- export declare function formatPairingStatus(record: DeviceRecord | null): string;
46
+ /**
47
+ * Add device to existing user: create pairing session → wait → fetch new credential → save.
48
+ */
49
+ export declare function executePairingAddDevice(userId: string, preferredLabel?: string): Promise<DeviceRecord>;
50
+ /**
51
+ * Legacy: format pairing status (kept for backward compat).
52
+ */
53
+ export declare function formatPairingStatus(userId: string | null): string;