@zhihand/mcp 0.24.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 +4 -1
- package/bin/zhihand +5 -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 +26 -4
- 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,6 +93,7 @@ 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
|
|
|
@@ -113,6 +114,7 @@ zhihand gemini --model pro Switch backend with custom model
|
|
|
113
114
|
```bash
|
|
114
115
|
zhihand start # Start daemon in foreground
|
|
115
116
|
zhihand start -d # Start daemon in background
|
|
117
|
+
zhihand start --debug # Start with verbose debug logging
|
|
116
118
|
zhihand stop # Stop the daemon
|
|
117
119
|
zhihand status # Check if daemon is running, show device & backend info
|
|
118
120
|
```
|
|
@@ -159,6 +161,7 @@ When you switch:
|
|
|
159
161
|
| `--model, -m <name>` | Set model alias (e.g. `flash`, `pro`, `sonnet`, `opus`, `gpt-5.4-mini`) |
|
|
160
162
|
| `--port <port>` | Override daemon port (default: 18686) |
|
|
161
163
|
| `-d, --detach` | Run daemon in background |
|
|
164
|
+
| `--debug` | Enable verbose debug logging (all API requests, CLI args, SSE events) |
|
|
162
165
|
| `-h, --help` | Show help |
|
|
163
166
|
|
|
164
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
|
|
|
@@ -59,6 +61,7 @@ Options:
|
|
|
59
61
|
--model, -m <name> Set model alias (e.g. flash, pro, sonnet, opus, gpt-5.4-mini)
|
|
60
62
|
--port <port> Override daemon port (default: 18686)
|
|
61
63
|
-d, --detach Run daemon in background
|
|
64
|
+
--debug Enable verbose debug logging
|
|
62
65
|
-h, --help Show this help
|
|
63
66
|
`);
|
|
64
67
|
process.exit(0);
|
|
@@ -145,6 +148,7 @@ switch (command) {
|
|
|
145
148
|
const args = [process.argv[1], "start"];
|
|
146
149
|
if (values.port) args.push("--port", values.port);
|
|
147
150
|
if (values.device) args.push("--device", values.device);
|
|
151
|
+
if (values.debug) args.push("--debug");
|
|
148
152
|
|
|
149
153
|
// Write daemon logs to ~/.zhihand/daemon.log
|
|
150
154
|
const zhihandDir = pathMod.default.join(osMod.default.homedir(), ".zhihand");
|
|
@@ -167,6 +171,7 @@ switch (command) {
|
|
|
167
171
|
await startDaemon({
|
|
168
172
|
port,
|
|
169
173
|
deviceName: values.device ?? process.env.ZHIHAND_DEVICE,
|
|
174
|
+
debug: values.debug,
|
|
170
175
|
});
|
|
171
176
|
break;
|
|
172
177
|
}
|
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,
|
|
@@ -559,8 +575,10 @@ async function dispatchClaudeWithHistory(prompt, startTime, log, model) {
|
|
|
559
575
|
child.stdin?.write(fullPrompt);
|
|
560
576
|
child.stdin?.end();
|
|
561
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 ? "..." : ""}`);
|
|
562
579
|
// Claude --output-format json wraps the result in a JSON envelope — extract the actual text
|
|
563
580
|
const result = extractClaudeResult(raw);
|
|
581
|
+
dbg(`[claude] Parsed result: success=${result.success}, text (${result.text.length} chars)`);
|
|
564
582
|
recordTurn("user", prompt);
|
|
565
583
|
recordTurn("assistant", result.text);
|
|
566
584
|
return result;
|
|
@@ -691,8 +709,10 @@ function collectChildOutput(child, startTime) {
|
|
|
691
709
|
}
|
|
692
710
|
// ── Reply ──────────────────────────────────────────────────
|
|
693
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 ? "..." : ""}`);
|
|
694
715
|
try {
|
|
695
|
-
const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/prompts/${encodeURIComponent(promptId)}/reply`;
|
|
696
716
|
const response = await fetch(url, {
|
|
697
717
|
method: "POST",
|
|
698
718
|
headers: {
|
|
@@ -702,9 +722,11 @@ export async function postReply(config, promptId, text) {
|
|
|
702
722
|
body: JSON.stringify({ role: "assistant", text }),
|
|
703
723
|
signal: AbortSignal.timeout(30_000),
|
|
704
724
|
});
|
|
725
|
+
dbg(`[reply] Response: ${response.status} ${response.statusText}`);
|
|
705
726
|
return response.ok || (response.status >= 400 && response.status < 500);
|
|
706
727
|
}
|
|
707
|
-
catch {
|
|
728
|
+
catch (err) {
|
|
729
|
+
dbg(`[reply] Error: ${err.message}`);
|
|
708
730
|
return false;
|
|
709
731
|
}
|
|
710
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",
|