@zhihand/mcp 0.29.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.
Files changed (42) hide show
  1. package/bin/zhihand +448 -212
  2. package/dist/core/command.d.ts +5 -5
  3. package/dist/core/command.js +6 -8
  4. package/dist/core/config.d.ts +48 -21
  5. package/dist/core/config.js +178 -42
  6. package/dist/core/device.d.ts +28 -19
  7. package/dist/core/device.js +168 -145
  8. package/dist/core/logger.d.ts +17 -0
  9. package/dist/core/logger.js +32 -0
  10. package/dist/core/pair.d.ts +39 -31
  11. package/dist/core/pair.js +205 -77
  12. package/dist/core/registry.d.ts +60 -0
  13. package/dist/core/registry.js +415 -0
  14. package/dist/core/screenshot.d.ts +3 -3
  15. package/dist/core/screenshot.js +3 -2
  16. package/dist/core/sse.d.ts +40 -18
  17. package/dist/core/sse.js +122 -62
  18. package/dist/core/ws.d.ts +92 -0
  19. package/dist/core/ws.js +327 -0
  20. package/dist/daemon/dispatcher.d.ts +3 -1
  21. package/dist/daemon/dispatcher.js +4 -3
  22. package/dist/daemon/heartbeat.d.ts +4 -4
  23. package/dist/daemon/heartbeat.js +1 -1
  24. package/dist/daemon/index.js +10 -8
  25. package/dist/daemon/prompt-listener.d.ts +8 -7
  26. package/dist/daemon/prompt-listener.js +59 -99
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.js +104 -40
  29. package/dist/openclaw.adapter.js +10 -2
  30. package/dist/tools/control.d.ts +10 -3
  31. package/dist/tools/control.js +18 -24
  32. package/dist/tools/pair.d.ts +1 -1
  33. package/dist/tools/pair.js +22 -28
  34. package/dist/tools/resolve.d.ts +7 -0
  35. package/dist/tools/resolve.js +22 -0
  36. package/dist/tools/schemas.d.ts +9 -1
  37. package/dist/tools/schemas.js +10 -8
  38. package/dist/tools/screenshot.d.ts +3 -2
  39. package/dist/tools/screenshot.js +2 -2
  40. package/dist/tools/system.d.ts +3 -5
  41. package/dist/tools/system.js +19 -6
  42. package/package.json +3 -1
