@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 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.23.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);
@@ -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 response = await fetch(`${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/commands`, {
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 response = await fetch(`${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/commands/${encodeURIComponent(commandId)}`, {
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) {
@@ -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 timeout = setTimeout(() => controller.abort(), config.timeoutMs ?? 10_000);
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(`${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/screen`, {
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
- return Buffer.from(await response.arrayBuffer());
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 child = spawn(claudePath, [
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 result = await collectChildOutput(child, startTime);
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
  }
@@ -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 response = await fetch(buildUrl(config), {
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
  }
@@ -2,5 +2,6 @@ export declare function isAlreadyRunning(): number | null;
2
2
  export declare function startDaemon(options?: {
3
3
  port?: number;
4
4
  deviceName?: string;
5
+ debug?: boolean;
5
6
  }): Promise<void>;
6
7
  export declare function stopDaemon(): boolean;
@@ -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
- // Polling failure is non-fatal
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.23.1";
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.23.1";
8
+ export const PACKAGE_VERSION = "0.25.0";
9
9
  export function createServer(deviceName) {
10
10
  const server = new McpServer({
11
11
  name: "zhihand",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhihand/mcp",
3
- "version": "0.23.1",
3
+ "version": "0.25.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "ZhiHand MCP Server — phone control tools for Claude Code, Codex, Gemini CLI, and OpenClaw",