@zhihand/mcp 0.25.0 → 0.26.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/README.md +14 -2
- package/bin/zhihand +7 -3
- package/dist/core/command.js +2 -1
- package/dist/core/device.d.ts +47 -0
- package/dist/core/device.js +171 -0
- package/dist/core/sse.js +1 -1
- package/dist/daemon/dispatcher.js +31 -3
- package/dist/daemon/index.js +10 -0
- package/dist/daemon/prompt-listener.js +8 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +35 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
ZhiHand MCP Server — let AI agents see and control your phone.
|
|
4
4
|
|
|
5
|
-
Version: `0.
|
|
5
|
+
Version: `0.26.0`
|
|
6
6
|
|
|
7
7
|
## What is this?
|
|
8
8
|
|
|
@@ -177,7 +177,7 @@ When you switch:
|
|
|
177
177
|
|
|
178
178
|
## MCP Tools
|
|
179
179
|
|
|
180
|
-
The server exposes
|
|
180
|
+
The server exposes these tools to AI agents:
|
|
181
181
|
|
|
182
182
|
### `zhihand_control`
|
|
183
183
|
|
|
@@ -212,6 +212,12 @@ Capture the current phone screen without performing any action. Returns an image
|
|
|
212
212
|
|
|
213
213
|
No parameters required.
|
|
214
214
|
|
|
215
|
+
### `zhihand_status`
|
|
216
|
+
|
|
217
|
+
Get device status: platform, model, OS version, screen size, battery, network, BLE connection, dark mode, storage, and more. No parameters.
|
|
218
|
+
|
|
219
|
+
Tool description and `open_app` guidance are **automatically adapted** based on the connected device platform (Android/iOS), so AI agents always send correct platform-specific parameters.
|
|
220
|
+
|
|
215
221
|
### `zhihand_pair`
|
|
216
222
|
|
|
217
223
|
Pair with a phone device. Returns a QR code and pairing URL.
|
|
@@ -220,6 +226,10 @@ Pair with a phone device. Returns a QR code and pairing URL.
|
|
|
220
226
|
|---|---|---|
|
|
221
227
|
| `forceNew` | `boolean` | Force new pairing even if already paired (default: `false`) |
|
|
222
228
|
|
|
229
|
+
### MCP Resource: `device://profile`
|
|
230
|
+
|
|
231
|
+
Provides full device context (static + dynamic) as JSON. Includes platform, model, OS version, screen size, battery, network, BLE, dark mode, storage, thermal state, locale, and more.
|
|
232
|
+
|
|
223
233
|
## How It Works
|
|
224
234
|
|
|
225
235
|
```
|
|
@@ -302,12 +312,14 @@ packages/mcp/
|
|
|
302
312
|
│ ├── core/
|
|
303
313
|
│ │ ├── config.ts # Credential & config management (~/.zhihand/), default models
|
|
304
314
|
│ │ ├── resolve-path.ts # Platform-aware executable path resolution (gemini/claude/codex)
|
|
315
|
+
│ │ ├── device.ts # Device context: static/dynamic profile, fetch, SSE updates
|
|
305
316
|
│ │ ├── command.ts # Command creation, enqueue, ACK formatting
|
|
306
317
|
│ │ ├── screenshot.ts # Binary screenshot fetch (JPEG)
|
|
307
318
|
│ │ ├── sse.ts # SSE client + hybrid ACK (SSE push + polling fallback)
|
|
308
319
|
│ │ └── pair.ts # Plugin registration + device pairing flow
|
|
309
320
|
│ ├── daemon/
|
|
310
321
|
│ │ ├── index.ts # Daemon entry: HTTP server + MCP + Relay + Config API
|
|
322
|
+
│ │ ├── logger.ts # Debug logger (--debug flag)
|
|
311
323
|
│ │ ├── heartbeat.ts # Brain heartbeat loop (30s interval, 5s retry)
|
|
312
324
|
│ │ ├── prompt-listener.ts # SSE + polling prompt listener with dedup
|
|
313
325
|
│ │ └── dispatcher.ts # Async CLI dispatch (spawn + timeout + two-stage kill)
|
package/bin/zhihand
CHANGED
|
@@ -311,8 +311,9 @@ switch (command) {
|
|
|
311
311
|
{ label: "3. Swipe up", type: "hid", params: { action: "swipe", startXRatio: 0.5, startYRatio: 0.7, endXRatio: 0.5, endYRatio: 0.3, durationMs: 300 } },
|
|
312
312
|
{ label: "4. Swipe down", type: "hid", params: { action: "swipe", startXRatio: 0.5, startYRatio: 0.3, endXRatio: 0.5, endYRatio: 0.7, durationMs: 300 } },
|
|
313
313
|
{ label: "5. Press Home", type: "hid", params: { action: "home" } },
|
|
314
|
-
{ label: "6.
|
|
315
|
-
{ label: "7.
|
|
314
|
+
{ label: "6. Open WeChat", type: "hid", params: { action: "open_app", appPackage: "com.tencent.mm" } },
|
|
315
|
+
{ label: "7. Press Back", type: "hid", params: { action: "back" } },
|
|
316
|
+
{ label: "8. Screenshot", type: "screenshot" },
|
|
316
317
|
];
|
|
317
318
|
|
|
318
319
|
let passed = 0;
|
|
@@ -342,7 +343,10 @@ switch (command) {
|
|
|
342
343
|
const ack = await waitForCommandAck(testConfig, { commandId: queued.id, timeoutMs: 10_000 });
|
|
343
344
|
const ms = Date.now() - t0;
|
|
344
345
|
if (ack.acked) {
|
|
345
|
-
|
|
346
|
+
const ackStatus = ack.command?.ack_status ?? "ok";
|
|
347
|
+
const detail = ackStatus !== "ok" ? ` [${ackStatus}]` : "";
|
|
348
|
+
const resultInfo = ack.command?.ack_result ? ` ${JSON.stringify(ack.command.ack_result)}` : "";
|
|
349
|
+
console.log(`✅ (${ms}ms)${detail}${resultInfo}`);
|
|
346
350
|
passed++;
|
|
347
351
|
} else {
|
|
348
352
|
console.log(`⏱️ Timeout (${ms}ms)`);
|
package/dist/core/command.js
CHANGED
|
@@ -104,7 +104,8 @@ export async function getCommand(config, commandId) {
|
|
|
104
104
|
throw new Error(`Get command failed: ${response.status}`);
|
|
105
105
|
}
|
|
106
106
|
const payload = (await response.json());
|
|
107
|
-
|
|
107
|
+
const cmd = payload.command;
|
|
108
|
+
dbg(`[cmd] Got: id=${cmd.id}, status=${cmd.status}, acked=${!!cmd.acked_at}, ack_status=${cmd.ack_status ?? "-"}, ack_result=${JSON.stringify(cmd.ack_result ?? null)}`);
|
|
108
109
|
return payload.command;
|
|
109
110
|
}
|
|
110
111
|
export function formatAckSummary(action, result) {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device Context — static + dynamic device info fetched from control plane.
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
import type { ZhiHandConfig } from "./config.ts";
|
|
11
|
+
export interface StaticContext {
|
|
12
|
+
platform: string;
|
|
13
|
+
model: string;
|
|
14
|
+
osVersion: string;
|
|
15
|
+
screenWidthPx: number;
|
|
16
|
+
screenHeightPx: number;
|
|
17
|
+
density: number;
|
|
18
|
+
formFactor: string;
|
|
19
|
+
locale: string;
|
|
20
|
+
textDirection: string;
|
|
21
|
+
timezone: string;
|
|
22
|
+
navigationMode?: string;
|
|
23
|
+
romFamily?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface DynamicContext {
|
|
26
|
+
batteryLevel: number;
|
|
27
|
+
batteryState: string;
|
|
28
|
+
networkType: string;
|
|
29
|
+
bleRssi: number | null;
|
|
30
|
+
darkMode: boolean;
|
|
31
|
+
hidConnected: boolean;
|
|
32
|
+
recordingActive: boolean;
|
|
33
|
+
appInForeground: boolean;
|
|
34
|
+
availableStorageMb: number;
|
|
35
|
+
thermalState?: string;
|
|
36
|
+
fontScale: number;
|
|
37
|
+
}
|
|
38
|
+
export declare function getStaticContext(): StaticContext;
|
|
39
|
+
export declare function getDynamicContext(): DynamicContext;
|
|
40
|
+
export declare function isDeviceProfileLoaded(): boolean;
|
|
41
|
+
export declare function extractStatic(profile: Record<string, unknown>): StaticContext;
|
|
42
|
+
export declare function extractDynamic(profile: Record<string, unknown>): DynamicContext;
|
|
43
|
+
export declare function updateDeviceProfile(profile: Record<string, unknown>): void;
|
|
44
|
+
export declare function fetchDeviceProfile(config: ZhiHandConfig): Promise<void>;
|
|
45
|
+
export declare function buildControlToolDescription(): string;
|
|
46
|
+
export declare function buildScreenshotToolDescription(): string;
|
|
47
|
+
export declare function formatDeviceStatus(): Record<string, unknown>;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device Context — static + dynamic device info fetched from control plane.
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
import { dbg } from "../daemon/logger.js";
|
|
11
|
+
// ── Default values ────────────────────────────────────────
|
|
12
|
+
const DEFAULT_STATIC = {
|
|
13
|
+
platform: "unknown",
|
|
14
|
+
model: "unknown",
|
|
15
|
+
osVersion: "unknown",
|
|
16
|
+
screenWidthPx: 0,
|
|
17
|
+
screenHeightPx: 0,
|
|
18
|
+
density: 1,
|
|
19
|
+
formFactor: "phone",
|
|
20
|
+
locale: "en-US",
|
|
21
|
+
textDirection: "ltr",
|
|
22
|
+
timezone: "UTC",
|
|
23
|
+
};
|
|
24
|
+
const DEFAULT_DYNAMIC = {
|
|
25
|
+
batteryLevel: -1,
|
|
26
|
+
batteryState: "unknown",
|
|
27
|
+
networkType: "unknown",
|
|
28
|
+
bleRssi: null,
|
|
29
|
+
darkMode: false,
|
|
30
|
+
hidConnected: false,
|
|
31
|
+
recordingActive: false,
|
|
32
|
+
appInForeground: false,
|
|
33
|
+
availableStorageMb: -1,
|
|
34
|
+
fontScale: 1,
|
|
35
|
+
};
|
|
36
|
+
// ── Module state ──────────────────────────────────────────
|
|
37
|
+
let staticCtx = { ...DEFAULT_STATIC };
|
|
38
|
+
let dynamicCtx = { ...DEFAULT_DYNAMIC };
|
|
39
|
+
let loaded = false;
|
|
40
|
+
export function getStaticContext() {
|
|
41
|
+
return staticCtx;
|
|
42
|
+
}
|
|
43
|
+
export function getDynamicContext() {
|
|
44
|
+
return dynamicCtx;
|
|
45
|
+
}
|
|
46
|
+
export function isDeviceProfileLoaded() {
|
|
47
|
+
return loaded;
|
|
48
|
+
}
|
|
49
|
+
// ── Extract helpers ───────────────────────────────────────
|
|
50
|
+
function str(v, fallback) {
|
|
51
|
+
return typeof v === "string" && v ? v : fallback;
|
|
52
|
+
}
|
|
53
|
+
function num(v, fallback) {
|
|
54
|
+
return typeof v === "number" && !isNaN(v) ? v : fallback;
|
|
55
|
+
}
|
|
56
|
+
function bool(v, fallback) {
|
|
57
|
+
return typeof v === "boolean" ? v : fallback;
|
|
58
|
+
}
|
|
59
|
+
export function extractStatic(profile) {
|
|
60
|
+
return {
|
|
61
|
+
platform: str(profile.platform, DEFAULT_STATIC.platform),
|
|
62
|
+
model: str(profile.model, DEFAULT_STATIC.model),
|
|
63
|
+
osVersion: str(profile.os_version, DEFAULT_STATIC.osVersion),
|
|
64
|
+
screenWidthPx: num(profile.screen_width_px, DEFAULT_STATIC.screenWidthPx),
|
|
65
|
+
screenHeightPx: num(profile.screen_height_px, DEFAULT_STATIC.screenHeightPx),
|
|
66
|
+
density: num(profile.density, DEFAULT_STATIC.density),
|
|
67
|
+
formFactor: str(profile.form_factor, DEFAULT_STATIC.formFactor),
|
|
68
|
+
locale: str(profile.locale, DEFAULT_STATIC.locale),
|
|
69
|
+
textDirection: str(profile.text_direction, DEFAULT_STATIC.textDirection),
|
|
70
|
+
timezone: str(profile.timezone, DEFAULT_STATIC.timezone),
|
|
71
|
+
navigationMode: typeof profile.navigation_mode === "string" ? profile.navigation_mode : undefined,
|
|
72
|
+
romFamily: typeof profile.rom_family === "string" ? profile.rom_family : undefined,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export function extractDynamic(profile) {
|
|
76
|
+
return {
|
|
77
|
+
batteryLevel: num(profile.battery_level, DEFAULT_DYNAMIC.batteryLevel),
|
|
78
|
+
batteryState: str(profile.battery_state, DEFAULT_DYNAMIC.batteryState),
|
|
79
|
+
networkType: str(profile.network_type, DEFAULT_DYNAMIC.networkType),
|
|
80
|
+
bleRssi: typeof profile.ble_rssi === "number" ? profile.ble_rssi : null,
|
|
81
|
+
darkMode: bool(profile.dark_mode, DEFAULT_DYNAMIC.darkMode),
|
|
82
|
+
hidConnected: bool(profile.hid_connected, DEFAULT_DYNAMIC.hidConnected),
|
|
83
|
+
recordingActive: bool(profile.recording_active, DEFAULT_DYNAMIC.recordingActive),
|
|
84
|
+
appInForeground: bool(profile.app_in_foreground, DEFAULT_DYNAMIC.appInForeground),
|
|
85
|
+
availableStorageMb: num(profile.available_storage_mb, DEFAULT_DYNAMIC.availableStorageMb),
|
|
86
|
+
thermalState: typeof profile.thermal_state === "string" ? profile.thermal_state : undefined,
|
|
87
|
+
fontScale: num(profile.font_scale, DEFAULT_DYNAMIC.fontScale),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// ── Update from SSE event ─────────────────────────────────
|
|
91
|
+
export function updateDeviceProfile(profile) {
|
|
92
|
+
staticCtx = extractStatic(profile);
|
|
93
|
+
dynamicCtx = extractDynamic(profile);
|
|
94
|
+
loaded = true;
|
|
95
|
+
dbg(`[device] Profile updated: platform=${staticCtx.platform}, model=${staticCtx.model}, screen=${staticCtx.screenWidthPx}x${staticCtx.screenHeightPx}`);
|
|
96
|
+
}
|
|
97
|
+
// ── Fetch initial profile from API ────────────────────────
|
|
98
|
+
export async function fetchDeviceProfile(config) {
|
|
99
|
+
const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/device-profile`;
|
|
100
|
+
dbg(`[device] Fetching profile: GET ${url}`);
|
|
101
|
+
try {
|
|
102
|
+
const response = await fetch(url, {
|
|
103
|
+
headers: { "x-zhihand-controller-token": config.controllerToken },
|
|
104
|
+
signal: AbortSignal.timeout(10_000),
|
|
105
|
+
});
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
dbg(`[device] Profile fetch failed: ${response.status} ${response.statusText}`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const data = await response.json();
|
|
111
|
+
// API may wrap in { device_profile: {...} } or return flat
|
|
112
|
+
const profile = (typeof data.device_profile === "object" && data.device_profile !== null)
|
|
113
|
+
? data.device_profile
|
|
114
|
+
: data;
|
|
115
|
+
updateDeviceProfile(profile);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
dbg(`[device] Profile fetch error: ${err.message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// ── Build tool description with device info ───────────────
|
|
122
|
+
export function buildControlToolDescription() {
|
|
123
|
+
if (!loaded || staticCtx.platform === "unknown") {
|
|
124
|
+
return "Control the connected mobile device. Supports click, swipe, type, scroll, open_app, back, home, and more. All coordinates use normalized ratios [0,1].";
|
|
125
|
+
}
|
|
126
|
+
const parts = [
|
|
127
|
+
`Control a ${staticCtx.platform} device`,
|
|
128
|
+
`(${staticCtx.model}, ${staticCtx.osVersion}`,
|
|
129
|
+
`${staticCtx.screenWidthPx}x${staticCtx.screenHeightPx}`,
|
|
130
|
+
`${staticCtx.formFactor}, ${staticCtx.locale})`,
|
|
131
|
+
];
|
|
132
|
+
let desc = parts.join(", ") + ".";
|
|
133
|
+
desc += " All coordinates use normalized ratios [0,1].";
|
|
134
|
+
// Platform-specific open_app guidance
|
|
135
|
+
if (staticCtx.platform === "android") {
|
|
136
|
+
desc += " For open_app, use appPackage (e.g. 'com.tencent.mm'). Do NOT send bundleId or urlScheme.";
|
|
137
|
+
}
|
|
138
|
+
else if (staticCtx.platform === "ios") {
|
|
139
|
+
desc += " For open_app, use bundleId (e.g. 'com.tencent.xin') or urlScheme (e.g. 'weixin://'). Do NOT send appPackage.";
|
|
140
|
+
}
|
|
141
|
+
return desc;
|
|
142
|
+
}
|
|
143
|
+
export function buildScreenshotToolDescription() {
|
|
144
|
+
if (!loaded || staticCtx.platform === "unknown") {
|
|
145
|
+
return "Take a screenshot of the phone screen.";
|
|
146
|
+
}
|
|
147
|
+
return `Take a screenshot of the ${staticCtx.platform} device (${staticCtx.model}, ${staticCtx.screenWidthPx}x${staticCtx.screenHeightPx}).`;
|
|
148
|
+
}
|
|
149
|
+
// ── Format status for zhihand_status tool ─────────────────
|
|
150
|
+
export function formatDeviceStatus() {
|
|
151
|
+
return {
|
|
152
|
+
platform: staticCtx.platform,
|
|
153
|
+
model: staticCtx.model,
|
|
154
|
+
os_version: staticCtx.osVersion,
|
|
155
|
+
screen: `${staticCtx.screenWidthPx}x${staticCtx.screenHeightPx}`,
|
|
156
|
+
density: staticCtx.density,
|
|
157
|
+
form_factor: staticCtx.formFactor,
|
|
158
|
+
locale: staticCtx.locale,
|
|
159
|
+
timezone: staticCtx.timezone,
|
|
160
|
+
navigation_mode: staticCtx.navigationMode ?? null,
|
|
161
|
+
battery: `${dynamicCtx.batteryLevel}% (${dynamicCtx.batteryState})`,
|
|
162
|
+
network: dynamicCtx.networkType,
|
|
163
|
+
ble: dynamicCtx.hidConnected
|
|
164
|
+
? `connected${dynamicCtx.bleRssi !== null ? ` (RSSI: ${dynamicCtx.bleRssi})` : ""}`
|
|
165
|
+
: "disconnected",
|
|
166
|
+
dark_mode: dynamicCtx.darkMode,
|
|
167
|
+
storage_available_mb: dynamicCtx.availableStorageMb,
|
|
168
|
+
thermal: dynamicCtx.thermalState ?? "normal",
|
|
169
|
+
font_scale: dynamicCtx.fontScale,
|
|
170
|
+
};
|
|
171
|
+
}
|
package/dist/core/sse.js
CHANGED
|
@@ -10,7 +10,7 @@ export function handleSSEEvent(event) {
|
|
|
10
10
|
if (event.kind === "command.acked" && event.command) {
|
|
11
11
|
const callback = ackCallbacks.get(event.command.id);
|
|
12
12
|
if (callback) {
|
|
13
|
-
dbg(`[sse-cmd] ACK callback for ${event.command.id}, ack_status=${event.command.ack_status}`);
|
|
13
|
+
dbg(`[sse-cmd] ACK callback for ${event.command.id}, ack_status=${event.command.ack_status}, ack_result=${JSON.stringify(event.command.ack_result ?? null)}`);
|
|
14
14
|
callback(event.command);
|
|
15
15
|
ackCallbacks.delete(event.command.id);
|
|
16
16
|
}
|
|
@@ -5,6 +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
9
|
import { dbg } from "./logger.js";
|
|
9
10
|
const CLI_TIMEOUT = 300_000; // 300s (5min) per prompt — MCP tool chains need multiple turns
|
|
10
11
|
const SIGKILL_DELAY = 2_000; // 2s after SIGTERM
|
|
@@ -351,7 +352,30 @@ export async function killActiveChild() {
|
|
|
351
352
|
conversationHistory.length = 0;
|
|
352
353
|
}
|
|
353
354
|
// ── System Prompt ─────────────────────────────────────────
|
|
354
|
-
|
|
355
|
+
/**
|
|
356
|
+
* Build system context dynamically — injects device platform info when available
|
|
357
|
+
* so the AI sends correct platform-specific parameters (e.g. appPackage vs bundleId).
|
|
358
|
+
*/
|
|
359
|
+
function buildSystemContext() {
|
|
360
|
+
const static_ = isDeviceProfileLoaded() ? getStaticContext() : null;
|
|
361
|
+
const deviceLine = static_
|
|
362
|
+
? `Connected device: ${static_.platform} ${static_.model} (${static_.osVersion}), ${static_.screenWidthPx}x${static_.screenHeightPx}, ${static_.formFactor}, ${static_.locale}`
|
|
363
|
+
: "Connected device: unknown platform";
|
|
364
|
+
// Platform-specific open_app guidance
|
|
365
|
+
let openAppDoc;
|
|
366
|
+
if (static_?.platform === "android") {
|
|
367
|
+
openAppDoc = "- open_app: Open an app. Params: appPackage (e.g. 'com.tencent.mm'). Do NOT send bundleId or urlScheme on Android.";
|
|
368
|
+
}
|
|
369
|
+
else if (static_?.platform === "ios") {
|
|
370
|
+
openAppDoc = "- open_app: Open an app. Params: bundleId (e.g. 'com.tencent.xin') or urlScheme (e.g. 'weixin://'). Do NOT send appPackage on iOS.";
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
openAppDoc = "- open_app: Open an app. Params: appPackage (Android, e.g. 'com.tencent.mm'), bundleId (iOS), urlScheme (e.g. 'weixin://')";
|
|
374
|
+
}
|
|
375
|
+
return `You are ZhiHand, an AI assistant connected to the user's mobile phone via MCP tools.
|
|
376
|
+
|
|
377
|
+
## Device
|
|
378
|
+
${deviceLine}
|
|
355
379
|
|
|
356
380
|
## Available MCP Tools
|
|
357
381
|
|
|
@@ -372,22 +396,26 @@ Control the phone. Requires "action" parameter. All coordinates use normalized r
|
|
|
372
396
|
- back: Press Back button (no params)
|
|
373
397
|
- home: Press Home button (no params)
|
|
374
398
|
- enter: Press Enter key (no params)
|
|
375
|
-
|
|
399
|
+
${openAppDoc}
|
|
376
400
|
- clipboard: Read/write clipboard. Params: clipboardAction ("get"/"set"), text
|
|
377
401
|
- screenshot: Capture screen via control (same as zhihand_screenshot)
|
|
378
402
|
- wait: Wait before next action. Params: durationMs (default 1000)
|
|
379
403
|
|
|
404
|
+
### zhihand_status
|
|
405
|
+
Get device status: platform, battery, network, BLE connection, dark mode, storage, etc.
|
|
406
|
+
|
|
380
407
|
## Rules
|
|
381
408
|
- When the user asks to see their screen, ALWAYS call zhihand_screenshot first.
|
|
382
409
|
- When the user asks to open an app (e.g. WeChat, Settings), use open_app action.
|
|
383
410
|
- When the user asks to go back/home, use back/home actions.
|
|
384
411
|
- For all tap/click operations, use xRatio and yRatio (0-1 normalized coordinates based on the screenshot).`;
|
|
412
|
+
}
|
|
385
413
|
/**
|
|
386
414
|
* Build the full system prompt with optional conversation history.
|
|
387
415
|
* Used for first prompt in persistent sessions and all one-shot calls.
|
|
388
416
|
*/
|
|
389
417
|
function wrapPrompt(userPrompt, history) {
|
|
390
|
-
let result =
|
|
418
|
+
let result = buildSystemContext();
|
|
391
419
|
if (history && history.length > 0) {
|
|
392
420
|
result += "\n\n## Recent Conversation\n";
|
|
393
421
|
for (const turn of history) {
|
package/dist/daemon/index.js
CHANGED
|
@@ -11,6 +11,7 @@ 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
15
|
const DEFAULT_PORT = 18686;
|
|
15
16
|
const PID_FILE = "daemon.pid";
|
|
16
17
|
// ── State ──────────────────────────────────────────────────
|
|
@@ -189,6 +190,15 @@ export async function startDaemon(options) {
|
|
|
189
190
|
else {
|
|
190
191
|
log(`[config] No backend configured. Use: zhihand gemini / zhihand claude / zhihand codex`);
|
|
191
192
|
}
|
|
193
|
+
// Fetch device profile (platform, model, screen size) — non-blocking, best-effort
|
|
194
|
+
await fetchDeviceProfile(config);
|
|
195
|
+
if (isDeviceProfileLoaded()) {
|
|
196
|
+
const s = getStaticContext();
|
|
197
|
+
log(`[device] ${s.platform} ${s.model} (${s.osVersion}), ${s.screenWidthPx}x${s.screenHeightPx}, ${s.locale}`);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
log(`[device] Device profile not available — tool descriptions will use generic defaults`);
|
|
201
|
+
}
|
|
192
202
|
// MCP sessions: each client gets its own McpServer + Transport pair
|
|
193
203
|
// because McpServer.connect() can only be called once per instance
|
|
194
204
|
const MAX_MCP_SESSIONS = 20;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { updateDeviceProfile } from "../core/device.js";
|
|
1
2
|
import { dbg } from "./logger.js";
|
|
2
3
|
const SSE_WATCHDOG_TIMEOUT = 120_000; // 120s no data → reconnect (servers may not send keepalive frequently)
|
|
3
4
|
const SSE_RECONNECT_DELAY = 3_000;
|
|
@@ -131,6 +132,13 @@ export class PromptListener {
|
|
|
131
132
|
}
|
|
132
133
|
}
|
|
133
134
|
}
|
|
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);
|
|
141
|
+
}
|
|
134
142
|
}
|
|
135
143
|
startPolling() {
|
|
136
144
|
if (this.pollTimer)
|
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.
|
|
2
|
+
export declare const PACKAGE_VERSION = "0.26.0";
|
|
3
3
|
export declare function createServer(deviceName?: string): McpServer;
|
|
4
4
|
export declare function startStdioServer(deviceName?: string): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -5,29 +5,60 @@ import { controlSchema, screenshotSchema, pairSchema } from "./tools/schemas.js"
|
|
|
5
5
|
import { executeControl } from "./tools/control.js";
|
|
6
6
|
import { handleScreenshot } from "./tools/screenshot.js";
|
|
7
7
|
import { handlePair } from "./tools/pair.js";
|
|
8
|
-
|
|
8
|
+
import { getStaticContext, getDynamicContext, fetchDeviceProfile, buildControlToolDescription, buildScreenshotToolDescription, formatDeviceStatus, } from "./core/device.js";
|
|
9
|
+
export const PACKAGE_VERSION = "0.26.0";
|
|
9
10
|
export function createServer(deviceName) {
|
|
10
11
|
const server = new McpServer({
|
|
11
12
|
name: "zhihand",
|
|
12
13
|
version: PACKAGE_VERSION,
|
|
13
14
|
});
|
|
14
15
|
// zhihand_control — main phone control tool
|
|
15
|
-
|
|
16
|
+
// Description includes device info (platform, model, screen size) when available
|
|
17
|
+
server.tool("zhihand_control", buildControlToolDescription(), controlSchema, async (params) => {
|
|
16
18
|
const config = resolveConfig(deviceName);
|
|
17
19
|
return await executeControl(config, params);
|
|
18
20
|
});
|
|
19
21
|
// zhihand_screenshot — capture current screen without any action
|
|
20
|
-
server.tool("zhihand_screenshot", screenshotSchema, async () => {
|
|
22
|
+
server.tool("zhihand_screenshot", buildScreenshotToolDescription(), screenshotSchema, async () => {
|
|
21
23
|
const config = resolveConfig(deviceName);
|
|
22
24
|
return await handleScreenshot(config);
|
|
23
25
|
});
|
|
26
|
+
// zhihand_status — return device context for LLM to query on demand
|
|
27
|
+
server.tool("zhihand_status", "Get device status: platform, model, OS version, screen size, battery, network, BLE, dark mode, storage, and more.", {}, async () => {
|
|
28
|
+
return {
|
|
29
|
+
content: [{
|
|
30
|
+
type: "text",
|
|
31
|
+
text: JSON.stringify(formatDeviceStatus(), null, 2),
|
|
32
|
+
}],
|
|
33
|
+
};
|
|
34
|
+
});
|
|
24
35
|
// zhihand_pair — device pairing
|
|
25
|
-
server.tool("zhihand_pair", pairSchema, async (params) => {
|
|
36
|
+
server.tool("zhihand_pair", "Pair a new mobile device via QR code.", pairSchema, async (params) => {
|
|
26
37
|
return await handlePair(params);
|
|
27
38
|
});
|
|
39
|
+
// device://profile — MCP resource for device profile
|
|
40
|
+
server.resource("device-profile", "device://profile", { description: "Device static and dynamic context (platform, model, screen, battery, network, etc.)" }, async () => {
|
|
41
|
+
const staticCtx = getStaticContext();
|
|
42
|
+
const dynamicCtx = getDynamicContext();
|
|
43
|
+
return {
|
|
44
|
+
contents: [{
|
|
45
|
+
uri: "device://profile",
|
|
46
|
+
mimeType: "application/json",
|
|
47
|
+
text: JSON.stringify({ ...staticCtx, ...dynamicCtx }, null, 2),
|
|
48
|
+
}],
|
|
49
|
+
};
|
|
50
|
+
});
|
|
28
51
|
return server;
|
|
29
52
|
}
|
|
30
53
|
export async function startStdioServer(deviceName) {
|
|
54
|
+
// Fetch device profile before creating server so tool descriptions have platform info
|
|
55
|
+
try {
|
|
56
|
+
const config = resolveConfig(deviceName);
|
|
57
|
+
await fetchDeviceProfile(config);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Non-fatal — server will use generic descriptions
|
|
61
|
+
}
|
|
31
62
|
const server = createServer(deviceName);
|
|
32
63
|
const transport = new StdioServerTransport();
|
|
33
64
|
await server.connect(transport);
|