@zhihand/mcp 0.22.1 → 0.23.1

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.20.0`
5
+ Version: `0.23.0`
6
6
 
7
7
  ## What is this?
8
8
 
@@ -5,8 +5,10 @@ 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
- const CLI_TIMEOUT = 120_000; // 120s per prompt
8
+ const CLI_TIMEOUT = 300_000; // 300s (5min) per prompt — MCP tool chains need multiple turns
9
9
  const SIGKILL_DELAY = 2_000; // 2s after SIGTERM
10
+ const MCP_PORT = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || 18686;
11
+ const MCP_URL = `http://127.0.0.1:${MCP_PORT}/mcp`;
10
12
  const MAX_OUTPUT_BYTES = 100 * 1024; // 100KB (for one-shot backends)
11
13
  const MAX_HISTORY_TURNS = 20; // keep last N exchanges in conversation history
12
14
  // Gemini session file polling
@@ -208,7 +210,7 @@ function pollGeminiSession(child, startTime, promptText, log, knownSessionFile,
208
210
  }
209
211
  closeChild(child);
210
212
  settle({
211
- text: "Gemini timed out after 120s.",
213
+ text: "Gemini timed out after 5 minutes.",
212
214
  success: false,
213
215
  durationMs: elapsed,
214
216
  });