@@ -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
- import { dbg } from "../daemon/logger.js";
11
- // ── Default values ────────────────────────────────────────
9
+ import { log } from "./logger.js";
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,125 +130,58 @@ 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
- dbg(`[device] Fetching profile: GET ${url}`);
139
+ log.debug(`[device] Fetching profile: GET ${url}`);
195
140
  try {
196
141
  const response = await fetch(url, {
197
- headers: { "x-zhihand-controller-token": config.controllerToken },
142
+ headers: { "Authorization": `Bearer ${config.controllerToken}` },
198
143
  signal: AbortSignal.timeout(10_000),
199
144
  });
200
145
  if (!response.ok) {
201
- dbg(`[device] Profile fetch failed: ${response.status} ${response.statusText}`);
202
- return;
146
+ log.debug(`[device] Profile fetch failed: ${response.status} ${response.statusText}`);
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
- dbg(`[device] Profile fetch error: ${err.message}`);
160
+ log.debug(`[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,141 @@ 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 formatDeviceLabel(d, multiUser) {
204
+ return multiUser ? `[${d.userLabel}] ${d.label}` : d.label;
205
+ }
206
+ function singleDeviceOpenAppGuidance(platform) {
207
+ if (platform === "android") {
208
+ return " For open_app, use appPackage (e.g. 'com.tencent.mm'). Do NOT send bundleId or urlScheme.";
209
+ }
210
+ if (platform === "ios") {
211
+ return " For open_app, use bundleId (e.g. 'com.tencent.xin') or urlScheme (e.g. 'weixin://'). Do NOT send appPackage.";
212
+ }
213
+ return "";
214
+ }
215
+ export function buildControlToolDescription(state, onlineStates, multiUser) {
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.";
217
+ if (onlineStates) {
218
+ if (onlineStates.length === 0) {
219
+ return "No devices online — ask user to open the ZhiHand app. " + baseGeneric;
220
+ }
221
+ if (onlineStates.length === 1) {
222
+ const s = onlineStates[0];
223
+ const ctx = s.profile;
224
+ const label = formatDeviceLabel(s, multiUser ?? false);
225
+ if (!ctx || ctx.platform === "unknown") {
226
+ return `Control the connected mobile device (${label}). device_id is optional (single device online). All coordinates use normalized ratios [0,1].`;
227
+ }
228
+ const parts = [
229
+ `Control a ${ctx.platform} device`,
230
+ `(${ctx.model}, ${ctx.osVersion}`,
231
+ `${ctx.screenWidthPx}x${ctx.screenHeightPx}`,
232
+ `${ctx.formFactor}, ${ctx.locale})`,
233
+ ];
234
+ let desc = parts.join(", ") + `. device_id is optional (single device online: ${s.credentialId}).`;
235
+ desc += " All coordinates use normalized ratios [0,1].";
236
+ desc += singleDeviceOpenAppGuidance(ctx.platform);
237
+ return desc;
238
+ }
239
+ // 2+ devices
240
+ const ids = onlineStates.map((d) => `${d.credentialId} (${formatDeviceLabel(d, multiUser ?? false)}, ${d.platform})`).join("; ");
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].`;
242
+ }
243
+ // No explicit onlineStates: describe single state or generic
244
+ if (!state || !state.profile || state.profile.platform === "unknown") {
245
+ return baseGeneric;
246
+ }
247
+ const ctx = state.profile;
248
+ const parts = [
249
+ `Control a ${ctx.platform} device`,
250
+ `(${ctx.model}, ${ctx.osVersion}`,
251
+ `${ctx.screenWidthPx}x${ctx.screenHeightPx}`,
252
+ `${ctx.formFactor}, ${ctx.locale})`,
253
+ ];
254
+ let desc = parts.join(", ") + ".";
255
+ desc += " All coordinates use normalized ratios [0,1].";
256
+ desc += singleDeviceOpenAppGuidance(ctx.platform);
257
+ return desc;
258
+ }
259
+ export function buildSystemToolDescription(state, onlineStates, multiUser) {
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.";
261
+ if (onlineStates) {
262
+ if (onlineStates.length === 0) {
263
+ return "No devices online — ask user to open the ZhiHand app. " + genericBase;
264
+ }
265
+ if (onlineStates.length === 1) {
266
+ const s = onlineStates[0];
267
+ const platform = s.profile?.platform ?? s.platform;
268
+ const label = formatDeviceLabel(s, multiUser ?? false);
269
+ const parts = [
270
+ `System navigation and media controls for ${platform} device (${s.profile?.model ?? label}). device_id is optional (single device online).`,
271
+ ];
272
+ parts.push("Navigation: notification, recent, search (optional text query), switch_input.");
273
+ if (platform === "ios")
274
+ parts.push("iOS: siri, control_center.");
275
+ else if (platform === "android")
276
+ parts.push("Android: open_browser, shortcut_help.");
277
+ parts.push("Media: volume_up, volume_down, mute, play_pause, stop, next_track, prev_track, fast_forward, rewind.");
278
+ parts.push("Hardware: brightness_up, brightness_down, power.");
279
+ return parts.join(" ");
280
+ }
281
+ const ids = onlineStates.map((d) => `${d.credentialId} (${formatDeviceLabel(d, multiUser ?? false)}, ${d.platform})`).join("; ");
282
+ return `System navigation and media controls for mobile device. device_id is REQUIRED (multiple online). Online: ${ids}. ` + genericBase;
283
+ }
284
+ if (!state || !state.profile || state.profile.platform === "unknown") {
285
+ return genericBase;
286
+ }
287
+ const platform = state.profile.platform;
288
+ const parts = [
289
+ `System navigation and media controls for ${platform} device (${state.profile.model}).`,
290
+ ];
291
+ parts.push("Navigation: notification, recent, search (optional text query), switch_input.");
292
+ if (platform === "ios")
293
+ parts.push("iOS: siri, control_center.");
294
+ else if (platform === "android")
295
+ parts.push("Android: open_browser, shortcut_help.");
296
+ parts.push("Media: volume_up, volume_down, mute, play_pause, stop, next_track, prev_track, fast_forward, rewind.");
297
+ parts.push("Hardware: brightness_up, brightness_down, power.");
298
+ return parts.join(" ");
299
+ }
300
+ export function buildScreenshotToolDescription(state, onlineStates, multiUser) {
301
+ if (onlineStates) {
302
+ if (onlineStates.length === 0) {
303
+ return "Take a screenshot of the phone screen. No devices online — ask user to open the ZhiHand app.";
304
+ }
305
+ if (onlineStates.length === 1) {
306
+ const s = onlineStates[0];
307
+ const ctx = s.profile;
308
+ const label = formatDeviceLabel(s, multiUser ?? false);
309
+ if (!ctx || ctx.platform === "unknown") {
310
+ return `Take a screenshot of the phone screen (${label}). device_id is optional (single device online).`;
311
+ }
312
+ return `Take a screenshot of the ${ctx.platform} device (${ctx.model}, ${ctx.screenWidthPx}x${ctx.screenHeightPx}). device_id is optional (single device online).`;
313
+ }
314
+ const ids = onlineStates.map((d) => `${d.credentialId} (${formatDeviceLabel(d, multiUser ?? false)})`).join("; ");
315
+ return `Take a screenshot of a mobile device. device_id is REQUIRED (multiple online). Online: ${ids}.`;
316
+ }
317
+ if (!state || !state.profile || state.profile.platform === "unknown") {
318
+ return "Take a screenshot of the phone screen.";
319
+ }
320
+ const ctx = state.profile;
321
+ return `Take a screenshot of the ${ctx.platform} device (${ctx.model}, ${ctx.screenWidthPx}x${ctx.screenHeightPx}).`;
322
+ }
323
+ // ── Format status for zhihand_status tool ─────────────────
324
+ export function formatDeviceStatus(state) {
325
+ const staticCtx = state.profile ?? DEFAULT_STATIC;
326
+ const dynamicCtx = state.rawAttributes
327
+ ? extractDynamic(state.rawAttributes)
328
+ : DEFAULT_DYNAMIC;
329
+ const caps = state.capabilities ?? computeCapabilities(state.rawAttributes ?? {}, state.profileReceivedAtMs);
309
330
  return {
310
- // Curated summary (human-readable, stable schema)
331
+ credential_id: state.credentialId,
332
+ label: state.label,
333
+ user_id: state.userId,
334
+ user_label: state.userLabel,
335
+ online: state.online,
311
336
  platform: staticCtx.platform,
312
337
  model: staticCtx.model,
313
338
  os_version: staticCtx.osVersion,
@@ -326,9 +351,7 @@ export function formatDeviceStatus() {
326
351
  storage_available_mb: dynamicCtx.availableStorageMb,
327
352
  thermal: dynamicCtx.thermalState ?? "normal",
328
353
  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(),
354
+ capabilities: caps,
355
+ raw: pickAllowlistedRawAttributes(state.rawAttributes ?? {}),
333
356
  };
334
357
  }
@@ -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 { DeviceCredential } 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
  }
27
14
  /**
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.
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.
31
26
  */
32
27
  export declare function registerPlugin(endpoint: string, options: {
33
28
  stableIdentity: string;
34
29
  displayName?: string;
35
30
  adapterKind?: string;
36
- }): Promise<PluginRecord>;
37
- export declare function createPairingSession(endpoint: string, options: CreatePairingOptions): Promise<PairingSession>;
38
- export declare function getPairingSession(endpoint: string, sessionId: string): Promise<PairingSession>;
39
- 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>;
40
38
  export declare function renderPairingQRCode(url: string): Promise<string>;
41
- export declare function executePairing(endpoint: string, edgeId: string, deviceName?: string): Promise<{
42
- session: PairingSession;
43
- credential: DeviceCredential;
39
+ /**
40
+ * New user pairing: create user → create pairing session → wait → fetch credentials → save config.
41
+ */
42
+ export declare function executePairingNewUser(preferredLabel?: string): Promise<{
43
+ userRecord: UserRecord;
44
+ deviceRecord: DeviceRecord;
44
45
  }>;
45
- export declare function formatPairingStatus(cred: DeviceCredential | 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;