@zhihand/mcp 0.15.0 → 0.16.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,22 +2,30 @@
2
2
 
3
3
  ZhiHand MCP Server — let AI agents see and control your phone.
4
4
 
5
- Version: `0.15.0`
5
+ Version: `0.16.0`
6
6
 
7
7
  ## What is this?
8
8
 
9
- `@zhihand/mcp` is the core integration layer for ZhiHand. It provides an [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that exposes phone control tools to any compatible AI agent, including:
9
+ `@zhihand/mcp` is the core integration layer for ZhiHand. It runs as a **persistent daemon** that exposes phone control tools to any compatible AI agent via [MCP (Model Context Protocol)](https://modelcontextprotocol.io/), including:
10
10
 
11
11
  - **Claude Code**
12
12
  - **Codex CLI**
13
13
  - **Gemini CLI**
14
14
  - **OpenClaw**
15
15
 
16
- One npm package, two entry points:
16
+ The daemon is a single persistent process that bundles three subsystems:
17
+
18
+ | Subsystem | Purpose |
19
+ |---|---|
20
+ | **MCP Server** | HTTP Streamable transport on `localhost:18686/mcp` — serves tool calls to AI agents |
21
+ | **Relay** | Brain heartbeat (30s), prompt listener (phone-initiated tasks), CLI dispatch |
22
+ | **Config API** | IPC endpoint for `zhihand gemini/claude/codex` backend switching |
23
+
24
+ Legacy entry points (backward compatible):
17
25
 
18
26
  | Entry | Purpose |
19
27
  |---|---|
20
- | `zhihand serve` | MCP Server (stdio) — used by Claude Code, Codex, Gemini CLI |
28
+ | `zhihand serve` | MCP Server (stdio mode) — legacy, still works for direct CLI integration |
21
29
  | `zhihand.openclaw` | OpenClaw Plugin entry — thin wrapper calling the same core |
22
30
 
23
31
  ## Requirements
@@ -53,10 +61,20 @@ This runs the full interactive setup:
53
61
  4. Saves credentials to `~/.zhihand/credentials.json`
54
62
  5. Detects installed CLI tools (Claude Code, Codex, Gemini CLI, OpenClaw)
55
63
  6. Auto-selects the best available tool and configures MCP automatically
64
+ 7. Starts the daemon (MCP Server + Relay + Config API)
56
65
 
57
66
  No manual MCP configuration needed — `zhihand setup` handles everything.
58
67
 
59
- ### 2. Start using it
68
+ ### 2. Start the daemon
69
+
70
+ ```bash
71
+ zhihand start # Start daemon in foreground
72
+ zhihand start -d # Start daemon in background (detached)
73
+ ```
74
+
75
+ The daemon runs the MCP Server on `localhost:18686/mcp` (HTTP Streamable transport), maintains a brain heartbeat every 30 seconds (keeps the phone Brain indicator green), and listens for phone-initiated prompts.
76
+
77
+ ### 3. Start using it
60
78
 
61
79
  Once configured, your AI agent can use ZhiHand tools directly. For example, in Claude Code:
62
80
 
@@ -70,18 +88,36 @@ Once configured, your AI agent can use ZhiHand tools directly. For example, in C
70
88
  ## CLI Commands
71
89
 
72
90
  ```
73
- zhihand serve Start MCP Server (stdio mode, called by AI tools)
74
- zhihand setup Interactive setup: pair + auto-detect + auto-configure
91
+ zhihand setup Interactive setup: pair + detect tools + auto-select + configure MCP + start daemon
92
+ zhihand start Start daemon (MCP Server + Relay + Config API)
93
+ zhihand start -d Start daemon in background (detached)
94
+ zhihand stop Stop the running daemon
95
+ zhihand status Show daemon status, pairing info, device, and active backend
96
+
75
97
  zhihand pair Pair with a phone (QR code in terminal)
76
- zhihand status Show pairing status, device info, and active backend
77
98
  zhihand detect List detected CLI tools and their login status
99
+ zhihand serve Start MCP Server (stdio mode, backward compatible)
78
100
  zhihand --help Show help
79
101
 
80
- zhihand claude Switch backend to Claude Code (auto-configures MCP)
81
- zhihand codex Switch backend to Codex CLI (auto-configures MCP)
82
- zhihand gemini Switch backend to Gemini CLI (auto-configures MCP)
102
+ zhihand claude Switch backend to Claude Code (sends IPC to daemon, auto-configures MCP)
103
+ zhihand codex Switch backend to Codex CLI (sends IPC to daemon, auto-configures MCP)
104
+ zhihand gemini Switch backend to Gemini CLI (sends IPC to daemon, auto-configures MCP)
83
105
  ```
84
106
 
107
+ ### Daemon Lifecycle
108
+
109
+ ```bash
110
+ zhihand start # Start daemon in foreground
111
+ zhihand start -d # Start daemon in background
112
+ zhihand stop # Stop the daemon
113
+ zhihand status # Check if daemon is running, show device & backend info
114
+ ```
115
+
116
+ The daemon is a single persistent process that runs:
117
+ - **MCP Server** on `localhost:18686/mcp` (HTTP Streamable transport)
118
+ - **Relay**: brain heartbeat every 30s (keeps phone Brain indicator green), prompt listener (phone-initiated tasks dispatched to CLI), CLI dispatch
119
+ - **Config API**: IPC endpoint for backend switching
120
+
85
121
  ### Switching Backends
86
122
 
87
123
  Use `zhihand claude`, `zhihand codex`, or `zhihand gemini` to switch the active backend:
@@ -93,6 +129,7 @@ zhihand codex # Switch to Codex CLI
93
129
  ```
94
130
 
95
131
  When you switch:
132
+ - The command sends an **IPC message to the running daemon**
96
133
  - MCP config is **automatically added** to the new backend
97
134
  - MCP config is **automatically removed** from the previous backend
98
135
  - If the tool is not installed, an error is shown
@@ -154,26 +191,40 @@ Pair with a phone device. Returns a QR code and pairing URL.
154
191
  ## How It Works
155
192
 
156
193
  ```
157
- AI Agent ←stdiozhihand serve (MCP Server)
158
-
159
- ├── POST /v1/plugins Register plugin
160
- ├── POST /v1/pairing/sessions Create pairing
161
- ├── POST /v1/credentials/{id}/commands Send command
162
- ├── GET /v1/credentials/{id}/commands/{cid} Poll ACK
163
- ├── SSE /v1/credentials/{id}/events?topic=commands Real-time ACK
164
- └── GET /v1/credentials/{id}/screen Fetch screenshot (JPEG)
165
-
166
- ZhiHand Server
167
-
168
- ZhiHand Mobile App
194
+ AI Agent ←HTTP StreamableDaemon (localhost:18686/mcp)
195
+
196
+ ├── MCP Server ──→ ZhiHand Server ──→ Mobile App
197
+ │ (tool calls: control, screenshot, pair)
198
+
199
+ ├── Relay
200
+ ├── Brain heartbeat (30s) ──→ Server
201
+ │ ├── Prompt listener (SSE) ←── Server ←── Phone
202
+ └── CLI dispatch ──→ spawn claude/codex/gemini
203
+
204
+ └── Config API
205
+ └── IPC from zhihand claude/codex/gemini
169
206
  ```
170
207
 
208
+ ### Agent-initiated flow (tool calls)
209
+
171
210
  1. AI agent calls a tool (e.g. `zhihand_control` with `action: "click"`)
172
211
  2. MCP Server translates to a device command and enqueues it via the ZhiHand API
173
212
  3. Mobile app picks up the command, executes it, and sends an ACK
174
213
  4. MCP Server receives the ACK (via SSE or polling fallback)
175
214
  5. MCP Server fetches a fresh screenshot and returns it to the AI agent
176
215
 
216
+ ### Phone-initiated flow (prompt relay)
217
+
218
+ 1. User speaks or types a prompt on the phone
219
+ 2. Phone sends prompt to ZhiHand Server
220
+ 3. Daemon receives prompt via SSE
221
+ 4. Daemon spawns the active CLI tool (e.g. `claude`, `codex`, `gemini`) with the prompt
222
+ 5. CLI tool executes, result is sent back to the phone
223
+
224
+ ### Brain heartbeat
225
+
226
+ The daemon sends a heartbeat to the ZhiHand Server every 30 seconds. This keeps the **Brain indicator green** on the phone, showing the user that an AI backend is connected and ready.
227
+
177
228
  Screenshots are transferred as raw JPEG binary and only base64-encoded at the LLM API boundary, minimizing bandwidth.
178
229
 
179
230
  ## Credential Storage
@@ -184,6 +235,7 @@ Pairing credentials are stored at:
184
235
  ~/.zhihand/
185
236
  ├── credentials.json # Device credentials (credentialId, controllerToken, endpoint)
186
237
  ├── backend.json # Active backend selection (claudecode/codex/gemini)
238
+ ├── daemon.pid # Daemon PID file (for zhihand stop)
187
239
  └── state.json # Current pairing session state
188
240
  ```
189
241
 
@@ -209,10 +261,10 @@ You can manage multiple devices. The `credentials.json` file stores a `default`
209
261
  ```
210
262
  packages/mcp/
211
263
  ├── bin/
212
- │ ├── zhihand # Main CLI entry (serve/setup/pair/status/detect)
264
+ │ ├── zhihand # Main CLI entry (start/stop/status/setup/serve/pair/detect)
213
265
  │ └── zhihand.openclaw # OpenClaw plugin entry
214
266
  ├── src/
215
- │ ├── index.ts # MCP Server (stdio transport)
267
+ │ ├── index.ts # MCP Server (stdio transport, legacy)
216
268
  │ ├── openclaw.adapter.ts # OpenClaw Plugin adapter (thin wrapper)
217
269
  │ ├── core/
218
270
  │ │ ├── config.ts # Credential & config management (~/.zhihand/)
@@ -220,6 +272,11 @@ packages/mcp/
220
272
  │ │ ├── screenshot.ts # Binary screenshot fetch (JPEG)
221
273
  │ │ ├── sse.ts # SSE client + hybrid ACK (SSE push + polling fallback)
222
274
  │ │ └── pair.ts # Plugin registration + device pairing flow
275
+ │ ├── daemon/
276
+ │ │ ├── index.ts # Daemon entry: HTTP server + MCP + Relay + Config API
277
+ │ │ ├── heartbeat.ts # Brain heartbeat loop (30s interval, 5s retry)
278
+ │ │ ├── prompt-listener.ts # SSE + polling prompt listener with dedup
279
+ │ │ └── dispatcher.ts # Async CLI dispatch (spawn + timeout + two-stage kill)
223
280
  │ ├── tools/
224
281
  │ │ ├── schemas.ts # Zod parameter schemas
225
282
  │ │ ├── control.ts # zhihand_control handler
package/bin/zhihand CHANGED
@@ -3,6 +3,7 @@
3
3
  import os from "node:os";
4
4
  import { parseArgs } from "node:util";
5
5
  import { startStdioServer } from "../dist/index.js";
6
+ import { startDaemon, stopDaemon, isAlreadyRunning } from "../dist/daemon/index.js";
6
7
  import { detectCLITools, formatDetectedTools } from "../dist/cli/detect.js";
7
8
  import { detectAndSetupOpenClaw } from "../dist/cli/openclaw.js";
8
9
  import { loadDefaultCredential, loadBackendConfig, saveBackendConfig } from "../dist/core/config.js";
@@ -22,9 +23,9 @@ const { positionals, values } = parseArgs({
22
23
  strict: false,
23
24
  options: {
24
25
  device: { type: "string" },
25
- model: { type: "string" },
26
- http: { type: "boolean", default: false },
27
26
  help: { type: "boolean", short: "h", default: false },
27
+ detach: { type: "boolean", short: "d", default: false },
28
+ port: { type: "string" },
28
29
  },
29
30
  });
30
31
 
@@ -32,28 +33,35 @@ const command = positionals[0] ?? "serve";
32
33
 
33
34
  if (values.help) {
34
35
  console.log(`
35
- zhihand — MCP Server for phone control
36
+ zhihand — MCP Server and Relay for phone control
36
37
 
37
38
  Usage:
38
- zhihand serve Start MCP Server (stdio mode)
39
- zhihand serve --http Start MCP Server (HTTP mode)
40
- zhihand pair Pair with a phone device
41
- zhihand status Show pairing status and device info
42
- zhihand detect Detect available CLI tools
43
- zhihand setup Interactive setup: pair + auto-configure
39
+ zhihand start Start daemon (MCP Server + Relay, foreground)
40
+ zhihand start -d Start daemon in background (detach)
41
+ zhihand stop Stop daemon
42
+ zhihand status Show status (pairing, backend, brain)
44
43
 
44
+ zhihand gemini Switch backend to Gemini CLI
45
45
  zhihand claude Switch backend to Claude Code
46
46
  zhihand codex Switch backend to Codex CLI
47
- zhihand gemini Switch backend to Gemini CLI
47
+
48
+ zhihand setup Interactive setup: pair + configure + start
49
+ zhihand pair Pair with a phone device
50
+ zhihand detect Detect available CLI tools
51
+
52
+ zhihand serve Start MCP Server (stdio mode, backward compat)
48
53
 
49
54
  Options:
50
55
  --device <name> Use a specific paired device
56
+ --port <port> Override daemon port (default: 18686)
57
+ -d, --detach Run daemon in background
51
58
  -h, --help Show this help
52
59
  `);
53
60
  process.exit(0);
54
61
  }
55
62
 
56
- // Handle CLI tool subcommands: claude, codex, gemini → switch backend
63
+ // ── Backend switch commands: claude, codex, gemini ─────────
64
+
57
65
  if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
58
66
  const backendName = CLI_TOOL_MAP[command];
59
67
  const tools = await detectCLITools();
@@ -69,6 +77,8 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
69
77
  console.error(`Warning: ${command} is installed but not logged in.`);
70
78
  }
71
79
 
80
+ // Check if daemon is running — if so, notify it via HTTP
81
+ const daemonPid = isAlreadyRunning();
72
82
  const config = loadBackendConfig();
73
83
  const previous = config.activeBackend;
74
84
 
@@ -78,20 +88,82 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
78
88
  }
79
89
 
80
90
  console.log(`Switching backend to ${displayName(backendName)}...`);
91
+
92
+ // Configure MCP (HTTP transport)
81
93
  const { configured, removed } = configureMCP(backendName, previous);
82
94
 
83
95
  if (configured) {
84
- saveBackendConfig({ activeBackend: backendName });
85
- console.log(`\nBackend switched to ${displayName(backendName)}.`);
86
- if (previous) {
87
- console.log(`Previous backend: ${displayName(previous)} ${removed ? '(MCP config removed)' : '(MCP config removal skipped)'}.`);
96
+ // Notify daemon if running
97
+ if (daemonPid) {
98
+ try {
99
+ const port = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || 18686;
100
+ const res = await fetch(`http://127.0.0.1:${port}/internal/backend`, {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/json" },
103
+ body: JSON.stringify({ backend: backendName }),
104
+ signal: AbortSignal.timeout(5000),
105
+ });
106
+ if (res.ok) {
107
+ console.log(`\nDaemon notified. Backend switched to ${displayName(backendName)}.`);
108
+ }
109
+ } catch {
110
+ // Daemon not responding, just save config
111
+ saveBackendConfig({ activeBackend: backendName });
112
+ console.log(`\nBackend config saved. Daemon not responding — restart with 'zhihand start'.`);
113
+ }
114
+ } else {
115
+ saveBackendConfig({ activeBackend: backendName });
116
+ console.log(`\nBackend switched to ${displayName(backendName)}.`);
117
+ console.log(`Start the daemon to receive prompts: zhihand start`);
118
+ }
119
+
120
+ if (previous && removed) {
121
+ console.log(`Previous backend: ${displayName(previous)} (MCP config removed).`);
88
122
  }