@@ -540,11 +542,14 @@ async function dispatchClaudeWithHistory(prompt, startTime, log, model) {
540
542
  log(`[claude] One-shot dispatch (history: ${conversationHistory.length} turns)`);
541
543
  // Pass prompt via stdin (-p -) to avoid ARG_MAX limit with long conversation history
542
544
  // --permission-mode bypassPermissions: auto-approve all tool calls (like gemini's --approval-mode yolo)
545
+ // --mcp-config: explicitly pass MCP server URL so Claude finds it regardless of cwd
546
+ const mcpConfig = JSON.stringify({ mcpServers: { zhihand: { type: "http", url: MCP_URL } } });
543
547
  const child = spawn(claudePath, [
544
548
  "-p", "-",
545
549
  "--model", model,
546
550
  "--output-format", "json",
547
551
  "--permission-mode", "bypassPermissions",
552
+ "--mcp-config", mcpConfig,
548
553
  ], {
549
554
  env: process.env,
550
555
  stdio: ["pipe", "pipe", "pipe"],
@@ -2,6 +2,7 @@ const HEARTBEAT_INTERVAL = 30_000; // 30s
2
2
  const HEARTBEAT_RETRY_INTERVAL = 5_000; // 5s on failure
3
3
  let heartbeatTimer;
4
4
  let retryTimer;
5
+ let stopped = true;
5
6
  let currentMeta = {};
6
7
  /** Update the backend/model metadata that will be sent with the next heartbeat. */
7
8
  export function setBrainMeta(meta) {
@@ -40,36 +41,65 @@ export async function sendBrainOffline(config) {
40
41
  }
41
42
  export function startHeartbeatLoop(config, log) {
42
43
  let retrying = false;
44
+ stopped = false;
43
45
  async function beat() {
46
+ // Skip main-timer beats while retry loop is active (avoids overlap & flapping)
47
+ if (retrying || stopped)
48
+ return;
44
49
  const ok = await sendBrainOnline(config);
45
- if (!ok && !retrying) {
46
- retrying = true;
47
- log("[heartbeat] Failed, retrying every 5s...");
48
- // Fast retry to recover before 40s TTL
49
- retryTimer = setInterval(async () => {
50
- const recovered = await sendBrainOnline(config);
51
- if (recovered) {
52
- retrying = false;
53
- if (retryTimer) {
54
- clearInterval(retryTimer);
55
- retryTimer = undefined;
56
- }
57
- log("[heartbeat] Recovered.");
58
- }
59
- }, HEARTBEAT_RETRY_INTERVAL);
50
+ if (stopped)
51
+ return; // check after await — stopHeartbeatLoop() may have been called
52
+ if (ok) {
53
+ scheduleNextBeat();
54
+ return;
60
55
  }
56
+ // Enter retry mode
57
+ retrying = true;
58
+ log("[heartbeat] Failed, retrying every 5s...");
59
+ scheduleRetry();
60
+ }
61
+ /** Recursive setTimeout for retry — waits for fetch to settle before scheduling next. */
62
+ function scheduleRetry() {
63
+ if (stopped)
64
+ return;
65
+ retryTimer = setTimeout(async () => {
66
+ if (!retrying || stopped)
67
+ return;
68
+ const recovered = await sendBrainOnline(config);
69
+ if (stopped)
70
+ return; // check after await
71
+ if (recovered) {
72
+ retrying = false;
73
+ retryTimer = undefined;
74
+ log("[heartbeat] Recovered.");
75
+ // Resume normal beat cycle
76
+ scheduleNextBeat();
77
+ return;
78
+ }
79
+ // Still failing — schedule another retry
80
+ if (retrying && !stopped)
81
+ scheduleRetry();
82
+ }, HEARTBEAT_RETRY_INTERVAL);
83
+ }
84
+ /** Schedule next normal heartbeat using setTimeout (not setInterval, to avoid overlap). */
85
+ function scheduleNextBeat() {
86
+ if (stopped)
87
+ return;
88
+ if (heartbeatTimer)
89
+ clearTimeout(heartbeatTimer);
90
+ heartbeatTimer = setTimeout(beat, HEARTBEAT_INTERVAL);
61
91
  }
62
92
  // Immediate first heartbeat
63
93
  beat();
64
- heartbeatTimer = setInterval(beat, HEARTBEAT_INTERVAL);
65
94
  }
66
95
  export function stopHeartbeatLoop() {
96
+ stopped = true;
67
97
  if (heartbeatTimer) {
68
- clearInterval(heartbeatTimer);
98
+ clearTimeout(heartbeatTimer);
69
99
  heartbeatTimer = undefined;
70
100
  }
71
101
  if (retryTimer) {
72
- clearInterval(retryTimer);
102
+ clearTimeout(retryTimer);
73
103
  retryTimer = undefined;
74
104
  }
75
105
  }
@@ -27,6 +27,8 @@ export declare class PromptListener {
27
27
  private resetWatchdog;
28
28
  private handleSSEEvent;
29
29
  private startPolling;
30
+ /** Recursive setTimeout: waits for fetch to complete before scheduling next poll. */
31
+ private schedulePoll;
30
32
  private stopPolling;
31
33
  private poll;
32
34
  }
@@ -1,4 +1,4 @@
1
- const SSE_WATCHDOG_TIMEOUT = 45_000; // 45s no data → reconnect
1
+ const SSE_WATCHDOG_TIMEOUT = 120_000; // 120s no data → reconnect (servers may not send keepalive frequently)
2
2
  const SSE_RECONNECT_DELAY = 3_000;
3
3
  const POLL_INTERVAL = 2_000;
4
4
  export class PromptListener {
@@ -24,7 +24,7 @@ export class PromptListener {
24
24
  this.sseAbort?.abort();
25
25
  this.sseAbort = null;
26
26
  if (this.pollTimer) {
27
- clearInterval(this.pollTimer);
27
+ clearTimeout(this.pollTimer);
28
28
  this.pollTimer = null;
29
29
  }
30
30
  }
@@ -63,34 +63,39 @@ export class PromptListener {
63
63
  const decoder = new TextDecoder();
64
64
  let buffer = "";
65
65
  let watchdog = this.resetWatchdog();
66
- while (!this.stopped) {
67
- const { done, value } = await reader.read();
68
- if (done)
69
- break;
70
- // Reset watchdog on any data (including keepalive comments)
71
- clearTimeout(watchdog);
72
- watchdog = this.resetWatchdog();
73
- buffer += decoder.decode(value, { stream: true });
74
- const lines = buffer.split("\n");
75
- buffer = lines.pop() ?? "";
76
- let eventData = "";
77
- for (const line of lines) {
78
- if (line.startsWith("data: ")) {
79
- eventData += (eventData ? "\n" : "") + line.slice(6);
80
- }
81
- else if (line === "" && eventData) {
82
- try {
83
- const event = JSON.parse(eventData);
84
- this.handleSSEEvent(event);
66
+ try {
67
+ while (!this.stopped) {
68
+ const { done, value } = await reader.read();
69
+ if (done)
70
+ break;
71
+ // Reset watchdog on any data (including keepalive comments)
72
+ clearTimeout(watchdog);
73
+ watchdog = this.resetWatchdog();
74
+ buffer += decoder.decode(value, { stream: true });
75
+ const lines = buffer.split("\n");
76
+ buffer = lines.pop() ?? "";
77
+ let eventData = "";
78
+ for (const line of lines) {
79
+ if (line.startsWith("data: ")) {
80
+ eventData += (eventData ? "\n" : "") + line.slice(6);
85
81
  }
86
- catch {
87
- // Malformed event
82
+ else if (line === "" && eventData) {
83
+ try {
84
+ const event = JSON.parse(eventData);
85
+ this.handleSSEEvent(event);
86
+ }
87
+ catch {
88
+ // Malformed event
89
+ }
90
+ eventData = "";
88
91
  }
89
- eventData = "";
90
92
  }
91
93
  }
92
94
  }
93
- clearTimeout(watchdog);
95
+ finally {
96
+ // Always clear watchdog — prevents leaked timer from aborting next connection
97
+ clearTimeout(watchdog);
98
+ }
94
99
  }
95
100
  catch (err) {
96
101
  if (this.stopped)
@@ -104,7 +109,7 @@ export class PromptListener {
104
109
  }
105
110
  resetWatchdog() {
106
111
  return setTimeout(() => {
107
- this.log("[sse] Watchdog timeout (45s no data). Reconnecting...");
112
+ this.log("[sse] Watchdog timeout (120s no data). Reconnecting...");
108
113
  this.sseAbort?.abort();
109
114
  }, SSE_WATCHDOG_TIMEOUT);
110
115
  }
@@ -123,11 +128,24 @@ export class PromptListener {
123
128
  startPolling() {
124
129
  if (this.pollTimer)
125
130
  return;
126
- this.pollTimer = setInterval(() => this.poll(), POLL_INTERVAL);
131
+ this.schedulePoll();
132
+ }
133
+ /** Recursive setTimeout: waits for fetch to complete before scheduling next poll. */
134
+ schedulePoll() {
135
+ if (this.pollTimer)
136
+ return;
137
+ this.pollTimer = setTimeout(async () => {
138
+ this.pollTimer = null;
139
+ await this.poll();
140
+ // Schedule next poll only if SSE is still disconnected
141
+ if (!this.sseConnected && !this.stopped) {
142
+ this.schedulePoll();
143
+ }
144
+ }, POLL_INTERVAL);
127
145
  }
128
146
  stopPolling() {
129
147
  if (this.pollTimer) {
130
- clearInterval(this.pollTimer);
148
+ clearTimeout(this.pollTimer);
131
149
  this.pollTimer = null;
132
150
  }
133
151
  }
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.22.1";
2
+ export declare const PACKAGE_VERSION = "0.23.1";
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.22.1";
8
+ export const PACKAGE_VERSION = "0.23.1";
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.22.1",
3
+ "version": "0.23.1",
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",