@zhihand/mcp 0.23.1 → 0.25.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 +5 -1
- package/bin/zhihand +87 -0
- package/dist/core/command.js +13 -5
- package/dist/core/screenshot.js +11 -3
- package/dist/core/sse.js +4 -0
- package/dist/daemon/dispatcher.js +45 -5
- package/dist/daemon/heartbeat.js +7 -2
- package/dist/daemon/index.d.ts +1 -0
- package/dist/daemon/index.js +13 -0
- package/dist/daemon/logger.d.ts +10 -0
- package/dist/daemon/logger.js +22 -0
- package/dist/daemon/prompt-listener.js +15 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- 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.25.0`
|
|
6
6
|
|
|
7
7
|
## What is this?
|
|
8
8
|
|
|
@@ -93,9 +93,11 @@ Once configured, your AI agent can use ZhiHand tools directly. For example, in C
|
|
|
93
93
|
zhihand setup Interactive setup: pair + detect tools + auto-select + configure MCP + start daemon
|
|
94
94
|
zhihand start Start daemon (MCP Server + Relay + Config API)
|
|
95
95
|
zhihand start -d Start daemon in background (logs to ~/.zhihand/daemon.log)
|
|
96
|
+
zhihand start --debug Start daemon with verbose debug logging
|
|
96
97
|
zhihand stop Stop the running daemon
|
|
97
98
|
zhihand status Show daemon status, pairing info, device, backend, and model
|
|
98
99
|
|
|
100
|
+
zhihand test Test device connectivity (screenshot, click, swipe, home, back)
|
|
99
101
|
zhihand pair Pair with a phone (QR code in terminal)
|
|
100
102
|
zhihand detect List detected CLI tools and their login status
|
|
101
103
|
zhihand serve Start MCP Server (stdio mode, backward compatible)
|
|
@@ -112,6 +114,7 @@ zhihand gemini --model pro Switch backend with custom model
|
|
|
112
114
|
```bash
|
|
113
115
|
zhihand start # Start daemon in foreground
|
|
114
116
|
zhihand start -d # Start daemon in background
|
|
117
|
+
zhihand start --debug # Start with verbose debug logging
|
|
115
118
|
zhihand stop # Stop the daemon
|
|
116
119
|
zhihand status # Check if daemon is running, show device & backend info
|
|
117
120
|
```
|
|
@@ -158,6 +161,7 @@ When you switch:
|
|
|
158
161
|
| `--model, -m <name>` | Set model alias (e.g. `flash`, `pro`, `sonnet`, `opus`, `gpt-5.4-mini`) |
|
|
159
162
|
| `--port <port>` | Override daemon port (default: 18686) |
|
|
160
163
|
| `-d, --detach` | Run daemon in background |
|
|
164
|
+
| `--debug` | Enable verbose debug logging (all API requests, CLI args, SSE events) |
|
|
161
165
|
| `-h, --help` | Show help |
|
|
162
166
|
|
|
163
167
|
### Environment Variables
|
package/bin/zhihand
CHANGED
|
@@ -26,6 +26,7 @@ const { positionals, values } = parseArgs({
|
|
|
26
26
|
model: { type: "string", short: "m" },
|
|
27
27
|
help: { type: "boolean", short: "h", default: false },
|
|
28
28
|
detach: { type: "boolean", short: "d", default: false },
|
|
29
|
+
debug: { type: "boolean", default: false },
|
|
29
30
|
port: { type: "string" },
|
|
30
31
|
},
|
|
31
32
|
});
|
|
@@ -39,6 +40,7 @@ zhihand — MCP Server and Relay for phone control
|
|
|
39
40
|
Usage:
|
|
40
41
|
zhihand start Start daemon (MCP Server + Relay, foreground)
|
|
41
42
|
zhihand start -d Start daemon in background (detach)
|
|
43
|
+
zhihand start --debug Start daemon with verbose debug logging
|
|
42
44
|
zhihand stop Stop daemon
|
|
43
45
|
zhihand status Show status (pairing, backend, brain)
|
|
44
46
|
|
|
@@ -51,6 +53,7 @@ Usage:
|
|
|
51
53
|
zhihand pair Pair with a phone device
|
|
52
54
|
zhihand detect Detect available CLI tools
|
|
53
55
|
|
|
56
|
+
zhihand test Test device connectivity (sends click + type commands)
|
|
54
57
|
zhihand serve Start MCP Server (stdio mode, backward compat)
|
|
55
58
|
|
|
56
59
|
Options:
|
|
@@ -58,6 +61,7 @@ Options:
|
|
|
58
61
|
--model, -m <name> Set model alias (e.g. flash, pro, sonnet, opus, gpt-5.4-mini)
|
|
59
62
|
--port <port> Override daemon port (default: 18686)
|
|
60
63
|
-d, --detach Run daemon in background
|
|
64
|
+
--debug Enable verbose debug logging
|
|
61
65
|
-h, --help Show this help
|
|
62
66
|
`);
|
|
63
67
|
process.exit(0);
|
|
@@ -144,6 +148,7 @@ switch (command) {
|
|
|
144
148
|
const args = [process.argv[1], "start"];
|
|
145
149
|
if (values.port) args.push("--port", values.port);
|
|
146
150
|
if (values.device) args.push("--device", values.device);
|
|
151
|
+
if (values.debug) args.push("--debug");
|
|
147
152
|
|
|
148
153
|
// Write daemon logs to ~/.zhihand/daemon.log
|
|
149
154
|
const zhihandDir = pathMod.default.join(osMod.default.homedir(), ".zhihand");
|
|
@@ -166,6 +171,7 @@ switch (command) {
|
|
|
166
171
|
await startDaemon({
|
|
167
172
|
port,
|
|
168
173
|
deviceName: values.device ?? process.env.ZHIHAND_DEVICE,
|
|
174
|
+
debug: values.debug,
|
|
169
175
|
});
|
|
170
176
|
break;
|
|
171
177
|
}
|
|
@@ -280,6 +286,87 @@ switch (command) {
|
|
|
280
286
|
break;
|
|
281
287
|
}
|
|
282
288
|
|
|
289
|
+
case "test": {
|
|
290
|
+
const { resolveConfig: resolveTestConfig } = await import("../dist/core/config.js");
|
|
291
|
+
const { createControlCommand, enqueueCommand } = await import("../dist/core/command.js");
|
|
292
|
+
const { waitForCommandAck } = await import("../dist/core/sse.js");
|
|
293
|
+
const { fetchScreenshotBinary } = await import("../dist/core/screenshot.js");
|
|
294
|
+
|
|
295
|
+
let testConfig;
|
|
296
|
+
try {
|
|
297
|
+
testConfig = resolveTestConfig(values.device ?? process.env.ZHIHAND_DEVICE);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
console.error(`Error: ${err.message}`);
|
|
300
|
+
console.error("Run 'zhihand setup' to pair a device first.");
|
|
301
|
+
process.exit(1);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
console.log("🧪 ZhiHand Device Test");
|
|
305
|
+
console.log(` Device: ${testConfig.credentialId}`);
|
|
306
|
+
console.log(` Endpoint: ${testConfig.controlPlaneEndpoint}\n`);
|
|
307
|
+
|
|
308
|
+
const steps = [
|
|
309
|
+
{ label: "1. Screenshot", type: "screenshot" },
|
|
310
|
+
{ label: "2. Click center", type: "hid", params: { action: "click", xRatio: 0.5, yRatio: 0.5 } },
|
|
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
|
+
{ label: "4. Swipe down", type: "hid", params: { action: "swipe", startXRatio: 0.5, startYRatio: 0.3, endXRatio: 0.5, endYRatio: 0.7, durationMs: 300 } },
|
|
313
|
+
{ label: "5. Press Home", type: "hid", params: { action: "home" } },
|
|
314
|
+
{ label: "6. Press Back", type: "hid", params: { action: "back" } },
|
|
315
|
+
{ label: "7. Screenshot", type: "screenshot" },
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
let passed = 0;
|
|
319
|
+
let failed = 0;
|
|
320
|
+
|
|
321
|
+
for (const step of steps) {
|
|
322
|
+
process.stdout.write(` ${step.label}... `);
|
|
323
|
+
const t0 = Date.now();
|
|
324
|
+
try {
|
|
325
|
+
if (step.type === "screenshot") {
|
|
326
|
+
// Screenshot: send receive_screenshot command, then fetch binary
|
|
327
|
+
const cmd = createControlCommand({ action: "screenshot" });
|
|
328
|
+
const queued = await enqueueCommand(testConfig, cmd);
|
|
329
|
+
const ack = await waitForCommandAck(testConfig, { commandId: queued.id, timeoutMs: 10_000 });
|
|
330
|
+
if (ack.acked) {
|
|
331
|
+
const buf = await fetchScreenshotBinary(testConfig);
|
|
332
|
+
const ms = Date.now() - t0;
|
|
333
|
+
console.log(`✅ ${(buf.length / 1024).toFixed(0)}KB (${ms}ms)`);
|
|
334
|
+
passed++;
|
|
335
|
+
} else {
|
|
336
|
+
console.log(`⏱️ Timeout (${Date.now() - t0}ms)`);
|
|
337
|
+
failed++;
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
const cmd = createControlCommand(step.params);
|
|
341
|
+
const queued = await enqueueCommand(testConfig, cmd);
|
|
342
|
+
const ack = await waitForCommandAck(testConfig, { commandId: queued.id, timeoutMs: 10_000 });
|
|
343
|
+
const ms = Date.now() - t0;
|
|
344
|
+
if (ack.acked) {
|
|
345
|
+
console.log(`✅ (${ms}ms)`);
|
|
346
|
+
passed++;
|
|
347
|
+
} else {
|
|
348
|
+
console.log(`⏱️ Timeout (${ms}ms)`);
|
|
349
|
+
failed++;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
} catch (err) {
|
|
353
|
+
const ms = Date.now() - t0;
|
|
354
|
+
console.log(`❌ ${err.message} (${ms}ms)`);
|
|
355
|
+
failed++;
|
|
356
|
+
}
|
|
357
|
+
// Pause between commands: let phone process + user can observe
|
|
358
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
console.log(`\n Result: ${passed}/${steps.length} passed`);
|
|
362
|
+
if (failed === 0) {
|
|
363
|
+
console.log(" ✅ Device is responding to all commands!");
|
|
364
|
+
} else {
|
|
365
|
+
console.log(" ⚠️ Some commands failed. Check phone connectivity.");
|
|
366
|
+
}
|
|
367
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
368
|
+
}
|
|
369
|
+
|
|
283
370
|
default:
|
|
284
371
|
console.error(`Unknown command: ${command}. Run 'zhihand --help' for usage.`);
|
|
285
372
|
process.exit(1);
|
package/dist/core/command.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dbg } from "../daemon/logger.js";
|
|
1
2
|
let messageCounter = 0;
|
|
2
3
|
function nextMessageId() {
|
|
3
4
|
messageCounter = (messageCounter + 1) % 1000;
|
|
@@ -73,30 +74,37 @@ export function createControlCommand(params) {
|
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
export async function enqueueCommand(config, command) {
|
|
76
|
-
const
|
|
77
|
+
const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/commands`;
|
|
78
|
+
const body = { command: { ...command, message_id: command.messageId ?? nextMessageId() } };
|
|
79
|
+
dbg(`[cmd] POST ${url} type=${command.type} payload=${JSON.stringify(command.payload ?? {})}`);
|
|
80
|
+
const response = await fetch(url, {
|
|
77
81
|
method: "POST",
|
|
78
82
|
headers: {
|
|
79
83
|
"Content-Type": "application/json",
|
|
80
84
|
"x-zhihand-controller-token": config.controllerToken,
|
|
81
85
|
},
|
|
82
|
-
body: JSON.stringify(
|
|
83
|
-
command: { ...command, message_id: command.messageId ?? nextMessageId() },
|
|
84
|
-
}),
|
|
86
|
+
body: JSON.stringify(body),
|
|
85
87
|
});
|
|
86
88
|
if (!response.ok) {
|
|
89
|
+
dbg(`[cmd] Enqueue failed: ${response.status} ${response.statusText}`);
|
|
87
90
|
throw new Error(`Enqueue command failed: ${response.status}`);
|
|
88
91
|
}
|
|
89
92
|
const payload = (await response.json());
|
|
93
|
+
dbg(`[cmd] Enqueued: id=${payload.command.id}, status=${payload.command.status}`);
|
|
90
94
|
return payload.command;
|
|
91
95
|
}
|
|
92
96
|
export async function getCommand(config, commandId) {
|
|
93
|
-
const
|
|
97
|
+
const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/commands/${encodeURIComponent(commandId)}`;
|
|
98
|
+
dbg(`[cmd] GET ${url}`);
|
|
99
|
+
const response = await fetch(url, {
|
|
94
100
|
headers: { "x-zhihand-controller-token": config.controllerToken },
|
|
95
101
|
});
|
|
96
102
|
if (!response.ok) {
|
|
103
|
+
dbg(`[cmd] Get failed: ${response.status}`);
|
|
97
104
|
throw new Error(`Get command failed: ${response.status}`);
|
|
98
105
|
}
|
|
99
106
|
const payload = (await response.json());
|
|
107
|
+
dbg(`[cmd] Got: id=${payload.command.id}, status=${payload.command.status}, acked=${!!payload.command.acked_at}`);
|
|
100
108
|
return payload.command;
|
|
101
109
|
}
|
|
102
110
|
export function formatAckSummary(action, result) {
|
package/dist/core/screenshot.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { dbg } from "../daemon/logger.js";
|
|
1
2
|
export async function fetchScreenshotBinary(config) {
|
|
2
3
|
const controller = new AbortController();
|
|
3
|
-
const
|
|
4
|
+
const timeoutMs = config.timeoutMs ?? 10_000;
|
|
5
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
6
|
+
const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/screen`;
|
|
7
|
+
dbg(`[screenshot] GET ${url} timeout=${timeoutMs}ms`);
|
|
8
|
+
const t0 = Date.now();
|
|
4
9
|
try {
|
|
5
|
-
const response = await fetch(
|
|
10
|
+
const response = await fetch(url, {
|
|
6
11
|
method: "GET",
|
|
7
12
|
headers: {
|
|
8
13
|
"x-zhihand-controller-token": config.controllerToken,
|
|
@@ -11,9 +16,12 @@ export async function fetchScreenshotBinary(config) {
|
|
|
11
16
|
signal: controller.signal,
|
|
12
17
|
});
|
|
13
18
|
if (!response.ok) {
|
|
19
|
+
dbg(`[screenshot] Failed: ${response.status} ${response.statusText}`);
|
|
14
20
|
throw new Error(`Screenshot fetch failed: ${response.status}`);
|
|
15
21
|
}
|
|
16
|
-
|
|
22
|
+
const buf = Buffer.from(await response.arrayBuffer());
|
|
23
|
+
dbg(`[screenshot] OK: ${(buf.length / 1024).toFixed(0)}KB in ${Date.now() - t0}ms`);
|
|
24
|
+
return buf;
|
|
17
25
|
}
|
|
18
26
|
finally {
|
|
19
27
|
clearTimeout(timeout);
|
package/dist/core/sse.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { getCommand } from "./command.js";
|
|
2
|
+
import { dbg } from "../daemon/logger.js";
|
|
2
3
|
// Per-commandId callback registry for SSE-based ACK
|
|
3
4
|
const ackCallbacks = new Map();
|
|
4
5
|
// Active SSE connection state
|
|
5
6
|
let sseAbortController = null;
|
|
6
7
|
let sseConnected = false;
|
|
7
8
|
export function handleSSEEvent(event) {
|
|
9
|
+
dbg(`[sse-cmd] Event: kind=${event.kind}, command=${event.command?.id ?? "-"}`);
|
|
8
10
|
if (event.kind === "command.acked" && event.command) {
|
|
9
11
|
const callback = ackCallbacks.get(event.command.id);
|
|
10
12
|
if (callback) {
|
|
13
|
+
dbg(`[sse-cmd] ACK callback for ${event.command.id}, ack_status=${event.command.ack_status}`);
|
|
11
14
|
callback(event.command);
|
|
12
15
|
ackCallbacks.delete(event.command.id);
|
|
13
16
|
}
|
|
@@ -103,6 +106,7 @@ export function isSSEConnected() {
|
|
|
103
106
|
*/
|
|
104
107
|
export async function waitForCommandAck(config, options) {
|
|
105
108
|
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
109
|
+
dbg(`[sse-cmd] Waiting for ACK: commandId=${options.commandId}, timeout=${timeoutMs}ms`);
|
|
106
110
|
// Ensure SSE is connected for real-time ACKs
|
|
107
111
|
connectSSE(config);
|
|
108
112
|
return new Promise((resolve, reject) => {
|
|
@@ -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 { dbg } from "./logger.js";
|
|
8
9
|
const CLI_TIMEOUT = 300_000; // 300s (5min) per prompt — MCP tool chains need multiple turns
|
|
9
10
|
const SIGKILL_DELAY = 2_000; // 2s after SIGTERM
|
|
10
11
|
const MCP_PORT = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || 18686;
|
|
@@ -263,6 +264,7 @@ function pollGeminiSession(child, startTime, promptText, log, knownSessionFile,
|
|
|
263
264
|
}
|
|
264
265
|
if (Date.now() - outcomeAt >= SESSION_STABILITY_DELAY) {
|
|
265
266
|
const [status, text] = finalResult ?? outcome;
|
|
267
|
+
dbg(`[gemini] Session outcome: status=${status}, text (${text.length} chars): ${text.slice(0, 200)}${text.length > 200 ? "..." : ""}`);
|
|
266
268
|
settle({
|
|
267
269
|
text,
|
|
268
270
|
success: status === "success",
|
|
@@ -419,6 +421,7 @@ export function dispatchToCLI(backend, prompt, log, model) {
|
|
|
419
421
|
}
|
|
420
422
|
const sessionLabel = canReuse ? `#${session.promptCount + 1}` : "new";
|
|
421
423
|
log(`[dispatch] Backend: ${backend}, Model: ${resolvedModel}, Session: ${sessionLabel}`);
|
|
424
|
+
dbg(`[dispatch] Prompt (${prompt.length} chars): ${prompt.slice(0, 200)}${prompt.length > 200 ? "..." : ""}`);
|
|
422
425
|
if (backend === "gemini") {
|
|
423
426
|
return dispatchGeminiPersistent(prompt, startTime, log, resolvedModel);
|
|
424
427
|
}
|
|
@@ -461,6 +464,7 @@ async function dispatchGeminiPersistent(prompt, startTime, log, model) {
|
|
|
461
464
|
session.promptCount++;
|
|
462
465
|
const turnNum = session.promptCount;
|
|
463
466
|
log(`[gemini] Reusing session — sending prompt #${turnNum}`);
|
|
467
|
+
dbg(`[gemini] Writing to PTY stdin: ${prompt.slice(0, 200)}${prompt.length > 200 ? "..." : ""}`);
|
|
464
468
|
// Write raw prompt to PTY stdin (gemini already has system context from first prompt)
|
|
465
469
|
session.child.stdin?.write(prompt + "\n");
|
|
466
470
|
const result = await pollGeminiSession(session.child, startTime, prompt, log, session.geminiSessionFile, turnNum);
|
|
@@ -483,6 +487,10 @@ async function dispatchGeminiPersistent(prompt, startTime, log, model) {
|
|
|
483
487
|
};
|
|
484
488
|
const geminiPath = resolveGemini();
|
|
485
489
|
log(`[gemini] Starting new persistent session (model: ${model})`);
|
|
490
|
+
dbg(`[gemini] Executable: ${geminiPath}`);
|
|
491
|
+
dbg(`[gemini] PTY wrap: python3 ${PTY_WRAP_SCRIPT}`);
|
|
492
|
+
dbg(`[gemini] Args: ${JSON.stringify(cliArgs)}`);
|
|
493
|
+
dbg(`[gemini] Wrapped prompt (${wrappedPrompt.length} chars): ${wrappedPrompt.slice(0, 300)}...`);
|
|
486
494
|
const child = spawn("python3", [PTY_WRAP_SCRIPT, geminiPath, ...cliArgs], {
|
|
487
495
|
env,
|
|
488
496
|
stdio: ["pipe", "pipe", "pipe"], // stdin=pipe for subsequent prompts
|
|
@@ -522,6 +530,9 @@ async function dispatchCodexWithHistory(prompt, startTime, log, model) {
|
|
|
522
530
|
args.push("-");
|
|
523
531
|
const codexPath = resolveCodex();
|
|
524
532
|
log(`[codex] One-shot dispatch (history: ${conversationHistory.length} turns)`);
|
|
533
|
+
dbg(`[codex] Executable: ${codexPath}`);
|
|
534
|
+
dbg(`[codex] Args: ${JSON.stringify(args)}`);
|
|
535
|
+
dbg(`[codex] Stdin prompt (${fullPrompt.length} chars): ${fullPrompt.slice(0, 300)}...`);
|
|
525
536
|
const child = spawn(codexPath, args, {
|
|
526
537
|
env: process.env,
|
|
527
538
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -531,6 +542,7 @@ async function dispatchCodexWithHistory(prompt, startTime, log, model) {
|
|
|
531
542
|
child.stdin?.write(fullPrompt);
|
|
532
543
|
child.stdin?.end();
|
|
533
544
|
const result = await collectCodexOutput(child, startTime);
|
|
545
|
+
dbg(`[codex] Output: success=${result.success}, duration=${result.durationMs}ms, text (${result.text.length} chars): ${result.text.slice(0, 300)}${result.text.length > 300 ? "..." : ""}`);
|
|
534
546
|
recordTurn("user", prompt);
|
|
535
547
|
recordTurn("assistant", result.text);
|
|
536
548
|
return result;
|
|
@@ -544,13 +556,17 @@ async function dispatchClaudeWithHistory(prompt, startTime, log, model) {
|
|
|
544
556
|
// --permission-mode bypassPermissions: auto-approve all tool calls (like gemini's --approval-mode yolo)
|
|
545
557
|
// --mcp-config: explicitly pass MCP server URL so Claude finds it regardless of cwd
|
|
546
558
|
const mcpConfig = JSON.stringify({ mcpServers: { zhihand: { type: "http", url: MCP_URL } } });
|
|
547
|
-
const
|
|
559
|
+
const claudeArgs = [
|
|
548
560
|
"-p", "-",
|
|
549
561
|
"--model", model,
|
|
550
562
|
"--output-format", "json",
|
|
551
563
|
"--permission-mode", "bypassPermissions",
|
|
552
564
|
"--mcp-config", mcpConfig,
|
|
553
|
-
]
|
|
565
|
+
];
|
|
566
|
+
dbg(`[claude] Executable: ${claudePath}`);
|
|
567
|
+
dbg(`[claude] Args: ${JSON.stringify(claudeArgs)}`);
|
|
568
|
+
dbg(`[claude] Stdin prompt (${fullPrompt.length} chars): ${fullPrompt.slice(0, 300)}...`);
|
|
569
|
+
const child = spawn(claudePath, claudeArgs, {
|
|
554
570
|
env: process.env,
|
|
555
571
|
stdio: ["pipe", "pipe", "pipe"],
|
|
556
572
|
detached: false,
|
|
@@ -558,11 +574,31 @@ async function dispatchClaudeWithHistory(prompt, startTime, log, model) {
|
|
|
558
574
|
// Write prompt to stdin, then close to signal EOF
|
|
559
575
|
child.stdin?.write(fullPrompt);
|
|
560
576
|
child.stdin?.end();
|
|
561
|
-
const
|
|
577
|
+
const raw = await collectChildOutput(child, startTime);
|
|
578
|
+
dbg(`[claude] Raw output (${raw.text.length} chars): ${raw.text.slice(0, 500)}${raw.text.length > 500 ? "..." : ""}`);
|
|
579
|
+
// Claude --output-format json wraps the result in a JSON envelope — extract the actual text
|
|
580
|
+
const result = extractClaudeResult(raw);
|
|
581
|
+
dbg(`[claude] Parsed result: success=${result.success}, text (${result.text.length} chars)`);
|
|
562
582
|
recordTurn("user", prompt);
|
|
563
583
|
recordTurn("assistant", result.text);
|
|
564
584
|
return result;
|
|
565
585
|
}
|
|
586
|
+
/** Parse Claude JSON output and extract the result text. */
|
|
587
|
+
function extractClaudeResult(raw) {
|
|
588
|
+
try {
|
|
589
|
+
const parsed = JSON.parse(raw.text);
|
|
590
|
+
if (!parsed || typeof parsed !== "object")
|
|
591
|
+
return raw;
|
|
592
|
+
const resultText = typeof parsed.result === "string" ? parsed.result : raw.text;
|
|
593
|
+
const isError = parsed.is_error === true || parsed.subtype === "error";
|
|
594
|
+
// Preserve process exit failure: only succeed if both JSON and process agree
|
|
595
|
+
return { text: resultText, success: raw.success && !isError, durationMs: raw.durationMs };
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
// Not JSON — return as-is
|
|
599
|
+
return raw;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
566
602
|
// ── Codex JSONL Output Collector ──────────────────────────────
|
|
567
603
|
function collectCodexOutput(child, startTime) {
|
|
568
604
|
return new Promise((resolve) => {
|
|
@@ -673,8 +709,10 @@ function collectChildOutput(child, startTime) {
|
|
|
673
709
|
}
|
|
674
710
|
// ── Reply ──────────────────────────────────────────────────
|
|
675
711
|
export async function postReply(config, promptId, text) {
|
|
712
|
+
const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/prompts/${encodeURIComponent(promptId)}/reply`;
|
|
713
|
+
dbg(`[reply] POST ${url}`);
|
|
714
|
+
dbg(`[reply] Body (${text.length} chars): ${text.slice(0, 300)}${text.length > 300 ? "..." : ""}`);
|
|
676
715
|
try {
|
|
677
|
-
const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/prompts/${encodeURIComponent(promptId)}/reply`;
|
|
678
716
|
const response = await fetch(url, {
|
|
679
717
|
method: "POST",
|
|
680
718
|
headers: {
|
|
@@ -684,9 +722,11 @@ export async function postReply(config, promptId, text) {
|
|
|
684
722
|
body: JSON.stringify({ role: "assistant", text }),
|
|
685
723
|
signal: AbortSignal.timeout(30_000),
|
|
686
724
|
});
|
|
725
|
+
dbg(`[reply] Response: ${response.status} ${response.statusText}`);
|
|
687
726
|
return response.ok || (response.status >= 400 && response.status < 500);
|
|
688
727
|
}
|
|
689
|
-
catch {
|
|
728
|
+
catch (err) {
|
|
729
|
+
dbg(`[reply] Error: ${err.message}`);
|
|
690
730
|
return false;
|
|
691
731
|
}
|
|
692
732
|
}
|
package/dist/daemon/heartbeat.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dbg } from "./logger.js";
|
|
1
2
|
const HEARTBEAT_INTERVAL = 30_000; // 30s
|
|
2
3
|
const HEARTBEAT_RETRY_INTERVAL = 5_000; // 5s on failure
|
|
3
4
|
let heartbeatTimer;
|
|
@@ -18,7 +19,9 @@ async function sendHeartbeat(config, online) {
|
|
|
18
19
|
body.backend = currentMeta.backend;
|
|
19
20
|
if (currentMeta.model)
|
|
20
21
|
body.model = currentMeta.model;
|
|
21
|
-
const
|
|
22
|
+
const url = buildUrl(config);
|
|
23
|
+
dbg(`[heartbeat] POST ${url} body=${JSON.stringify(body)}`);
|
|
24
|
+
const response = await fetch(url, {
|
|
22
25
|
method: "POST",
|
|
23
26
|
headers: {
|
|
24
27
|
"Content-Type": "application/json",
|
|
@@ -27,9 +30,11 @@ async function sendHeartbeat(config, online) {
|
|
|
27
30
|
body: JSON.stringify(body),
|
|
28
31
|
signal: AbortSignal.timeout(10_000),
|
|
29
32
|
});
|
|
33
|
+
dbg(`[heartbeat] Response: ${response.status} ${response.statusText}`);
|
|
30
34
|
return response.ok;
|
|
31
35
|
}
|
|
32
|
-
catch {
|
|
36
|
+
catch (err) {
|
|
37
|
+
dbg(`[heartbeat] Error: ${err.message}`);
|
|
33
38
|
return false;
|
|
34
39
|
}
|
|
35
40
|
}
|
package/dist/daemon/index.d.ts
CHANGED
package/dist/daemon/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { PACKAGE_VERSION } from "../index.js";
|
|
|
10
10
|
import { startHeartbeatLoop, stopHeartbeatLoop, sendBrainOffline, setBrainMeta } from "./heartbeat.js";
|
|
11
11
|
import { PromptListener } from "./prompt-listener.js";
|
|
12
12
|
import { dispatchToCLI, postReply, killActiveChild } from "./dispatcher.js";
|
|
13
|
+
import { setDebugEnabled, dbg } from "./logger.js";
|
|
13
14
|
const DEFAULT_PORT = 18686;
|
|
14
15
|
const PID_FILE = "daemon.pid";
|
|
15
16
|
// ── State ──────────────────────────────────────────────────
|
|
@@ -30,7 +31,11 @@ async function processPrompt(config, prompt) {
|
|
|
30
31
|
}
|
|
31
32
|
const preview = prompt.text.length > 40 ? prompt.text.slice(0, 40) + "..." : prompt.text;
|
|
32
33
|
log(`[relay] Prompt: "${preview}" → dispatching to ${activeBackend}...`);
|
|
34
|
+
dbg(`[relay] Prompt ID: ${prompt.id}, full text (${prompt.text.length} chars): ${prompt.text}`);
|
|
35
|
+
dbg(`[relay] Prompt metadata: status=${prompt.status}, edge_id=${prompt.edge_id}, created_at=${prompt.created_at}`);
|
|
33
36
|
const result = await dispatchToCLI(activeBackend, prompt.text, log, activeModel ?? undefined);
|
|
37
|
+
dbg(`[relay] Dispatch result: success=${result.success}, duration=${result.durationMs}ms, text length=${result.text.length}`);
|
|
38
|
+
dbg(`[relay] Reply text: ${result.text.slice(0, 500)}${result.text.length > 500 ? "..." : ""}`);
|
|
34
39
|
const ok = await postReply(config, prompt.id, result.text);
|
|
35
40
|
const dur = (result.durationMs / 1000).toFixed(1);
|
|
36
41
|
if (ok) {
|
|
@@ -50,6 +55,7 @@ async function processQueue(config) {
|
|
|
50
55
|
}
|
|
51
56
|
function onPromptReceived(config, prompt) {
|
|
52
57
|
promptQueue.push(prompt);
|
|
58
|
+
dbg(`[queue] Enqueued prompt ${prompt.id}, queue length: ${promptQueue.length}, processing: ${isProcessing}`);
|
|
53
59
|
if (!isProcessing) {
|
|
54
60
|
processQueue(config);
|
|
55
61
|
}
|
|
@@ -58,6 +64,7 @@ function onPromptReceived(config, prompt) {
|
|
|
58
64
|
function handleInternalAPI(req, res) {
|
|
59
65
|
const url = req.url ?? "";
|
|
60
66
|
if (url === "/internal/backend" && req.method === "POST") {
|
|
67
|
+
dbg(`[api] POST /internal/backend from ${req.socket.remoteAddress}`);
|
|
61
68
|
let body = "";
|
|
62
69
|
const MAX_BODY = 10 * 1024; // 10KB
|
|
63
70
|
req.on("data", (chunk) => {
|
|
@@ -95,6 +102,7 @@ function handleInternalAPI(req, res) {
|
|
|
95
102
|
return true;
|
|
96
103
|
}
|
|
97
104
|
if (url === "/internal/status" && req.method === "GET") {
|
|
105
|
+
dbg(`[api] GET /internal/status`);
|
|
98
106
|
const effectiveModel = activeBackend ? (activeModel ?? DEFAULT_MODELS[activeBackend]) : null;
|
|
99
107
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
100
108
|
res.end(JSON.stringify({
|
|
@@ -146,6 +154,8 @@ export function isAlreadyRunning() {
|
|
|
146
154
|
}
|
|
147
155
|
// ── Main Daemon Entry ──────────────────────────────────────
|
|
148
156
|
export async function startDaemon(options) {
|
|
157
|
+
if (options?.debug)
|
|
158
|
+
setDebugEnabled(true);
|
|
149
159
|
const port = options?.port ?? (parseInt(process.env.ZHIHAND_PORT ?? "", 10) || DEFAULT_PORT);
|
|
150
160
|
// Check if already running
|
|
151
161
|
const existingPid = readPid();
|
|
@@ -169,6 +179,8 @@ export async function startDaemon(options) {
|
|
|
169
179
|
activeModel = backendConfig.model ?? null;
|
|
170
180
|
// Log startup info + set brain meta for heartbeat
|
|
171
181
|
log(`ZhiHand v${PACKAGE_VERSION} starting...`);
|
|
182
|
+
if (options?.debug)
|
|
183
|
+
log(`[config] Debug mode enabled — verbose logging active`);
|
|
172
184
|
if (activeBackend) {
|
|
173
185
|
const effectiveModel = activeModel ?? DEFAULT_MODELS[activeBackend];
|
|
174
186
|
log(`[config] Backend: ${activeBackend}, Model: ${effectiveModel}`);
|
|
@@ -207,6 +219,7 @@ export async function startDaemon(options) {
|
|
|
207
219
|
if (req.url === "/mcp" || req.url?.startsWith("/mcp")) {
|
|
208
220
|
try {
|
|
209
221
|
const sessionId = req.headers["mcp-session-id"];
|
|
222
|
+
dbg(`[mcp] ${req.method} /mcp session=${sessionId?.slice(0, 8) ?? "(new)"} sessions=${mcpSessions.size}`);
|
|
210
223
|
if (sessionId && mcpSessions.has(sessionId)) {
|
|
211
224
|
// Existing session
|
|
212
225
|
const session = mcpSessions.get(sessionId);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug logger for ZhiHand daemon.
|
|
3
|
+
*
|
|
4
|
+
* Enable with `zhihand start --debug` to see detailed request/response,
|
|
5
|
+
* CLI spawn args, stdin/stdout data, SSE events, and timing information.
|
|
6
|
+
*/
|
|
7
|
+
export declare function setDebugEnabled(enabled: boolean): void;
|
|
8
|
+
export declare function isDebugEnabled(): boolean;
|
|
9
|
+
/** Debug log — only outputs when --debug is active. */
|
|
10
|
+
export declare function dbg(msg: string): void;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug logger for ZhiHand daemon.
|
|
3
|
+
*
|
|
4
|
+
* Enable with `zhihand start --debug` to see detailed request/response,
|
|
5
|
+
* CLI spawn args, stdin/stdout data, SSE events, and timing information.
|
|
6
|
+
*/
|
|
7
|
+
let debugEnabled = false;
|
|
8
|
+
export function setDebugEnabled(enabled) {
|
|
9
|
+
debugEnabled = enabled;
|
|
10
|
+
}
|
|
11
|
+
export function isDebugEnabled() {
|
|
12
|
+
return debugEnabled;
|
|
13
|
+
}
|
|
14
|
+
function ts() {
|
|
15
|
+
return new Date().toLocaleTimeString();
|
|
16
|
+
}
|
|
17
|
+
/** Debug log — only outputs when --debug is active. */
|
|
18
|
+
export function dbg(msg) {
|
|
19
|
+
if (!debugEnabled)
|
|
20
|
+
return;
|
|
21
|
+
process.stdout.write(`[${ts()}] [DEBUG] ${msg}\n`);
|
|
22
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dbg } from "./logger.js";
|
|
1
2
|
const SSE_WATCHDOG_TIMEOUT = 120_000; // 120s no data → reconnect (servers may not send keepalive frequently)
|
|
2
3
|
const SSE_RECONNECT_DELAY = 3_000;
|
|
3
4
|
const POLL_INTERVAL = 2_000;
|
|
@@ -29,9 +30,12 @@ export class PromptListener {
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
dispatchPrompt(prompt) {
|
|
32
|
-
if (this.processedIds.has(prompt.id))
|
|
33
|
+
if (this.processedIds.has(prompt.id)) {
|
|
34
|
+
dbg(`[prompt] Skipping duplicate prompt: ${prompt.id}`);
|
|
33
35
|
return;
|
|
36
|
+
}
|
|
34
37
|
this.processedIds.add(prompt.id);
|
|
38
|
+
dbg(`[prompt] Dispatching prompt: id=${prompt.id}, status=${prompt.status}, text="${prompt.text.slice(0, 100)}${prompt.text.length > 100 ? "..." : ""}"`);
|
|
35
39
|
// Prevent unbounded growth
|
|
36
40
|
if (this.processedIds.size > 500) {
|
|
37
41
|
const arr = [...this.processedIds];
|
|
@@ -44,6 +48,7 @@ export class PromptListener {
|
|
|
44
48
|
try {
|
|
45
49
|
this.sseAbort = new AbortController();
|
|
46
50
|
const url = `${this.config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(this.config.credentialId)}/events/stream?topic=prompts`;
|
|
51
|
+
dbg(`[sse] Connecting to ${url}`);
|
|
47
52
|
const response = await fetch(url, {
|
|
48
53
|
headers: {
|
|
49
54
|
"Accept": "text/event-stream",
|
|
@@ -52,6 +57,7 @@ export class PromptListener {
|
|
|
52
57
|
signal: this.sseAbort.signal,
|
|
53
58
|
});
|
|
54
59
|
if (!response.ok) {
|
|
60
|
+
dbg(`[sse] Connect failed: ${response.status} ${response.statusText}`);
|
|
55
61
|
throw new Error(`SSE connect failed: ${response.status}`);
|
|
56
62
|
}
|
|
57
63
|
this.sseConnected = true;
|
|
@@ -114,6 +120,7 @@ export class PromptListener {
|
|
|
114
120
|
}, SSE_WATCHDOG_TIMEOUT);
|
|
115
121
|
}
|
|
116
122
|
handleSSEEvent(event) {
|
|
123
|
+
dbg(`[sse] Event: kind=${event.kind}, prompt=${event.prompt?.id ?? "-"}, prompts=${event.prompts?.length ?? 0}`);
|
|
117
124
|
if (event.kind === "prompt.queued" && event.prompt) {
|
|
118
125
|
this.dispatchPrompt(event.prompt);
|
|
119
126
|
}
|
|
@@ -152,19 +159,23 @@ export class PromptListener {
|
|
|
152
159
|
async poll() {
|
|
153
160
|
try {
|
|
154
161
|
const url = `${this.config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(this.config.credentialId)}/prompts?limit=5`;
|
|
162
|
+
dbg(`[poll] GET ${url}`);
|
|
155
163
|
const response = await fetch(url, {
|
|
156
164
|
headers: { "x-zhihand-controller-token": this.config.controllerToken },
|
|
157
165
|
signal: AbortSignal.timeout(10_000),
|
|
158
166
|
});
|
|
159
|
-
if (!response.ok)
|
|
167
|
+
if (!response.ok) {
|
|
168
|
+
dbg(`[poll] Response: ${response.status}`);
|
|
160
169
|
return;
|
|
170
|
+
}
|
|
161
171
|
const data = (await response.json());
|
|
172
|
+
dbg(`[poll] Got ${data.items?.length ?? 0} prompt(s)`);
|
|
162
173
|
for (const prompt of data.items ?? []) {
|
|
163
174
|
this.dispatchPrompt(prompt);
|
|
164
175
|
}
|
|
165
176
|
}
|
|
166
|
-
catch {
|
|
167
|
-
|
|
177
|
+
catch (err) {
|
|
178
|
+
dbg(`[poll] Error: ${err.message}`);
|
|
168
179
|
}
|
|
169
180
|
}
|
|
170
181
|
}
|
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.25.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,7 +5,7 @@ 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
|
-
export const PACKAGE_VERSION = "0.
|
|
8
|
+
export const PACKAGE_VERSION = "0.25.0";
|
|
9
9
|
export function createServer(deviceName) {
|
|
10
10
|
const server = new McpServer({
|
|
11
11
|
name: "zhihand",
|