@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.
- package/bin/zhihand +448 -212
- package/dist/core/command.d.ts +5 -5
- package/dist/core/command.js +6 -8
- package/dist/core/config.d.ts +48 -21
- package/dist/core/config.js +178 -42
- package/dist/core/device.d.ts +28 -19
- package/dist/core/device.js +168 -145
- package/dist/core/logger.d.ts +17 -0
- package/dist/core/logger.js +32 -0
- package/dist/core/pair.d.ts +39 -31
- package/dist/core/pair.js +205 -77
- package/dist/core/registry.d.ts +60 -0
- package/dist/core/registry.js +415 -0
- package/dist/core/screenshot.d.ts +3 -3
- package/dist/core/screenshot.js +3 -2
- package/dist/core/sse.d.ts +40 -18
- package/dist/core/sse.js +122 -62
- package/dist/core/ws.d.ts +92 -0
- package/dist/core/ws.js +327 -0
- package/dist/daemon/dispatcher.d.ts +3 -1
- package/dist/daemon/dispatcher.js +4 -3
- package/dist/daemon/heartbeat.d.ts +4 -4
- package/dist/daemon/heartbeat.js +1 -1
- package/dist/daemon/index.js +10 -8
- package/dist/daemon/prompt-listener.d.ts +8 -7
- package/dist/daemon/prompt-listener.js +59 -99
- package/dist/index.d.ts +3 -3
- package/dist/index.js +104 -40
- package/dist/openclaw.adapter.js +10 -2
- package/dist/tools/control.d.ts +10 -3
- package/dist/tools/control.js +18 -24
- package/dist/tools/pair.d.ts +1 -1
- package/dist/tools/pair.js +22 -28
- package/dist/tools/resolve.d.ts +7 -0
- package/dist/tools/resolve.js +22 -0
- package/dist/tools/schemas.d.ts +9 -1
- package/dist/tools/schemas.js +10 -8
- package/dist/tools/screenshot.d.ts +3 -2
- package/dist/tools/screenshot.js +2 -2
- package/dist/tools/system.d.ts +3 -5
- package/dist/tools/system.js +19 -6
- package/package.json +3 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
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 {
|
|
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
|
|
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
|
-
"
|
|
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 {
|
|
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:
|
|
10
|
-
export declare function sendBrainOffline(config:
|
|
11
|
-
export declare function startHeartbeatLoop(config:
|
|
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;
|
package/dist/daemon/heartbeat.js
CHANGED
|
@@ -25,7 +25,7 @@ async function sendHeartbeat(config, online) {
|
|
|
25
25
|
method: "POST",
|
|
26
26
|
headers: {
|
|
27
27
|
"Content-Type": "application/json",
|
|
28
|
-
"
|
|
28
|
+
"Authorization": `Bearer ${config.controllerToken}`,
|
|
29
29
|
},
|
|
30
30
|
body: JSON.stringify(body),
|
|
31
31
|
signal: AbortSignal.timeout(10_000),
|
package/dist/daemon/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
194
|
-
await
|
|
195
|
-
|
|
196
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
19
|
+
private rws;
|
|
19
20
|
private pollTimer;
|
|
20
|
-
private
|
|
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
|
|
27
|
-
private
|
|
28
|
-
private
|
|
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 {
|
|
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
|
-
|
|
9
|
+
rws = null;
|
|
12
10
|
pollTimer = null;
|
|
13
|
-
|
|
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.
|
|
20
|
+
this.connectWS();
|
|
23
21
|
}
|
|
24
22
|
stop() {
|
|
25
23
|
this.stopped = true;
|
|
26
|
-
this.
|
|
27
|
-
this.
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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("[
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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 (
|
|
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 (
|
|
136
|
-
|
|
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
|
-
|
|
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: { "
|
|
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.
|
|
3
|
-
export declare function createServer(
|
|
4
|
-
export declare function startStdioServer(
|
|
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 {
|
|
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 {
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
//
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
const
|
|
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(
|
|
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(
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dist/openclaw.adapter.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
});
|
package/dist/tools/control.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
|
|
17
|
-
|
|
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 {};
|