@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 +82 -25
- package/bin/zhihand +122 -33
- package/dist/cli/mcp-config.d.ts +1 -1
- package/dist/cli/mcp-config.js +12 -6
- package/dist/daemon/dispatcher.d.ts +13 -0
- package/dist/daemon/dispatcher.js +145 -0
- package/dist/daemon/heartbeat.d.ts +5 -0
- package/dist/daemon/heartbeat.js +65 -0
- package/dist/daemon/index.d.ts +6 -0
- package/dist/daemon/index.js +283 -0
- package/dist/daemon/prompt-listener.d.ts +32 -0
- package/dist/daemon/prompt-listener.js +152 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
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.
|
|
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
|
|
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
|
-
|
|
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) —
|
|
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
|
|
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
|
|
74
|
-
zhihand
|
|
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 ←
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
194
|
+
AI Agent ←HTTP Streamable→ Daemon (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 (
|
|
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
|
|
39
|
-
zhihand
|
|
40
|
-
zhihand
|
|
41
|
-
zhihand status Show
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
188
|
+
console.log("No paired device. Run: zhihand setup");
|
|
116
189
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
161
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
|
package/dist/cli/mcp-config.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/mcp-config.js
CHANGED
|
@@ -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:
|
|
9
|
+
add: () => `claude mcp add --transport http zhihand ${mcpUrl()}`,
|
|
5
10
|
remove: "claude mcp remove zhihand",
|
|
6
11
|
},
|
|
7
12
|
codex: {
|
|
8
|
-
add:
|
|
13
|
+
add: () => `codex mcp add zhihand --url ${mcpUrl()}`,
|
|
9
14
|
remove: "codex mcp remove zhihand",
|
|
10
15
|
},
|
|
11
16
|
gemini: {
|
|
12
|
-
add:
|
|
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
|
-
|
|
57
|
+
const addCmd = cmds.add();
|
|
58
|
+
console.log(` Configuring MCP for ${DISPLAY_NAMES[backend]} (HTTP transport)...`);
|
|
53
59
|
try {
|
|
54
|
-
execSync(
|
|
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,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.
|
|
8
|
+
const PACKAGE_VERSION = "0.16.0";
|
|
9
9
|
export function createServer(deviceName) {
|
|
10
10
|
const server = new McpServer({
|
|
11
11
|
name: "zhihand",
|