@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,4 +1,5 @@
1
- import type { ZhiHandConfig, BackendName } from "../core/config.ts";
1
+ import type { ZhiHandRuntimeConfig, BackendName } from "../core/config.ts";
2
+ type ZhiHandConfig = ZhiHandRuntimeConfig;
2
3
  export interface DispatchResult {
3
4
  text: string;
4
5
  success: boolean;
@@ -10,3 +11,4 @@ export interface DispatchResult {
10
11
  export declare function killActiveChild(): Promise<void>;
11
12
  export declare function dispatchToCLI(backend: Exclude<BackendName, "openclaw">, prompt: string, log: (msg: string) => void, model?: string): Promise<DispatchResult>;
12
13
  export declare function postReply(config: ZhiHandConfig, promptId: string, text: string): Promise<boolean>;
14
+ export {};
@@ -5,7 +5,7 @@ import os from "node:os";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { DEFAULT_MODELS } from "../core/config.js";
7
7
  import { resolveGemini, resolveClaude, resolveCodex } from "../core/resolve-path.js";
8
- import { getStaticContext, isDeviceProfileLoaded } from "../core/device.js";
8
+ import { registry } from "../core/registry.js";
9
9
  import { dbg } from "./logger.js";
10
10
  const CLI_TIMEOUT = 300_000; // 300s (5min) per prompt — MCP tool chains need multiple turns
11
11
  const SIGKILL_DELAY = 2_000; // 2s after SIGTERM
@@ -357,7 +357,8 @@ export async function killActiveChild() {
357
357
  * so the AI sends correct platform-specific parameters (e.g. appPackage vs bundleId).
358
358
  */
359
359
  function buildSystemContext() {
360
- const static_ = isDeviceProfileLoaded() ? getStaticContext() : null;
360
+ const defaultState = registry.resolveDefault();
361
+ const static_ = defaultState?.profile ?? null;
361
362
  const deviceLine = static_
362
363
  ? `Connected device: ${static_.platform} ${static_.model} (${static_.osVersion}), ${static_.screenWidthPx}x${static_.screenHeightPx}, ${static_.formFactor}, ${static_.locale}`
363
364
  : "Connected device: unknown platform";
@@ -778,7 +779,7 @@ export async function postReply(config, promptId, text) {
778
779
  method: "POST",
779
780
  headers: {
780
781
  "Content-Type": "application/json",
781
- "x-zhihand-controller-token": config.controllerToken,
782
+ "Authorization": `Bearer ${config.controllerToken}`,
782
783
  },
783
784
  body: JSON.stringify({ role: "assistant", text }),
784
785
  signal: AbortSignal.timeout(30_000),
@@ -1,4 +1,4 @@
1
- import type { ZhiHandConfig } from "../core/config.ts";
1
+ import type { ZhiHandRuntimeConfig } from "../core/config.ts";
2
2
  /** Brain metadata included in every heartbeat, so the app always knows the current backend/model. */
3
3
  export interface BrainMeta {
4
4
  backend?: string | null;
@@ -6,7 +6,7 @@ export interface BrainMeta {
6
6
  }
7
7
  /** Update the backend/model metadata that will be sent with the next heartbeat. */
8
8
  export declare function setBrainMeta(meta: BrainMeta): void;
9
- export declare function sendBrainOnline(config: ZhiHandConfig): Promise<boolean>;
10
- export declare function sendBrainOffline(config: ZhiHandConfig): Promise<boolean>;
11
- export declare function startHeartbeatLoop(config: ZhiHandConfig, log: (msg: string) => void): void;
9
+ export declare function sendBrainOnline(config: ZhiHandRuntimeConfig): Promise<boolean>;
10
+ export declare function sendBrainOffline(config: ZhiHandRuntimeConfig): Promise<boolean>;
11
+ export declare function startHeartbeatLoop(config: ZhiHandRuntimeConfig, log: (msg: string) => void): void;
12
12
  export declare function stopHeartbeatLoop(): void;
@@ -25,7 +25,7 @@ async function sendHeartbeat(config, online) {
25
25
  method: "POST",
26
26
  headers: {
27
27
  "Content-Type": "application/json",
28
- "x-zhihand-controller-token": config.controllerToken,
28
+ "Authorization": `Bearer ${config.controllerToken}`,
29
29
  },
30
30
  body: JSON.stringify(body),
31
31
  signal: AbortSignal.timeout(10_000),
@@ -11,10 +11,10 @@ import { startHeartbeatLoop, stopHeartbeatLoop, sendBrainOffline, setBrainMeta }
11
11
  import { PromptListener } from "./prompt-listener.js";
12
12
  import { dispatchToCLI, postReply, killActiveChild } from "./dispatcher.js";
13
13
  import { setDebugEnabled, dbg } from "./logger.js";
14
- import { fetchDeviceProfile, getStaticContext, isDeviceProfileLoaded } from "../core/device.js";
14
+ import { registry } from "../core/registry.js";
15
15
  const DEFAULT_PORT = 18686;
16
16
  const PID_FILE = "daemon.pid";
17
- // ── State ──────────────────────────────────────────────────
17
+ // ── State ────────���─────────────────────────────────────────
18
18
  let activeBackend = null;
19
19
  let activeModel = null; // user-selected model alias, null = use default
20
20
  let isProcessing = false;
@@ -153,7 +153,7 @@ function readPid() {
153
153
  export function isAlreadyRunning() {
154
154
  return readPid();
155
155
  }
156
- // ── Main Daemon Entry ──────────────────────────────────────
156
+ // ── Main Daemon Entry ──────���───────────────────────────────
157
157
  export async function startDaemon(options) {
158
158
  if (options?.debug)
159
159
  setDebugEnabled(true);
@@ -190,10 +190,11 @@ export async function startDaemon(options) {
190
190
  else {
191
191
  log(`[config] No backend configured. Use: zhihand gemini / zhihand claude / zhihand codex`);
192
192
  }
193
- // Fetch device profile (platform, model, screen size) — non-blocking, best-effort
194
- await fetchDeviceProfile(config);
195
- if (isDeviceProfileLoaded()) {
196
- const s = getStaticContext();
193
+ // Init the multi-device registry and log profile.
194
+ await registry.init();
195
+ const defaultState = registry.resolveDefault();
196
+ if (defaultState?.profile) {
197
+ const s = defaultState.profile;
197
198
  log(`[device] ${s.platform} ${s.model} (${s.osVersion}), ${s.screenWidthPx}x${s.screenHeightPx}, ${s.locale}`);
198
199
  }
199
200
  else {
@@ -244,7 +245,7 @@ export async function startDaemon(options) {
244
245
  }
245
246
  else if (!sessionId) {
246
247
  // New session: create dedicated McpServer + Transport
247
- const server = createMcpServer(options?.deviceName);
248
+ const server = createMcpServer();
248
249
  const transport = new StreamableHTTPServerTransport({
249
250
  sessionIdGenerator: () => randomUUID(),
250
251
  onsessioninitialized: (sid) => {
@@ -341,6 +342,7 @@ export async function startDaemon(options) {
341
342
  catch { /* ignore */ }
342
343
  }
343
344
  mcpSessions.clear();
345
+ registry.shutdown();
344
346
  httpServer.close();
345
347
  removePid();
346
348
  log("Daemon stopped.");
@@ -1,4 +1,5 @@
1
- import type { ZhiHandConfig } from "../core/config.ts";
1
+ import type { ZhiHandRuntimeConfig } from "../core/config.ts";
2
+ type ZhiHandConfig = ZhiHandRuntimeConfig;
2
3
  export interface MobilePrompt {
3
4
  id: string;
4
5
  credential_id: string;
@@ -15,20 +16,20 @@ export declare class PromptListener {
15
16
  private handler;
16
17
  private log;
17
18
  private processedIds;
18
- private sseAbort;
19
+ private rws;
19
20
  private pollTimer;
20
- private sseConnected;
21
+ private wsConnected;
21
22
  private stopped;
22
23
  constructor(config: ZhiHandConfig, handler: PromptHandler, log: (msg: string) => void);
23
24
  start(): void;
24
25
  stop(): void;
25
26
  private dispatchPrompt;
26
- private connectSSE;
27
- private resetWatchdog;
28
- private handleSSEEvent;
27
+ private connectWS;
28
+ private handleWSMessage;
29
+ private handleEvent;
29
30
  private startPolling;
30
- /** Recursive setTimeout: waits for fetch to complete before scheduling next poll. */
31
31
  private schedulePoll;
32
32
  private stopPolling;
33
33
  private poll;
34
34
  }
35
+ export {};
@@ -1,16 +1,14 @@
1
- import { updateDeviceProfile } from "../core/device.js";
1
+ import { ReconnectingWebSocket } from "../core/ws.js";
2
2
  import { dbg } from "./logger.js";
3
- const SSE_WATCHDOG_TIMEOUT = 120_000; // 120s no data → reconnect (servers may not send keepalive frequently)
4
- const SSE_RECONNECT_DELAY = 3_000;
5
3
  const POLL_INTERVAL = 2_000;
6
4
  export class PromptListener {
7
5
  config;
8
6
  handler;
9
7
  log;
10
8
  processedIds = new Set();
11
- sseAbort = null;
9
+ rws = null;
12
10
  pollTimer = null;
13
- sseConnected = false;
11
+ wsConnected = false;
14
12
  stopped = false;
15
13
  constructor(config, handler, log) {
16
14
  this.config = config;
@@ -19,16 +17,14 @@ export class PromptListener {
19
17
  }
20
18
  start() {
21
19
  this.stopped = false;
22
- this.connectSSE();
20
+ this.connectWS();
23
21
  }
24
22
  stop() {
25
23
  this.stopped = true;
26
- this.sseAbort?.abort();
27
- this.sseAbort = null;
28
- if (this.pollTimer) {
29
- clearTimeout(this.pollTimer);
30
- this.pollTimer = null;
31
- }
24
+ this.rws?.stop();
25
+ this.rws = null;
26
+ this.wsConnected = false;
27
+ this.stopPolling();
32
28
  }
33
29
  dispatchPrompt(prompt) {
34
30
  if (this.processedIds.has(prompt.id)) {
@@ -44,116 +40,78 @@ export class PromptListener {
44
40
  }
45
41
  this.handler(prompt);
46
42
  }
47
- async connectSSE() {
48
- while (!this.stopped) {
49
- try {
50
- this.sseAbort = new AbortController();
51
- const url = `${this.config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(this.config.credentialId)}/events/stream?topic=prompts`;
52
- dbg(`[sse] Connecting to ${url}`);
53
- const response = await fetch(url, {
54
- headers: {
55
- "Accept": "text/event-stream",
56
- "x-zhihand-controller-token": this.config.controllerToken,
57
- },
58
- signal: this.sseAbort.signal,
59
- });
60
- if (!response.ok) {
61
- dbg(`[sse] Connect failed: ${response.status} ${response.statusText}`);
62
- throw new Error(`SSE connect failed: ${response.status}`);
63
- }
64
- this.sseConnected = true;
43
+ connectWS() {
44
+ if (this.stopped)
45
+ return;
46
+ const wsUrl = `${this.config.controlPlaneEndpoint.replace(/^http/, "ws")}/v1/credentials/${encodeURIComponent(this.config.credentialId)}/ws?topic=prompts`;
47
+ dbg(`[ws] Connecting to ${wsUrl}`);
48
+ this.rws = new ReconnectingWebSocket({
49
+ url: wsUrl,
50
+ headers: {
51
+ "Authorization": `Bearer ${this.config.controllerToken}`,
52
+ },
53
+ onOpen: () => {
54
+ this.wsConnected = true;
65
55
  this.stopPolling();
66
- this.log("[sse] Connected to prompt stream.");
67
- const reader = response.body?.getReader();
68
- if (!reader)
69
- throw new Error("No response body for SSE");
70
- const decoder = new TextDecoder();
71
- let buffer = "";
72
- let watchdog = this.resetWatchdog();
73
- try {
74
- while (!this.stopped) {
75
- const { done, value } = await reader.read();
76
- if (done)
77
- break;
78
- // Reset watchdog on any data (including keepalive comments)
79
- clearTimeout(watchdog);
80
- watchdog = this.resetWatchdog();
81
- buffer += decoder.decode(value, { stream: true });
82
- const lines = buffer.split("\n");
83
- buffer = lines.pop() ?? "";
84
- let eventData = "";
85
- for (const line of lines) {
86
- if (line.startsWith("data: ")) {
87
- eventData += (eventData ? "\n" : "") + line.slice(6);
88
- }
89
- else if (line === "" && eventData) {
90
- try {
91
- const event = JSON.parse(eventData);
92
- this.handleSSEEvent(event);
93
- }
94
- catch {
95
- // Malformed event
96
- }
97
- eventData = "";
98
- }
99
- }
100
- }
101
- }
102
- finally {
103
- // Always clear watchdog — prevents leaked timer from aborting next connection
104
- clearTimeout(watchdog);
56
+ this.log("[ws] Connected to prompt stream.");
57
+ },
58
+ onClose: (_code, _reason) => {
59
+ if (this.wsConnected) {
60
+ this.wsConnected = false;
61
+ this.log("[ws] Disconnected. Falling back to polling.");
62
+ this.startPolling();
105
63
  }
106
- }
107
- catch (err) {
108
- if (this.stopped)
109
- break;
110
- this.sseConnected = false;
111
- this.log(`[sse] Disconnected. Falling back to polling. (${err.message})`);
112
- this.startPolling();
113
- await new Promise((r) => setTimeout(r, SSE_RECONNECT_DELAY));
114
- }
115
- }
64
+ },
65
+ onMessage: (data) => {
66
+ this.handleWSMessage(data);
67
+ },
68
+ onError: (err) => {
69
+ dbg(`[ws] Error: ${err.message}`);
70
+ },
71
+ });
72
+ this.rws.start();
116
73
  }
117
- resetWatchdog() {
118
- return setTimeout(() => {
119
- this.log("[sse] Watchdog timeout (120s no data). Reconnecting...");
120
- this.sseAbort?.abort();
121
- }, SSE_WATCHDOG_TIMEOUT);
74
+ handleWSMessage(data) {
75
+ const msg = data;
76
+ // Application-level ping (if server sends these alongside protocol pings)
77
+ if (msg.type === "ping") {
78
+ this.rws?.send(JSON.stringify({ type: "pong" }));
79
+ return;
80
+ }
81
+ // Event dispatch
82
+ if (msg.type === "event" || msg.kind) {
83
+ this.handleEvent(msg);
84
+ }
122
85
  }
123
- handleSSEEvent(event) {
124
- dbg(`[sse] Event: kind=${event.kind}, prompt=${event.prompt?.id ?? "-"}, prompts=${event.prompts?.length ?? 0}`);
125
- if (event.kind === "prompt.queued" && event.prompt) {
86
+ handleEvent(event) {
87
+ const kind = event.kind;
88
+ dbg(`[ws] Event: kind=${kind}, prompt=${event.prompt?.id ?? "-"}, prompts=${event.prompts?.length ?? 0}`);
89
+ if (kind === "prompt.queued" && event.prompt) {
126
90
  this.dispatchPrompt(event.prompt);
127
91
  }
128
- else if (event.kind === "prompt.snapshot" && event.prompts) {
92
+ else if (kind === "prompt.snapshot" && event.prompts) {
129
93
  for (const p of event.prompts) {
130
94
  if (p.status === "pending" || p.status === "processing") {
131
95
  this.dispatchPrompt(p);
132
96
  }
133
97
  }
134
98
  }
135
- else if (event.kind === "device_profile.updated" && event.device_profile) {
136
- // NOTE: This event may only arrive if the server sends cross-topic events on
137
- // the prompts stream, or if the API is updated to support multi-topic SSE.
138
- // If not received, device profile is still fetched at daemon startup.
139
- this.log("[device] Device profile updated via SSE");
140
- updateDeviceProfile(event.device_profile);
99
+ else if (kind === "device_profile.updated") {
100
+ this.log("[device] device_profile.updated event received on prompts stream (ignored; registry handles it)");
141
101
  }
142
102
  }
143
103
  startPolling() {
144
- if (this.pollTimer)
104
+ if (this.pollTimer || this.stopped)
145
105
  return;
146
106
  this.schedulePoll();
147
107
  }
148
- /** Recursive setTimeout: waits for fetch to complete before scheduling next poll. */
149
108
  schedulePoll() {
150
109
  if (this.pollTimer)
151
110
  return;
152
111
  this.pollTimer = setTimeout(async () => {
153
112
  this.pollTimer = null;
154
113
  await this.poll();
155
- // Schedule next poll only if SSE is still disconnected
156
- if (!this.sseConnected && !this.stopped) {
114
+ if (!this.wsConnected && !this.stopped) {
157
115
  this.schedulePoll();
158
116
  }
159
117
  }, POLL_INTERVAL);
@@ -169,7 +127,7 @@ export class PromptListener {
169
127
  const url = `${this.config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(this.config.credentialId)}/prompts?limit=5`;
170
128
  dbg(`[poll] GET ${url}`);
171
129
  const response = await fetch(url, {
172
- headers: { "x-zhihand-controller-token": this.config.controllerToken },
130
+ headers: { "Authorization": `Bearer ${this.config.controllerToken}` },
173
131
  signal: AbortSignal.timeout(10_000),
174
132
  });
175
133
  if (!response.ok) {
@@ -178,6 +136,8 @@ export class PromptListener {
178
136
  }
179
137
  const data = (await response.json());
180
138
  dbg(`[poll] Got ${data.items?.length ?? 0} prompt(s)`);
139
+ if (this.stopped)
140
+ return; // Guard against late responses after stop()
181
141
  for (const prompt of data.items ?? []) {
182
142
  this.dispatchPrompt(prompt);
183
143
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- export declare const PACKAGE_VERSION = "0.29.0";
3
- export declare function createServer(deviceName?: string): McpServer;
4
- export declare function startStdioServer(deviceName?: string): Promise<void>;
2
+ export declare const PACKAGE_VERSION = "0.31.0";
3
+ export declare function createServer(): McpServer;
4
+ export declare function startStdioServer(): Promise<void>;
package/dist/index.js CHANGED
@@ -1,79 +1,143 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
- import { resolveConfig } from "./core/config.js";
4
- import { controlSchema, systemSchema, screenshotSchema, pairSchema } from "./tools/schemas.js";
3
+ import { controlSchema, systemSchema, screenshotSchema, pairSchema, listDevicesSchema, statusSchema, } from "./tools/schemas.js";
5
4
  import { executeControl } from "./tools/control.js";
6
5
  import { executeSystem } from "./tools/system.js";
7
6
  import { handleScreenshot } from "./tools/screenshot.js";
8
7
  import { handlePair } from "./tools/pair.js";
9
- import { getStaticContext, getDynamicContext, fetchDeviceProfile, buildControlToolDescription, buildSystemToolDescription, buildScreenshotToolDescription, formatDeviceStatus, } from "./core/device.js";
10
- export const PACKAGE_VERSION = "0.29.0";
11
- export function createServer(deviceName) {
8
+ import { resolveTargetDevice } from "./tools/resolve.js";
9
+ import { buildControlToolDescription, buildSystemToolDescription, buildScreenshotToolDescription, formatDeviceStatus, extractDynamic, } from "./core/device.js";
10
+ import { registry } from "./core/registry.js";
11
+ export const PACKAGE_VERSION = "0.31.0";
12
+ function errorResult(message) {
13
+ return { content: [{ type: "text", text: message }], isError: true };
14
+ }
15
+ export function createServer() {
12
16
  const server = new McpServer({
13
17
  name: "zhihand",
14
18
  version: PACKAGE_VERSION,
15
19
  });
16
- // zhihand_control main phone control tool
17
- // Description includes device info (platform, model, screen size) when available
18
- server.tool("zhihand_control", buildControlToolDescription(), controlSchema, async (params) => {
19
- const config = resolveConfig(deviceName);
20
- return await executeControl(config, params);
20
+ const multiUser = registry.isMultiUser();
21
+ const controlTool = server.tool("zhihand_control", buildControlToolDescription(null, registry.listOnline(), multiUser), controlSchema, async (params) => {
22
+ const resolved = resolveTargetDevice(params.device_id);
23
+ if ("error" in resolved)
24
+ return errorResult(resolved.error);
25
+ const { state } = resolved;
26
+ const cfg = registry.toRuntimeConfig(state);
27
+ const platform = state.profile?.platform ?? "unknown";
28
+ return await executeControl(cfg, params, platform, state.capabilities);
21
29
  });
22
- // zhihand_system system navigation + media controls (separate tool per Gemini design review)
23
- server.tool("zhihand_system", buildSystemToolDescription(), systemSchema, async (params) => {
24
- const config = resolveConfig(deviceName);
25
- return await executeSystem(config, params);
30
+ const systemTool = server.tool("zhihand_system", buildSystemToolDescription(null, registry.listOnline(), multiUser), systemSchema, async (params) => {
31
+ const resolved = resolveTargetDevice(params.device_id);
32
+ if ("error" in resolved)
33
+ return errorResult(resolved.error);
34
+ const { state } = resolved;
35
+ const cfg = registry.toRuntimeConfig(state);
36
+ const platform = state.profile?.platform ?? "unknown";
37
+ return await executeSystem(cfg, params, platform);
26
38
  });
27
- // zhihand_screenshot capture current screen without any action
28
- server.tool("zhihand_screenshot", buildScreenshotToolDescription(), screenshotSchema, async () => {
29
- const config = resolveConfig(deviceName);
30
- return await handleScreenshot(config);
39
+ const screenshotTool = server.tool("zhihand_screenshot", buildScreenshotToolDescription(null, registry.listOnline(), multiUser), screenshotSchema, async (params) => {
40
+ const resolved = resolveTargetDevice(params.device_id);
41
+ if ("error" in resolved)
42
+ return errorResult(resolved.error);
43
+ const { state } = resolved;
44
+ const cfg = registry.toRuntimeConfig(state);
45
+ return await handleScreenshot(cfg, state.capabilities);
31
46
  });
32
- // zhihand_status return device context for LLM to query on demand
33
- server.tool("zhihand_status", "Get device status and capability readiness. Returns curated fields (platform, model, OS, screen, battery, network, BLE, ...), a `capabilities` object with `ready`/`reason` for screen_sharing, hid, live_session, profile.age, AND a `raw` map of allowlisted device attributes (wire-format names). Call this BEFORE issuing commands if you are unsure whether the phone is screen-sharing or the ZhiHand (BLE HID) is connected.", {}, async () => {
47
+ server.tool("zhihand_status", "Get device status and capability readiness for a device. Returns curated fields (platform, model, OS, screen, battery, network, BLE, ...), a `capabilities` object with `ready`/`reason` for screen_sharing, hid, live_session, profile.age, AND a `raw` map of allowlisted device attributes. Pass device_id when multiple devices are online.", statusSchema, async (params) => {
48
+ const resolved = resolveTargetDevice(params.device_id);
49
+ if ("error" in resolved)
50
+ return errorResult(resolved.error);
34
51
  return {
35
52
  content: [{
36
53
  type: "text",
37
- text: JSON.stringify(formatDeviceStatus(), null, 2),
54
+ text: JSON.stringify(formatDeviceStatus(resolved.state), null, 2),
55
+ }],
56
+ };
57
+ });
58
+ server.tool("zhihand_list_devices", "List ALL configured ZhiHand devices with their online status. Returns device_id, label, platform, online, battery, is_default, last_active for each. Call this before zhihand_control/system/screenshot/status when multiple devices may be online.", listDevicesSchema, async () => {
59
+ const mu = registry.isMultiUser();
60
+ const defaultDev = registry.resolveDefault();
61
+ const devices = registry.list().map((d) => ({
62
+ device_id: d.credentialId,
63
+ label: mu ? `[${d.userLabel}] ${d.label}` : d.label,
64
+ platform: d.platform,
65
+ online: d.online,
66
+ battery: d.rawAttributes ? extractDynamic(d.rawAttributes).batteryLevel : null,
67
+ is_default: d === defaultDev,
68
+ last_active: d.lastSeenAtMs > 0 ? new Date(d.lastSeenAtMs).toISOString() : null,
69
+ }));
70
+ return {
71
+ content: [{
72
+ type: "text",
73
+ text: JSON.stringify({ devices }, null, 2),
38
74
  }],
39
75
  };
40
76
  });
41
- // zhihand_pair — device pairing
42
77
  server.tool("zhihand_pair", "Pair a new mobile device via QR code.", pairSchema, async (params) => {
43
78
  return await handlePair(params);
44
79
  });
45
- // device://profile MCP resource for device profile
46
- server.resource("device-profile", "device://profile", { description: "Device static and dynamic context (platform, model, screen, battery, network, etc.)" }, async () => {
47
- const staticCtx = getStaticContext();
48
- const dynamicCtx = getDynamicContext();
80
+ // Dynamic tool-description updates on online-set change.
81
+ registry.subscribe(() => {
82
+ const online = registry.listOnline();
83
+ const mu = registry.isMultiUser();
84
+ try {
85
+ controlTool.update({ description: buildControlToolDescription(null, online, mu) });
86
+ }
87
+ catch { /* best-effort */ }
88
+ try {
89
+ systemTool.update({ description: buildSystemToolDescription(null, online, mu) });
90
+ }
91
+ catch { /* best-effort */ }
92
+ try {
93
+ screenshotTool.update({ description: buildScreenshotToolDescription(null, online, mu) });
94
+ }
95
+ catch { /* best-effort */ }
96
+ try {
97
+ server.server.sendToolListChanged();
98
+ }
99
+ catch { /* best-effort */ }
100
+ });
101
+ // device://profile — returns default online device
102
+ server.resource("device-profile", "device://profile", { description: "Device static and dynamic context for the default online device." }, async () => {
103
+ const state = registry.resolveDefault();
104
+ if (!state) {
105
+ return {
106
+ contents: [{
107
+ uri: "device://profile",
108
+ mimeType: "application/json",
109
+ text: JSON.stringify({ error: "No device online" }, null, 2),
110
+ }],
111
+ };
112
+ }
49
113
  return {
50
114
  contents: [{
51
115
  uri: "device://profile",
52
116
  mimeType: "application/json",
53
- text: JSON.stringify({ ...staticCtx, ...dynamicCtx }, null, 2),
117
+ text: JSON.stringify(formatDeviceStatus(state), null, 2),
54
118
  }],
55
119
  };
56
120
  });
57
121
  return server;
58
122
  }
59
- export async function startStdioServer(deviceName) {
60
- // Fetch device profile before creating server so tool descriptions have platform info
61
- try {
62
- const config = resolveConfig(deviceName);
63
- await fetchDeviceProfile(config);
64
- }
65
- catch {
66
- // Non-fatal — server will use generic descriptions
67
- }
68
- const server = createServer(deviceName);
123
+ export async function startStdioServer() {
124
+ await registry.init();
125
+ const server = createServer();
69
126
  const transport = new StdioServerTransport();
70
127
  await server.connect(transport);
71
128
  }
72
- // Direct execution: start stdio server
129
+ function setupShutdown() {
130
+ const shutdown = () => {
131
+ registry.shutdown();
132
+ process.exit(0);
133
+ };
134
+ process.on("SIGINT", shutdown);
135
+ process.on("SIGTERM", shutdown);
136
+ }
137
+ setupShutdown();
73
138
  const isDirectRun = process.argv[1]?.endsWith("index.ts") || process.argv[1]?.endsWith("index.js");
74
139
  if (isDirectRun) {
75
- const deviceArg = process.argv.find((a) => a.startsWith("--device="))?.split("=")[1];
76
- startStdioServer(deviceArg ?? process.env.ZHIHAND_DEVICE).catch((err) => {
140
+ startStdioServer().catch((err) => {
77
141
  process.stderr.write(`ZhiHand MCP Server failed: ${err.message}\n`);
78
142
  process.exit(1);
79
143
  });
@@ -8,6 +8,7 @@ import { handleScreenshot } from "./tools/screenshot.js";
8
8
  import { handlePair } from "./tools/pair.js";
9
9
  import { detectCLITools, formatDetectedTools } from "./cli/detect.js";
10
10
  import { controlSchema, screenshotSchema, pairSchema } from "./tools/schemas.js";
11
+ import { registry } from "./core/registry.js";
11
12
  function zodSchemaToJsonSchema(zodShape) {
12
13
  // Simplified conversion — OpenClaw uses JSON Schema-like parameter objects.
13
14
  // The actual Zod schemas are used for validation inside tool handlers.
@@ -23,6 +24,8 @@ function zodSchemaToJsonSchema(zodShape) {
23
24
  }
24
25
  export function registerOpenClawTools(api, deviceName) {
25
26
  const log = (msg) => api.logger.info?.(msg);
27
+ // Kick off registry in the background so runtime config resolution benefits.
28
+ void registry.init().catch(() => { });
26
29
  // zhihand_control
27
30
  api.registerTool({
28
31
  name: "zhihand_control",
@@ -31,7 +34,10 @@ export function registerOpenClawTools(api, deviceName) {
31
34
  parameters: zodSchemaToJsonSchema(controlSchema),
32
35
  execute: async (_id, params) => {
33
36
  const config = resolveConfig(deviceName);
34
- const result = await executeControl(config, params);
37
+ const state = registry.get(config.credentialId);
38
+ const platform = state?.profile?.platform ?? "unknown";
39
+ const caps = state?.capabilities ?? null;
40
+ const result = await executeControl(config, params, platform, caps);
35
41
  return result;
36
42
  },
37
43
  });
@@ -43,7 +49,9 @@ export function registerOpenClawTools(api, deviceName) {
43
49
  parameters: zodSchemaToJsonSchema(screenshotSchema),
44
50
  execute: async (_id, _params) => {
45
51
  const config = resolveConfig(deviceName);
46
- const result = await handleScreenshot(config);
52
+ const state = registry.get(config.credentialId);
53
+ const caps = state?.capabilities ?? null;
54
+ const result = await handleScreenshot(config, caps);
47
55
  return result;
48
56
  },
49
57
  });
@@ -1,5 +1,7 @@
1
- import type { ZhiHandConfig } from "../core/config.ts";
1
+ import type { ZhiHandRuntimeConfig } from "../core/config.ts";
2
2
  import type { ControlParams } from "../core/command.ts";
3
+ import type { ScreenshotResult } from "../core/screenshot.ts";
4
+ import type { Capabilities } from "../core/device.ts";
3
5
  type TextContent = {
4
6
  type: "text";
5
7
  text: string;
@@ -13,6 +15,11 @@ type ToolContent = TextContent | ImageContent;
13
15
  type ToolResult = {
14
16
  content: ToolContent[];
15
17
  };
16
- export declare function executeControl(config: ZhiHandConfig, params: ControlParams): Promise<ToolResult>;
17
- export declare function executeScreenshot(config: ZhiHandConfig): Promise<ToolResult>;
18
+ /**
19
+ * Build a short human-readable warning for the LLM if the underlying
20
+ * capability isn't ready, or if the last screenshot is stale.
21
+ */
22
+ export declare function buildReadinessWarning(requiredCapability: "hid" | "screen" | "none", capabilities: Capabilities | null, screenshot: ScreenshotResult | null): string;
23
+ export declare function executeControl(config: ZhiHandRuntimeConfig, params: ControlParams, platform: string, capabilities: Capabilities | null): Promise<ToolResult>;
24
+ export declare function executeScreenshot(config: ZhiHandRuntimeConfig, capabilities: Capabilities | null): Promise<ToolResult>;
18
25
  export {};