89
123
  }
90
124
  process.exit(0);
91
125
  }
92
126
 
127
+ // ── Other commands ─────────────────────────────────────────
128
+
93
129
  switch (command) {
130
+ case "start":
131
+ case "relay": {
132
+ if (values.detach) {
133
+ const { spawn: spawnChild } = await import("node:child_process");
134
+ const args = [process.argv[1], "start"];
135
+ if (values.port) args.push("--port", values.port);
136
+ if (values.device) args.push("--device", values.device);
137
+
138
+ const child = spawnChild(process.execPath, args, {
139
+ detached: true,
140
+ stdio: "ignore",
141
+ env: { ...process.env },
142
+ });
143
+ child.unref();
144
+ console.log(`Daemon starting in background (PID ${child.pid}).`);
145
+ process.exit(0);
146
+ }
147
+ const port = values.port ? parseInt(values.port, 10) : undefined;
148
+ await startDaemon({
149
+ port,
150
+ deviceName: values.device ?? process.env.ZHIHAND_DEVICE,
151
+ });
152
+ break;
153
+ }
154
+
155
+ case "stop": {
156
+ const stopped = stopDaemon();
157
+ if (stopped) {
158
+ console.log("Daemon stopped.");
159
+ } else {
160
+ console.log("No daemon is running.");
161
+ }
162
+ break;
163
+ }
164
+
94
165
  case "serve": {
166
+ // Backward compatible: stdio MCP server (for old configs)
95
167
  await startStdioServer(values.device ?? process.env.ZHIHAND_DEVICE);
96
168
  break;
97
169
  }
@@ -106,18 +178,31 @@ switch (command) {
106
178
  case "status": {
107
179
  const cred = loadDefaultCredential();
108
180
  const backend = loadBackendConfig();
181
+ const daemonPid = isAlreadyRunning();
182
+
109
183
  if (cred) {
110
184
  console.log(`Paired device: ${cred.deviceName}`);
111
185
  console.log(`Credential ID: ${cred.credentialId}`);
112
186
  console.log(`Endpoint: ${cred.endpoint}`);
113
- console.log(`Paired at: ${cred.pairedAt}`);
114
187
  } else {
115
- console.log("No paired device. Run: zhihand pair");
188
+ console.log("No paired device. Run: zhihand setup");
116
189
  }
117
- if (backend.activeBackend) {
118
- console.log(`Active backend: ${displayName(backend.activeBackend)}`);
119
- } else {
120
- console.log("No active backend. Run: zhihand claude / zhihand gemini / zhihand codex");
190
+ console.log(`Active backend: ${backend.activeBackend ? displayName(backend.activeBackend) : "(none)"}`);
191
+ console.log(`Daemon: ${daemonPid ? `running (PID ${daemonPid})` : "not running"}`);
192
+
193
+ // If daemon running, get live status
194
+ if (daemonPid) {
195
+ try {
196
+ const port = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || 18686;
197
+ const res = await fetch(`http://127.0.0.1:${port}/internal/status`, {
198
+ signal: AbortSignal.timeout(3000),
199
+ });
200
+ if (res.ok) {
201
+ const status = await res.json();
202
+ console.log(`Processing: ${status.processing ? "yes" : "idle"}`);
203
+ console.log(`Queue: ${status.queueLength} prompt(s)`);
204
+ }
205
+ } catch { /* daemon not responding */ }
121
206
  }
122
207
  break;
123
208
  }
@@ -129,7 +214,7 @@ switch (command) {
129
214
  }
130
215
 
131
216
  case "setup": {
132
- // 1. Check/create pairing
217
+ // 1. Pair
133
218
  let cred = loadDefaultCredential();
134
219
  if (!cred) {
135
220
  console.log("No paired device found. Starting pairing...\n");
@@ -142,30 +227,34 @@ switch (command) {
142
227
  console.log(`\nPaired: ${cred.deviceName} (${cred.credentialId})\n`);
143
228
  }
144
229
 
145
- // 2. Detect CLI tools
230
+ // 2. Detect tools
146
231
  const tools = await detectCLITools();
147
232
  console.log(formatDetectedTools(tools));
148
233
 
149
234
  if (tools.length === 0) {
150
- console.log("\nNo CLI tools detected. Install one of: Claude Code, Codex CLI, Gemini CLI, OpenClaw.");
235
+ console.log("\nNo CLI tools detected. Install one of: Claude Code, Codex CLI, Gemini CLI.");
151
236
  break;
152
237
  }
153
238
 
154
- // 3. Auto-select best tool (logged in + highest priority) and configure
239
+ // 3. Auto-select best tool and configure MCP (HTTP transport)
155
240
  const best = tools.find((t) => t.loggedIn) ?? tools[0];
156
241
  const config = loadBackendConfig();
157
242
 
158
243
  console.log(`\nAuto-selecting backend: ${displayName(best.name)}...`);
159
-
160
- if (best.name === "openclaw") {
161
- await detectAndSetupOpenClaw();
162
- } else {
163
- configureMCP(best.name, config.activeBackend);
244
+ // Ensure MCP URL uses correct port
245
+ if (values.port) {
246
+ process.env.ZHIHAND_PORT = values.port;
164
247
  }
165
-
248
+ configureMCP(best.name, config.activeBackend);
166
249
  saveBackendConfig({ activeBackend: best.name });
167
- console.log(`\nSetup complete. Backend: ${displayName(best.name)}.`);
168
- console.log(`To switch backend later: zhihand claude / zhihand gemini / zhihand codex`);
250
+
251
+ // 4. Start daemon
252
+ console.log(`\nStarting daemon...\n`);
253
+ const port = values.port ? parseInt(values.port, 10) : undefined;
254
+ await startDaemon({
255
+ port,
256
+ deviceName: values.device ?? process.env.ZHIHAND_DEVICE,
257
+ });
169
258
  break;
170
259
  }
171
260
 
@@ -1,6 +1,6 @@
1
1
  import type { BackendName } from "../core/config.ts";
2
2
  /**
3
- * Configure MCP for the selected backend and remove from others.
3
+ * Configure MCP (HTTP transport) for the selected backend and remove from others.
4
4
  */
5
5
  export declare function configureMCP(backend: BackendName, previousBackend: BackendName | null): {
6
6
  configured: boolean;
@@ -1,15 +1,20 @@
1
1
  import { execSync } from "node:child_process";
2
+ const DEFAULT_PORT = 18686;
3
+ function mcpUrl() {
4
+ const port = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || DEFAULT_PORT;
5
+ return `http://localhost:${port}/mcp`;
6
+ }
2
7
  const MCP_COMMANDS = {
3
8
  claudecode: {
4
- add: "claude mcp add zhihand -- zhihand serve",
9
+ add: () => `claude mcp add --transport http zhihand ${mcpUrl()}`,
5
10
  remove: "claude mcp remove zhihand",
6
11
  },
7
12
  codex: {
8
- add: "codex mcp add zhihand -- zhihand serve",
13
+ add: () => `codex mcp add zhihand --url ${mcpUrl()}`,
9
14
  remove: "codex mcp remove zhihand",
10
15
  },
11
16
  gemini: {
12
- add: "gemini mcp add --scope user zhihand zhihand -- serve",
17
+ add: () => `gemini mcp add --transport http --scope user zhihand ${mcpUrl()}`,
13
18
  remove: "gemini mcp remove --scope user zhihand",
14
19
  },
15
20
  };
@@ -29,7 +34,7 @@ function tryRun(cmd) {
29
34
  }
30
35
  }
31
36
  /**
32
- * Configure MCP for the selected backend and remove from others.
37
+ * Configure MCP (HTTP transport) for the selected backend and remove from others.
33
38
  */
34
39
  export function configureMCP(backend, previousBackend) {
35
40
  let removed = false;
@@ -49,9 +54,10 @@ export function configureMCP(backend, previousBackend) {
49
54
  }
50
55
  else {
51
56
  const cmds = MCP_COMMANDS[backend];
52
- console.log(` Configuring MCP for ${DISPLAY_NAMES[backend]}...`);
57
+ const addCmd = cmds.add();
58
+ console.log(` Configuring MCP for ${DISPLAY_NAMES[backend]} (HTTP transport)...`);
53
59
  try {
54
- execSync(cmds.add, { stdio: "inherit", timeout: 10_000 });
60
+ execSync(addCmd, { stdio: "inherit", timeout: 10_000 });
55
61
  configured = true;
56
62
  }
57
63
  catch (err) {
@@ -0,0 +1,13 @@
1
+ import type { ZhiHandConfig, BackendName } from "../core/config.ts";
2
+ export interface DispatchResult {
3
+ text: string;
4
+ success: boolean;
5
+ durationMs: number;
6
+ }
7
+ /**
8
+ * Kill the active child process. Returns a promise that resolves
9
+ * when the child has exited (or immediately if no child).
10
+ */
11
+ export declare function killActiveChild(): Promise<void>;
12
+ export declare function dispatchToCLI(backend: Exclude<BackendName, "openclaw">, prompt: string, model?: string): Promise<DispatchResult>;
13
+ export declare function postReply(config: ZhiHandConfig, promptId: string, text: string): Promise<boolean>;
@@ -0,0 +1,145 @@
1
+ import { spawn } from "node:child_process";
2
+ const CLI_TIMEOUT = 120_000; // 120s
3
+ const SIGKILL_DELAY = 2_000; // 2s after SIGTERM
4
+ const MAX_OUTPUT_BYTES = 100 * 1024; // 100KB
5
+ const BACKENDS = {
6
+ gemini: {
7
+ command: "gemini",
8
+ buildArgs: (prompt, model) => [
9
+ "--approval-mode", "yolo",
10
+ "--model", model ?? process.env.CLAUDE_GEMINI_MODEL ?? "gemini-3.1-pro-preview",
11
+ "-i", prompt,
12
+ ],
13
+ env: {
14
+ GEMINI_SANDBOX: "false",
15
+ TERM: "xterm-256color",
16
+ COLORTERM: "truecolor",
17
+ },
18
+ },
19
+ claudecode: {
20
+ command: "claude",
21
+ buildArgs: (prompt) => ["-p", prompt, "--output-format", "json"],
22
+ },
23
+ codex: {
24
+ command: "codex",
25
+ buildArgs: (prompt) => ["-q", prompt, "--json"],
26
+ },
27
+ };
28
+ let activeChild = null;
29
+ /**
30
+ * Kill the active child process. Returns a promise that resolves
31
+ * when the child has exited (or immediately if no child).
32
+ */
33
+ export function killActiveChild() {
34
+ if (!activeChild || activeChild.killed) {
35
+ return Promise.resolve();
36
+ }
37
+ return new Promise((resolve) => {
38
+ const child = activeChild;
39
+ child.once("close", () => resolve());
40
+ child.kill("SIGTERM");
41
+ setTimeout(() => {
42
+ if (!child.killed) {
43
+ child.kill("SIGKILL");
44
+ }
45
+ }, SIGKILL_DELAY);
46
+ // Safety: resolve after SIGKILL_DELAY + 1s even if no close event
47
+ setTimeout(() => resolve(), SIGKILL_DELAY + 1000);
48
+ });
49
+ }
50
+ export function dispatchToCLI(backend, prompt, model) {
51
+ const config = BACKENDS[backend];
52
+ if (!config) {
53
+ return Promise.resolve({
54
+ text: `Unsupported backend: ${backend}`,
55
+ success: false,
56
+ durationMs: 0,
57
+ });
58
+ }
59
+ const startTime = Date.now();
60
+ const args = config.buildArgs(prompt, model);
61
+ const env = { ...process.env, ...config.env };
62
+ return new Promise((resolve) => {
63
+ const chunks = [];
64
+ let totalBytes = 0;
65
+ let truncated = false;
66
+ let settled = false;
67
+ function settle(result) {
68
+ if (settled)
69
+ return;
70
+ settled = true;
71
+ resolve(result);
72
+ }
73
+ const child = spawn(config.command, args, {
74
+ env,
75
+ stdio: ["ignore", "pipe", "pipe"],
76
+ detached: false,
77
+ });
78
+ activeChild = child;
79
+ // Timeout with two-stage kill
80
+ const timer = setTimeout(() => {
81
+ child.kill("SIGTERM");
82
+ setTimeout(() => {
83
+ if (!child.killed)
84
+ child.kill("SIGKILL");
85
+ }, SIGKILL_DELAY);
86
+ }, CLI_TIMEOUT);
87
+ const collectOutput = (data) => {
88
+ if (truncated)
89
+ return;
90
+ totalBytes += data.length;
91
+ if (totalBytes > MAX_OUTPUT_BYTES) {
92
+ truncated = true;
93
+ chunks.push(data.subarray(0, MAX_OUTPUT_BYTES - (totalBytes - data.length)));
94
+ }
95
+ else {
96
+ chunks.push(data);
97
+ }
98
+ };
99
+ child.stdout?.on("data", collectOutput);
100
+ child.stderr?.on("data", collectOutput);
101
+ child.on("close", (code) => {
102
+ clearTimeout(timer);
103
+ activeChild = null;
104
+ const durationMs = Date.now() - startTime;
105
+ let text = Buffer.concat(chunks).toString("utf8").trim();
106
+ if (truncated) {
107
+ text += "\n\n[Output truncated at 100KB]";
108
+ }
109
+ if (!text) {
110
+ text = code === 0
111
+ ? "Task completed (no output)."
112
+ : `CLI process exited with code ${code}.`;
113
+ }
114
+ settle({ text, success: code === 0, durationMs });
115
+ });
116
+ child.on("error", (err) => {
117
+ clearTimeout(timer);
118
+ activeChild = null;
119
+ settle({
120
+ text: `CLI launch failed: ${err.message}`,
121
+ success: false,
122
+ durationMs: Date.now() - startTime,
123
+ });
124
+ });
125
+ });
126
+ }
127
+ export async function postReply(config, promptId, text) {
128
+ try {
129
+ const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/prompts/${encodeURIComponent(promptId)}/reply`;
130
+ const response = await fetch(url, {
131
+ method: "POST",
132
+ headers: {
133
+ "Content-Type": "application/json",
134
+ "x-zhihand-controller-token": config.controllerToken,
135
+ },
136
+ body: JSON.stringify({ role: "assistant", text }),
137
+ signal: AbortSignal.timeout(30_000),
138
+ });
139
+ // 4xx = prompt cancelled, that's OK
140
+ return response.ok || (response.status >= 400 && response.status < 500);
141
+ }
142
+ catch {
143
+ return false;
144
+ }
145
+ }
@@ -0,0 +1,5 @@
1
+ import type { ZhiHandConfig } from "../core/config.ts";
2
+ export declare function sendBrainOnline(config: ZhiHandConfig): Promise<boolean>;
3
+ export declare function sendBrainOffline(config: ZhiHandConfig): Promise<boolean>;
4
+ export declare function startHeartbeatLoop(config: ZhiHandConfig, log: (msg: string) => void): void;
5
+ export declare function stopHeartbeatLoop(): void;
@@ -0,0 +1,65 @@
1
+ const HEARTBEAT_INTERVAL = 30_000; // 30s
2
+ const HEARTBEAT_RETRY_INTERVAL = 5_000; // 5s on failure
3
+ let heartbeatTimer;
4
+ let retryTimer;
5
+ function buildUrl(config) {
6
+ return `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/brain-status`;
7
+ }
8
+ async function sendHeartbeat(config, online) {
9
+ try {
10
+ const response = await fetch(buildUrl(config), {
11
+ method: "POST",
12
+ headers: {
13
+ "Content-Type": "application/json",
14
+ "x-zhihand-controller-token": config.controllerToken,
15
+ },
16
+ body: JSON.stringify({ plugin_online: online }),
17
+ signal: AbortSignal.timeout(10_000),
18
+ });
19
+ return response.ok;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ export async function sendBrainOnline(config) {
26
+ return sendHeartbeat(config, true);
27
+ }
28
+ export async function sendBrainOffline(config) {
29
+ return sendHeartbeat(config, false);
30
+ }
31
+ export function startHeartbeatLoop(config, log) {
32
+ let retrying = false;
33
+ async function beat() {
34
+ const ok = await sendBrainOnline(config);
35
+ if (!ok && !retrying) {
36
+ retrying = true;
37
+ log("[heartbeat] Failed, retrying every 5s...");
38
+ // Fast retry to recover before 40s TTL
39
+ retryTimer = setInterval(async () => {
40
+ const recovered = await sendBrainOnline(config);
41
+ if (recovered) {
42
+ retrying = false;
43
+ if (retryTimer) {
44
+ clearInterval(retryTimer);
45
+ retryTimer = undefined;
46
+ }
47
+ log("[heartbeat] Recovered.");
48
+ }
49
+ }, HEARTBEAT_RETRY_INTERVAL);
50
+ }
51
+ }
52
+ // Immediate first heartbeat
53
+ beat();
54
+ heartbeatTimer = setInterval(beat, HEARTBEAT_INTERVAL);
55
+ }
56
+ export function stopHeartbeatLoop() {
57
+ if (heartbeatTimer) {
58
+ clearInterval(heartbeatTimer);
59
+ heartbeatTimer = undefined;
60
+ }
61
+ if (retryTimer) {
62
+ clearInterval(retryTimer);
63
+ retryTimer = undefined;
64
+ }
65
+ }
@@ -0,0 +1,6 @@
1
+ export declare function isAlreadyRunning(): number | null;
2
+ export declare function startDaemon(options?: {
3
+ port?: number;
4
+ deviceName?: string;
5
+ }): Promise<void>;
6
+ export declare function stopDaemon(): boolean;
@@ -0,0 +1,283 @@
1
+ import { createServer as createHTTPServer } from "node:http";
2
+ import { randomUUID } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
+ // Transport type used only for cleanup interface
7
+ import { createServer as createMcpServer } from "../index.js";
8
+ import { resolveConfig, loadBackendConfig, saveBackendConfig, resolveZhiHandDir, ensureZhiHandDir, } from "../core/config.js";
9
+ import { startHeartbeatLoop, stopHeartbeatLoop, sendBrainOffline } from "./heartbeat.js";
10
+ import { PromptListener } from "./prompt-listener.js";
11
+ import { dispatchToCLI, postReply, killActiveChild } from "./dispatcher.js";
12
+ const DEFAULT_PORT = 18686;
13
+ const PID_FILE = "daemon.pid";
14
+ // ── State ──────────────────────────────────────────────────
15
+ let activeBackend = null;
16
+ let isProcessing = false;
17
+ const promptQueue = [];
18
+ function log(msg) {
19
+ const ts = new Date().toLocaleTimeString();
20
+ process.stdout.write(`[${ts}] ${msg}\n`);
21
+ }
22
+ // ── Prompt Processing ──────────────────────────────────────
23
+ async function processPrompt(config, prompt) {
24
+ if (!activeBackend) {
25
+ log(`[relay] No backend configured. Replying with error.`);
26
+ await postReply(config, prompt.id, "No AI backend configured. Run: zhihand gemini / zhihand claude / zhihand codex");
27
+ return;
28
+ }
29
+ const preview = prompt.text.length > 40 ? prompt.text.slice(0, 40) + "..." : prompt.text;
30
+ log(`[relay] Prompt: "${preview}" → dispatching to ${activeBackend}...`);
31
+ const result = await dispatchToCLI(activeBackend, prompt.text);
32
+ const ok = await postReply(config, prompt.id, result.text);
33
+ const dur = (result.durationMs / 1000).toFixed(1);
34
+ if (ok) {
35
+ log(`[relay] Reply posted (${dur}s, ${result.success ? "ok" : "error"}).`);
36
+ }
37
+ else {
38
+ log(`[relay] Failed to post reply for prompt ${prompt.id}.`);
39
+ }
40
+ }
41
+ async function processQueue(config) {
42
+ while (promptQueue.length > 0) {
43
+ isProcessing = true;
44
+ const next = promptQueue.shift();
45
+ await processPrompt(config, next);
46
+ }
47
+ isProcessing = false;
48
+ }
49
+ function onPromptReceived(config, prompt) {
50
+ promptQueue.push(prompt);
51
+ if (!isProcessing) {
52
+ processQueue(config);
53
+ }
54
+ }
55
+ // ── Internal API ───────────────────────────────────────────
56
+ function handleInternalAPI(req, res) {
57
+ const url = req.url ?? "";
58
+ if (url === "/internal/backend" && req.method === "POST") {
59
+ let body = "";
60
+ const MAX_BODY = 10 * 1024; // 10KB
61
+ req.on("data", (chunk) => {
62
+ body += chunk.toString();
63
+ if (body.length > MAX_BODY) {
64
+ res.writeHead(413, { "Content-Type": "application/json" });
65
+ res.end(JSON.stringify({ error: "Payload too large" }));
66
+ req.destroy();
67
+ return;
68
+ }
69
+ });
70
+ req.on("end", () => {
71
+ try {
72
+ const { backend } = JSON.parse(body);
73
+ const allowed = ["claudecode", "codex", "gemini"];
74
+ if (!allowed.includes(backend)) {
75
+ res.writeHead(400, { "Content-Type": "application/json" });
76
+ res.end(JSON.stringify({ error: `Invalid backend. Allowed: ${allowed.join(", ")}` }));
77
+ return;
78
+ }
79
+ activeBackend = backend;
80
+ saveBackendConfig({ activeBackend });
81
+ log(`[config] Backend switched to ${activeBackend}.`);
82
+ res.writeHead(200, { "Content-Type": "application/json" });
83
+ res.end(JSON.stringify({ ok: true, backend: activeBackend }));
84
+ }
85
+ catch {
86
+ res.writeHead(400, { "Content-Type": "application/json" });
87
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
88
+ }
89
+ });
90
+ return true;
91
+ }
92
+ if (url === "/internal/status" && req.method === "GET") {
93
+ res.writeHead(200, { "Content-Type": "application/json" });
94
+ res.end(JSON.stringify({
95
+ backend: activeBackend,
96
+ processing: isProcessing,
97
+ queueLength: promptQueue.length,
98
+ pid: process.pid,
99
+ }));
100
+ return true;
101
+ }
102
+ return false;
103
+ }
104
+ // ── PID Management ─────────────────────────────────────────
105
+ function getPidPath() {
106
+ return path.join(resolveZhiHandDir(), PID_FILE);
107
+ }
108
+ function writePid() {
109
+ ensureZhiHandDir();
110
+ fs.writeFileSync(getPidPath(), String(process.pid), { mode: 0o600 });
111
+ }
112
+ function removePid() {
113
+ try {
114
+ fs.unlinkSync(getPidPath());
115
+ }
116
+ catch { /* ignore */ }
117
+ }
118
+ function readPid() {
119
+ try {
120
+ const pid = parseInt(fs.readFileSync(getPidPath(), "utf8").trim(), 10);
121
+ if (isNaN(pid))
122
+ return null;
123
+ // Check if process is still alive
124
+ try {
125
+ process.kill(pid, 0);
126
+ return pid;
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
136
+ export function isAlreadyRunning() {
137
+ return readPid();
138
+ }
139
+ // ── Main Daemon Entry ──────────────────────────────────────
140
+ export async function startDaemon(options) {
141
+ const port = options?.port ?? (parseInt(process.env.ZHIHAND_PORT ?? "", 10) || DEFAULT_PORT);
142
+ // Check if already running
143
+ const existingPid = readPid();
144
+ if (existingPid) {
145
+ log(`Daemon already running (PID ${existingPid}). Use 'zhihand stop' first.`);
146
+ process.exit(1);
147
+ }
148
+ // Load config
149
+ let config;
150
+ try {
151
+ config = resolveConfig(options?.deviceName);
152
+ }
153
+ catch (err) {
154
+ log(`Error: ${err.message}`);
155
+ log("Run 'zhihand setup' to pair a device first.");
156
+ process.exit(1);
157
+ }
158
+ // Load backend
159
+ const backendConfig = loadBackendConfig();
160
+ activeBackend = backendConfig.activeBackend ?? null;
161
+ // Create MCP server
162
+ const mcpServer = createMcpServer(options?.deviceName);
163
+ // Track active transports for cleanup
164
+ const activeTransports = new Map();
165
+ // Create HTTP server
166
+ const httpServer = createHTTPServer(async (req, res) => {
167
+ // Internal API
168
+ if (req.url?.startsWith("/internal/")) {
169
+ if (handleInternalAPI(req, res))
170
+ return;
171
+ res.writeHead(404);
172
+ res.end();
173
+ return;
174
+ }
175
+ // MCP endpoint
176
+ if (req.url === "/mcp" || req.url?.startsWith("/mcp")) {
177
+ try {
178
+ // Check for existing session
179
+ const sessionId = req.headers["mcp-session-id"];
180
+ if (sessionId && activeTransports.has(sessionId)) {
181
+ // Route to existing transport
182
+ const transport = activeTransports.get(sessionId);
183
+ await transport.handleRequest(req, res);
184
+ }
185
+ else if (req.method === "POST" || !sessionId) {
186
+ // New session: create transport, connect to MCP server
187
+ const transport = new StreamableHTTPServerTransport({
188
+ sessionIdGenerator: () => randomUUID(),
189
+ onsessioninitialized: (sid) => {
190
+ activeTransports.set(sid, transport);
191
+ log(`[mcp] Session started: ${sid.slice(0, 8)}...`);
192
+ },
193
+ onsessionclosed: (sid) => {
194
+ activeTransports.delete(sid);
195
+ log(`[mcp] Session closed: ${sid.slice(0, 8)}...`);
196
+ },
197
+ });
198
+ await mcpServer.connect(transport);
199
+ await transport.handleRequest(req, res);
200
+ }
201
+ else {
202
+ // Unknown session ID
203
+ res.writeHead(400, { "Content-Type": "application/json" });
204
+ res.end(JSON.stringify({ error: "Invalid or expired session" }));
205
+ }
206
+ }
207
+ catch (err) {
208
+ if (!res.headersSent) {
209
+ res.writeHead(500);
210
+ res.end(`MCP error: ${err.message}`);
211
+ }
212
+ }
213
+ return;
214
+ }
215
+ // Health check
216
+ if (req.url === "/health") {
217
+ res.writeHead(200, { "Content-Type": "application/json" });
218
+ res.end(JSON.stringify({ status: "ok", pid: process.pid }));
219
+ return;
220
+ }
221
+ res.writeHead(404);
222
+ res.end();
223
+ });
224
+ // Start HTTP server on 127.0.0.1 ONLY (security: no 0.0.0.0)
225
+ await new Promise((resolve, reject) => {
226
+ httpServer.once("error", (err) => {
227
+ if (err.code === "EADDRINUSE") {
228
+ log(`Error: Port ${port} is already in use. Set ZHIHAND_PORT to use a different port.`);
229
+ process.exit(1);
230
+ }
231
+ reject(err);
232
+ });
233
+ httpServer.listen(port, "127.0.0.1", () => resolve());
234
+ });
235
+ writePid();
236
+ // Start heartbeat
237
+ startHeartbeatLoop(config, log);
238
+ // Start prompt listener
239
+ const promptListener = new PromptListener(config, (prompt) => onPromptReceived(config, prompt), log);
240
+ promptListener.start();
241
+ log(`ZhiHand daemon started.`);
242
+ log(` PID: ${process.pid}`);
243
+ log(` MCP: http://127.0.0.1:${port}/mcp`);
244
+ log(` Backend: ${activeBackend ?? "(none)"}`);
245
+ log(` Device: ${config.credentialId}`);
246
+ log(`Listening for prompts...`);
247
+ // Graceful shutdown
248
+ const shutdown = async () => {
249
+ log("\nShutting down...");
250
+ promptListener.stop();
251
+ stopHeartbeatLoop();
252
+ await killActiveChild();
253
+ await sendBrainOffline(config);
254
+ // Close all active MCP transports
255
+ for (const transport of activeTransports.values()) {
256
+ try {
257
+ await transport.close();
258
+ }
259
+ catch { /* ignore */ }
260
+ }
261
+ httpServer.close();
262
+ removePid();
263
+ log("Daemon stopped.");
264
+ process.exit(0);
265
+ };
266
+ process.on("SIGINT", shutdown);
267
+ process.on("SIGTERM", shutdown);
268
+ }
269
+ export function stopDaemon() {
270
+ const pid = readPid();
271
+ if (!pid) {
272
+ return false;
273
+ }
274
+ try {
275
+ process.kill(pid, "SIGTERM");
276
+ return true;
277
+ }
278
+ catch {
279
+ // Process already dead, clean up PID file
280
+ removePid();
281
+ return false;
282
+ }
283
+ }
@@ -0,0 +1,32 @@
1
+ import type { ZhiHandConfig } from "../core/config.ts";
2
+ export interface MobilePrompt {
3
+ id: string;
4
+ credential_id: string;
5
+ edge_id: string;
6
+ text: string;
7
+ status: string;
8
+ client_message_id?: string;
9
+ created_at: string;
10
+ attachments?: unknown[];
11
+ }
12
+ export type PromptHandler = (prompt: MobilePrompt) => void;
13
+ export declare class PromptListener {
14
+ private config;
15
+ private handler;
16
+ private log;
17
+ private processedIds;
18
+ private sseAbort;
19
+ private pollTimer;
20
+ private sseConnected;
21
+ private stopped;
22
+ constructor(config: ZhiHandConfig, handler: PromptHandler, log: (msg: string) => void);
23
+ start(): void;
24
+ stop(): void;
25
+ private dispatchPrompt;
26
+ private connectSSE;
27
+ private resetWatchdog;
28
+ private handleSSEEvent;
29
+ private startPolling;
30
+ private stopPolling;
31
+ private poll;
32
+ }
@@ -0,0 +1,152 @@
1
+ const SSE_WATCHDOG_TIMEOUT = 45_000; // 45s no data → reconnect
2
+ const SSE_RECONNECT_DELAY = 3_000;
3
+ const POLL_INTERVAL = 2_000;
4
+ export class PromptListener {
5
+ config;
6
+ handler;
7
+ log;
8
+ processedIds = new Set();
9
+ sseAbort = null;
10
+ pollTimer = null;
11
+ sseConnected = false;
12
+ stopped = false;
13
+ constructor(config, handler, log) {
14
+ this.config = config;
15
+ this.handler = handler;
16
+ this.log = log;
17
+ }
18
+ start() {
19
+ this.stopped = false;
20
+ this.connectSSE();
21
+ }
22
+ stop() {
23
+ this.stopped = true;
24
+ this.sseAbort?.abort();
25
+ this.sseAbort = null;
26
+ if (this.pollTimer) {
27
+ clearInterval(this.pollTimer);
28
+ this.pollTimer = null;
29
+ }
30
+ }
31
+ dispatchPrompt(prompt) {
32
+ if (this.processedIds.has(prompt.id))
33
+ return;
34
+ this.processedIds.add(prompt.id);
35
+ // Prevent unbounded growth
36
+ if (this.processedIds.size > 500) {
37
+ const arr = [...this.processedIds];
38
+ this.processedIds = new Set(arr.slice(-250));
39
+ }
40
+ this.handler(prompt);
41
+ }
42
+ async connectSSE() {
43
+ while (!this.stopped) {
44
+ try {
45
+ this.sseAbort = new AbortController();
46
+ const url = `${this.config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(this.config.credentialId)}/events/stream?topic=prompts`;
47
+ const response = await fetch(url, {
48
+ headers: {
49
+ "Accept": "text/event-stream",
50
+ "x-zhihand-controller-token": this.config.controllerToken,
51
+ },
52
+ signal: this.sseAbort.signal,
53
+ });
54
+ if (!response.ok) {
55
+ throw new Error(`SSE connect failed: ${response.status}`);
56
+ }
57
+ this.sseConnected = true;
58
+ this.stopPolling();
59
+ this.log("[sse] Connected to prompt stream.");
60
+ const reader = response.body?.getReader();
61
+ if (!reader)
62
+ throw new Error("No response body for SSE");
63
+ const decoder = new TextDecoder();
64
+ let buffer = "";
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);
85
+ }
86
+ catch {
87
+ // Malformed event
88
+ }
89
+ eventData = "";
90
+ }
91
+ }
92
+ }
93
+ clearTimeout(watchdog);
94
+ }
95
+ catch (err) {
96
+ if (this.stopped)
97
+ break;
98
+ this.sseConnected = false;
99
+ this.log(`[sse] Disconnected. Falling back to polling. (${err.message})`);
100
+ this.startPolling();
101
+ await new Promise((r) => setTimeout(r, SSE_RECONNECT_DELAY));
102
+ }
103
+ }
104
+ }
105
+ resetWatchdog() {
106
+ return setTimeout(() => {
107
+ this.log("[sse] Watchdog timeout (45s no data). Reconnecting...");
108
+ this.sseAbort?.abort();
109
+ }, SSE_WATCHDOG_TIMEOUT);
110
+ }
111
+ handleSSEEvent(event) {
112
+ if (event.kind === "prompt.queued" && event.prompt) {
113
+ this.dispatchPrompt(event.prompt);
114
+ }
115
+ else if (event.kind === "prompt.snapshot" && event.prompts) {
116
+ for (const p of event.prompts) {
117
+ if (p.status === "pending" || p.status === "processing") {
118
+ this.dispatchPrompt(p);
119
+ }
120
+ }
121
+ }
122
+ }
123
+ startPolling() {
124
+ if (this.pollTimer)
125
+ return;
126
+ this.pollTimer = setInterval(() => this.poll(), POLL_INTERVAL);
127
+ }
128
+ stopPolling() {
129
+ if (this.pollTimer) {
130
+ clearInterval(this.pollTimer);
131
+ this.pollTimer = null;
132
+ }
133
+ }
134
+ async poll() {
135
+ try {
136
+ const url = `${this.config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(this.config.credentialId)}/prompts?limit=5`;
137
+ const response = await fetch(url, {
138
+ headers: { "x-zhihand-controller-token": this.config.controllerToken },
139
+ signal: AbortSignal.timeout(10_000),
140
+ });
141
+ if (!response.ok)
142
+ return;
143
+ const data = (await response.json());
144
+ for (const prompt of data.items ?? []) {
145
+ this.dispatchPrompt(prompt);
146
+ }
147
+ }
148
+ catch {
149
+ // Polling failure is non-fatal
150
+ }
151
+ }
152
+ }
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
- const PACKAGE_VERSION = "0.15.0";
8
+ const PACKAGE_VERSION = "0.16.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.15.0",
3
+ "version": "0.16.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",