@zhihand/mcp 0.19.1 → 0.22.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 +44 -14
- package/bin/zhihand +21 -11
- package/dist/core/config.d.ts +9 -0
- package/dist/core/config.js +12 -0
- package/dist/core/resolve-path.js +2 -2
- package/dist/daemon/dispatcher.d.ts +1 -2
- package/dist/daemon/dispatcher.js +207 -126
- package/dist/daemon/heartbeat.d.ts +7 -0
- package/dist/daemon/heartbeat.js +11 -1
- package/dist/daemon/index.js +27 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
ZhiHand MCP Server — let AI agents see and control your phone.
|
|
4
4
|
|
|
5
|
-
Version: `0.
|
|
5
|
+
Version: `0.20.0`
|
|
6
6
|
|
|
7
7
|
## What is this?
|
|
8
8
|
|
|
@@ -74,6 +74,8 @@ zhihand start -d # Start daemon in background (detached)
|
|
|
74
74
|
|
|
75
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
76
|
|
|
77
|
+
When started with `-d`, daemon logs are written to `~/.zhihand/daemon.log`.
|
|
78
|
+
|
|
77
79
|
### 3. Start using it
|
|
78
80
|
|
|
79
81
|
Once configured, your AI agent can use ZhiHand tools directly. For example, in Claude Code:
|
|
@@ -90,18 +92,19 @@ Once configured, your AI agent can use ZhiHand tools directly. For example, in C
|
|
|
90
92
|
```
|
|
91
93
|
zhihand setup Interactive setup: pair + detect tools + auto-select + configure MCP + start daemon
|
|
92
94
|
zhihand start Start daemon (MCP Server + Relay + Config API)
|
|
93
|
-
zhihand start -d Start daemon in background (
|
|
95
|
+
zhihand start -d Start daemon in background (logs to ~/.zhihand/daemon.log)
|
|
94
96
|
zhihand stop Stop the running daemon
|
|
95
|
-
zhihand status Show daemon status, pairing info, device, and
|
|
97
|
+
zhihand status Show daemon status, pairing info, device, backend, and model
|
|
96
98
|
|
|
97
99
|
zhihand pair Pair with a phone (QR code in terminal)
|
|
98
100
|
zhihand detect List detected CLI tools and their login status
|
|
99
101
|
zhihand serve Start MCP Server (stdio mode, backward compatible)
|
|
100
102
|
zhihand --help Show help
|
|
101
103
|
|
|
102
|
-
zhihand
|
|
103
|
-
zhihand
|
|
104
|
-
zhihand
|
|
104
|
+
zhihand gemini Switch backend to Gemini CLI (default model: flash)
|
|
105
|
+
zhihand claude Switch backend to Claude Code (default model: sonnet)
|
|
106
|
+
zhihand codex Switch backend to Codex CLI (default model: gpt-5.4-mini)
|
|
107
|
+
zhihand gemini --model pro Switch backend with custom model
|
|
105
108
|
```
|
|
106
109
|
|
|
107
110
|
### Daemon Lifecycle
|
|
@@ -123,15 +126,28 @@ The daemon is a single persistent process that runs:
|
|
|
123
126
|
Use `zhihand claude`, `zhihand codex`, or `zhihand gemini` to switch the active backend:
|
|
124
127
|
|
|
125
128
|
```bash
|
|
126
|
-
zhihand gemini
|
|
127
|
-
zhihand claude
|
|
128
|
-
zhihand codex
|
|
129
|
+
zhihand gemini # Switch to Gemini CLI (model: flash)
|
|
130
|
+
zhihand claude # Switch to Claude Code (model: sonnet)
|
|
131
|
+
zhihand codex # Switch to Codex CLI (model: gpt-5.4-mini)
|
|
132
|
+
zhihand gemini --model pro # Use a custom model
|
|
133
|
+
zhihand claude -m opus # Short flag form
|
|
129
134
|
```
|
|
130
135
|
|
|
136
|
+
Each backend has a **default model alias** that resolves to the latest version:
|
|
137
|
+
|
|
138
|
+
| Backend | Default | Alias examples | Resolution |
|
|
139
|
+
|---------|---------|---------------|------------|
|
|
140
|
+
| Gemini CLI | `flash` | `flash`, `pro` | Gemini CLI resolves natively (e.g. flash → gemini-2.5-flash) |
|
|
141
|
+
| Claude Code | `sonnet` | `sonnet`, `opus`, `haiku` | Claude Code resolves natively (e.g. sonnet → claude-sonnet-4) |
|
|
142
|
+
| Codex CLI | `gpt-5.4-mini` | any full model name | Codex requires full model names |
|
|
143
|
+
|
|
144
|
+
Model resolution priority: `--model` flag > `ZHIHAND_MODEL` env > `ZHIHAND_<BACKEND>_MODEL` env > default.
|
|
145
|
+
|
|
131
146
|
When you switch:
|
|
132
147
|
- The command sends an **IPC message to the running daemon**
|
|
133
148
|
- MCP config is **automatically added** to the new backend
|
|
134
149
|
- MCP config is **automatically removed** from the previous backend
|
|
150
|
+
- The model selection is **persisted** to `~/.zhihand/backend.json`
|
|
135
151
|
- If the tool is not installed, an error is shown
|
|
136
152
|
|
|
137
153
|
### Options
|
|
@@ -139,6 +155,9 @@ When you switch:
|
|
|
139
155
|
| Option | Description |
|
|
140
156
|
|---|---|
|
|
141
157
|
| `--device <name>` | Use a specific paired device (if you have multiple) |
|
|
158
|
+
| `--model, -m <name>` | Set model alias (e.g. `flash`, `pro`, `sonnet`, `opus`, `gpt-5.4-mini`) |
|
|
159
|
+
| `--port <port>` | Override daemon port (default: 18686) |
|
|
160
|
+
| `-d, --detach` | Run daemon in background |
|
|
142
161
|
| `-h, --help` | Show help |
|
|
143
162
|
|
|
144
163
|
### Environment Variables
|
|
@@ -147,6 +166,10 @@ When you switch:
|
|
|
147
166
|
|---|---|
|
|
148
167
|
| `ZHIHAND_DEVICE` | Default device name (same as `--device`) |
|
|
149
168
|
| `ZHIHAND_CLI` | Override CLI tool selection for mobile-initiated tasks |
|
|
169
|
+
| `ZHIHAND_MODEL` | Override model for all backends |
|
|
170
|
+
| `ZHIHAND_GEMINI_MODEL` | Override model for Gemini only |
|
|
171
|
+
| `ZHIHAND_CLAUDE_MODEL` | Override model for Claude only |
|
|
172
|
+
| `ZHIHAND_CODEX_MODEL` | Override model for Codex only |
|
|
150
173
|
|
|
151
174
|
## MCP Tools
|
|
152
175
|
|
|
@@ -160,12 +183,17 @@ The main phone control tool. Supports these actions:
|
|
|
160
183
|
|---|---|---|
|
|
161
184
|
| `click` | `xRatio`, `yRatio` | Tap at normalized coordinates [0,1] |
|
|
162
185
|
| `doubleclick` | `xRatio`, `yRatio` | Double-tap |
|
|
163
|
-
| `
|
|
164
|
-
| `
|
|
186
|
+
| `longclick` | `xRatio`, `yRatio`, `durationMs` | Long press (default 800ms) |
|
|
187
|
+
| `rightclick` | `xRatio`, `yRatio` | Right-click (desktop/BLE HID) |
|
|
188
|
+
| `middleclick` | `xRatio`, `yRatio` | Middle-click (desktop/BLE HID) |
|
|
165
189
|
| `type` | `text` | Type text into the focused field |
|
|
166
|
-
| `swipe` | `startXRatio`, `startYRatio`, `endXRatio`, `endYRatio` | Swipe gesture |
|
|
190
|
+
| `swipe` | `startXRatio`, `startYRatio`, `endXRatio`, `endYRatio`, `durationMs` | Swipe gesture (default 300ms) |
|
|
167
191
|
| `scroll` | `xRatio`, `yRatio`, `direction`, `amount` | Scroll up/down/left/right |
|
|
168
192
|
| `keycombo` | `keys` | Key combination (e.g. `"ctrl+c"`, `"alt+tab"`) |
|
|
193
|
+
| `back` | — | Press system Back button |
|
|
194
|
+
| `home` | — | Press system Home button |
|
|
195
|
+
| `enter` | — | Press Enter key |
|
|
196
|
+
| `open_app` | `appPackage`, `bundleId`, `urlScheme`, `appName` | Open an application |
|
|
169
197
|
| `clipboard` | `clipboardAction` (`get`/`set`), `text` | Read or write clipboard |
|
|
170
198
|
| `wait` | `durationMs` | Wait (local sleep, no server round-trip) |
|
|
171
199
|
| `screenshot` | — | Capture screen immediately |
|
|
@@ -234,8 +262,9 @@ Pairing credentials are stored at:
|
|
|
234
262
|
```
|
|
235
263
|
~/.zhihand/
|
|
236
264
|
├── credentials.json # Device credentials (credentialId, controllerToken, endpoint)
|
|
237
|
-
├── backend.json # Active backend selection
|
|
265
|
+
├── backend.json # Active backend + model selection
|
|
238
266
|
├── daemon.pid # Daemon PID file (for zhihand stop)
|
|
267
|
+
├── daemon.log # Daemon log output (when started with -d)
|
|
239
268
|
└── state.json # Current pairing session state
|
|
240
269
|
```
|
|
241
270
|
|
|
@@ -267,7 +296,8 @@ packages/mcp/
|
|
|
267
296
|
│ ├── index.ts # MCP Server (stdio transport, legacy)
|
|
268
297
|
│ ├── openclaw.adapter.ts # OpenClaw Plugin adapter (thin wrapper)
|
|
269
298
|
│ ├── core/
|
|
270
|
-
│ │ ├── config.ts # Credential & config management (~/.zhihand/)
|
|
299
|
+
│ │ ├── config.ts # Credential & config management (~/.zhihand/), default models
|
|
300
|
+
│ │ ├── resolve-path.ts # Platform-aware executable path resolution (gemini/claude/codex)
|
|
271
301
|
│ │ ├── command.ts # Command creation, enqueue, ACK formatting
|
|
272
302
|
│ │ ├── screenshot.ts # Binary screenshot fetch (JPEG)
|
|
273
303
|
│ │ ├── sse.ts # SSE client + hybrid ACK (SSE push + polling fallback)
|
package/bin/zhihand
CHANGED
|
@@ -6,7 +6,7 @@ import { startStdioServer } from "../dist/index.js";
|
|
|
6
6
|
import { startDaemon, stopDaemon, isAlreadyRunning } from "../dist/daemon/index.js";
|
|
7
7
|
import { detectCLITools, formatDetectedTools } from "../dist/cli/detect.js";
|
|
8
8
|
import { detectAndSetupOpenClaw } from "../dist/cli/openclaw.js";
|
|
9
|
-
import { loadDefaultCredential, loadBackendConfig, saveBackendConfig } from "../dist/core/config.js";
|
|
9
|
+
import { loadDefaultCredential, loadBackendConfig, saveBackendConfig, DEFAULT_MODELS } from "../dist/core/config.js";
|
|
10
10
|
import { executePairing } from "../dist/core/pair.js";
|
|
11
11
|
import { configureMCP, displayName } from "../dist/cli/mcp-config.js";
|
|
12
12
|
|
|
@@ -23,6 +23,7 @@ const { positionals, values } = parseArgs({
|
|
|
23
23
|
strict: false,
|
|
24
24
|
options: {
|
|
25
25
|
device: { type: "string" },
|
|
26
|
+
model: { type: "string", short: "m" },
|
|
26
27
|
help: { type: "boolean", short: "h", default: false },
|
|
27
28
|
detach: { type: "boolean", short: "d", default: false },
|
|
28
29
|
port: { type: "string" },
|
|
@@ -41,9 +42,10 @@ Usage:
|
|
|
41
42
|
zhihand stop Stop daemon
|
|
42
43
|
zhihand status Show status (pairing, backend, brain)
|
|
43
44
|
|
|
44
|
-
zhihand gemini Switch backend to Gemini CLI
|
|
45
|
-
zhihand claude Switch backend to Claude Code
|
|
46
|
-
zhihand codex Switch backend to Codex CLI
|
|
45
|
+
zhihand gemini Switch backend to Gemini CLI (default model: flash)
|
|
46
|
+
zhihand claude Switch backend to Claude Code (default model: sonnet)
|
|
47
|
+
zhihand codex Switch backend to Codex CLI (default model: gpt-5.4-mini)
|
|
48
|
+
zhihand gemini --model pro Switch backend with custom model
|
|
47
49
|
|
|
48
50
|
zhihand setup Interactive setup: pair + configure + start
|
|
49
51
|
zhihand pair Pair with a phone device
|
|
@@ -53,6 +55,7 @@ Usage:
|
|
|
53
55
|
|
|
54
56
|
Options:
|
|
55
57
|
--device <name> Use a specific paired device
|
|
58
|
+
--model, -m <name> Set model alias (e.g. flash, pro, sonnet, opus, gpt-5.4-mini)
|
|
56
59
|
--port <port> Override daemon port (default: 18686)
|
|
57
60
|
-d, --detach Run daemon in background
|
|
58
61
|
-h, --help Show this help
|
|
@@ -82,12 +85,15 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
|
|
|
82
85
|
const config = loadBackendConfig();
|
|
83
86
|
const previous = config.activeBackend;
|
|
84
87
|
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
const userModel = values.model ?? null;
|
|
89
|
+
const effectiveModel = userModel ?? DEFAULT_MODELS[backendName];
|
|
90
|
+
|
|
91
|
+
if (previous === backendName && !userModel) {
|
|
92
|
+
console.log(`Already using ${displayName(backendName)} as backend (model: ${effectiveModel}).`);
|
|
87
93
|
process.exit(0);
|
|
88
94
|
}
|
|
89
95
|
|
|
90
|
-
console.log(`Switching backend to ${displayName(backendName)}...`);
|
|
96
|
+
console.log(`Switching backend to ${displayName(backendName)} (model: ${effectiveModel})...`);
|
|
91
97
|
|
|
92
98
|
// Configure MCP (HTTP transport)
|
|
93
99
|
const { configured, removed } = configureMCP(backendName, previous);
|
|
@@ -100,7 +106,7 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
|
|
|
100
106
|
const res = await fetch(`http://127.0.0.1:${port}/internal/backend`, {
|
|
101
107
|
method: "POST",
|
|
102
108
|
headers: { "Content-Type": "application/json" },
|
|
103
|
-
body: JSON.stringify({ backend: backendName }),
|
|
109
|
+
body: JSON.stringify({ backend: backendName, model: userModel }),
|
|
104
110
|
signal: AbortSignal.timeout(5000),
|
|
105
111
|
});
|
|
106
112
|
if (res.ok) {
|
|
@@ -108,11 +114,11 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
|
|
|
108
114
|
}
|
|
109
115
|
} catch {
|
|
110
116
|
// Daemon not responding, just save config
|
|
111
|
-
saveBackendConfig({ activeBackend: backendName });
|
|
117
|
+
saveBackendConfig({ activeBackend: backendName, model: userModel });
|
|
112
118
|
console.log(`\nBackend config saved. Daemon not responding — restart with 'zhihand start'.`);
|
|
113
119
|
}
|
|
114
120
|
} else {
|
|
115
|
-
saveBackendConfig({ activeBackend: backendName });
|
|
121
|
+
saveBackendConfig({ activeBackend: backendName, model: userModel });
|
|
116
122
|
console.log(`\nBackend switched to ${displayName(backendName)}.`);
|
|
117
123
|
console.log(`Start the daemon to receive prompts: zhihand start`);
|
|
118
124
|
}
|
|
@@ -199,7 +205,11 @@ switch (command) {
|
|
|
199
205
|
} else {
|
|
200
206
|
console.log("No paired device. Run: zhihand setup");
|
|
201
207
|
}
|
|
202
|
-
|
|
208
|
+
const backendLabel = backend.activeBackend ? displayName(backend.activeBackend) : "(none)";
|
|
209
|
+
const modelLabel = backend.activeBackend
|
|
210
|
+
? (backend.model ?? DEFAULT_MODELS[backend.activeBackend])
|
|
211
|
+
: "-";
|
|
212
|
+
console.log(`Active backend: ${backendLabel} (model: ${modelLabel})`);
|
|
203
213
|
console.log(`Daemon: ${daemonPid ? `running (PID ${daemonPid})` : "not running"}`);
|
|
204
214
|
|
|
205
215
|
// If daemon running, get live status
|
package/dist/core/config.d.ts
CHANGED
|
@@ -19,7 +19,16 @@ export interface ZhiHandConfig {
|
|
|
19
19
|
export type BackendName = "claudecode" | "codex" | "gemini" | "openclaw";
|
|
20
20
|
export interface BackendConfig {
|
|
21
21
|
activeBackend: BackendName | null;
|
|
22
|
+
model?: string | null;
|
|
22
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Default model aliases per backend.
|
|
26
|
+
* These are generic aliases that the respective CLIs resolve to the latest version:
|
|
27
|
+
* - Gemini CLI: "flash" → latest flash model (e.g. gemini-2.5-flash)
|
|
28
|
+
* - Claude Code: "sonnet" → latest sonnet (e.g. claude-sonnet-4-20250514)
|
|
29
|
+
* - Codex CLI: requires full model name, no alias support
|
|
30
|
+
*/
|
|
31
|
+
export declare const DEFAULT_MODELS: Record<Exclude<BackendName, "openclaw">, string>;
|
|
23
32
|
export declare function resolveZhiHandDir(): string;
|
|
24
33
|
export declare function ensureZhiHandDir(): void;
|
|
25
34
|
export declare function loadCredentialStore(): CredentialStore | null;
|
package/dist/core/config.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
|
+
/**
|
|
5
|
+
* Default model aliases per backend.
|
|
6
|
+
* These are generic aliases that the respective CLIs resolve to the latest version:
|
|
7
|
+
* - Gemini CLI: "flash" → latest flash model (e.g. gemini-2.5-flash)
|
|
8
|
+
* - Claude Code: "sonnet" → latest sonnet (e.g. claude-sonnet-4-20250514)
|
|
9
|
+
* - Codex CLI: requires full model name, no alias support
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_MODELS = {
|
|
12
|
+
gemini: "flash", // Gemini CLI resolves to latest flash
|
|
13
|
+
claudecode: "sonnet", // Claude Code resolves to latest sonnet
|
|
14
|
+
codex: "gpt-5.4-mini", // Codex default: latest GPT mini model
|
|
15
|
+
};
|
|
4
16
|
const ZHIHAND_DIR = path.join(os.homedir(), ".zhihand");
|
|
5
17
|
const CREDENTIALS_PATH = path.join(ZHIHAND_DIR, "credentials.json");
|
|
6
18
|
const STATE_PATH = path.join(ZHIHAND_DIR, "state.json");
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Platform-aware executable path resolution.
|
|
3
3
|
* Shared by both the CLI detection layer and the daemon dispatcher.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
6
|
import fs from "node:fs";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import os from "node:os";
|
|
@@ -19,7 +19,7 @@ export function resolveExecutable(name, fallbackPaths) {
|
|
|
19
19
|
return cached;
|
|
20
20
|
// Try `which` first (works when the binary is in PATH)
|
|
21
21
|
try {
|
|
22
|
-
const resolved =
|
|
22
|
+
const resolved = execFileSync("which", [name], { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
23
23
|
if (resolved) {
|
|
24
24
|
cache.set(name, resolved);
|
|
25
25
|
return resolved;
|
|
@@ -5,8 +5,7 @@ export interface DispatchResult {
|
|
|
5
5
|
durationMs: number;
|
|
6
6
|
}
|
|
7
7
|
/**
|
|
8
|
-
* Kill the active
|
|
9
|
-
* when the child has exited (or immediately if no child).
|
|
8
|
+
* Kill the active session. Called by daemon on shutdown or backend switch.
|
|
10
9
|
*/
|
|
11
10
|
export declare function killActiveChild(): Promise<void>;
|
|
12
11
|
export declare function dispatchToCLI(backend: Exclude<BackendName, "openclaw">, prompt: string, log: (msg: string) => void, model?: string): Promise<DispatchResult>;
|
|
@@ -3,10 +3,12 @@ import fsp from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { DEFAULT_MODELS } from "../core/config.js";
|
|
6
7
|
import { resolveGemini, resolveClaude, resolveCodex } from "../core/resolve-path.js";
|
|
7
|
-
const CLI_TIMEOUT = 120_000; // 120s
|
|
8
|
+
const CLI_TIMEOUT = 120_000; // 120s per prompt
|
|
8
9
|
const SIGKILL_DELAY = 2_000; // 2s after SIGTERM
|
|
9
|
-
const MAX_OUTPUT_BYTES = 100 * 1024; // 100KB
|
|
10
|
+
const MAX_OUTPUT_BYTES = 100 * 1024; // 100KB (for one-shot backends)
|
|
11
|
+
const MAX_HISTORY_TURNS = 20; // keep last N exchanges in conversation history
|
|
10
12
|
// Gemini session file polling
|
|
11
13
|
const SESSION_POLL_INTERVAL = 1_000; // 1s
|
|
12
14
|
const SESSION_STABILITY_DELAY = 2_000; // wait 2s after outcome before returning
|
|
@@ -15,7 +17,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
15
17
|
const PTY_WRAP_SCRIPT = path.resolve(__dirname, "../../scripts/pty-wrap.py");
|
|
16
18
|
// Gemini session directories
|
|
17
19
|
const GEMINI_TMP_DIR = path.join(os.homedir(), ".gemini", "tmp");
|
|
18
|
-
let
|
|
20
|
+
let session = null;
|
|
21
|
+
const conversationHistory = [];
|
|
19
22
|
// ── Gemini Session File Monitoring ─────────────────────────
|
|
20
23
|
/** Safely read and parse a JSON file (single attempt, async). */
|
|
21
24
|
async function loadJsonFile(filePath) {
|
|
@@ -25,7 +28,6 @@ async function loadJsonFile(filePath) {
|
|
|
25
28
|
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
26
29
|
}
|
|
27
30
|
catch {
|
|
28
|
-
// File locked or partial write — next poll cycle will retry
|
|
29
31
|
return null;
|
|
30
32
|
}
|
|
31
33
|
}
|
|
@@ -55,7 +57,6 @@ function extractMessageText(message) {
|
|
|
55
57
|
if (typeof obj.text === "string")
|
|
56
58
|
return obj.text;
|
|
57
59
|
}
|
|
58
|
-
// Fallback to displayContent
|
|
59
60
|
const display = message.displayContent;
|
|
60
61
|
if (typeof display === "string")
|
|
61
62
|
return display;
|
|
@@ -85,7 +86,6 @@ function hasActiveToolCalls(message) {
|
|
|
85
86
|
function checkSessionOutcome(messages) {
|
|
86
87
|
if (messages.length === 0)
|
|
87
88
|
return null;
|
|
88
|
-
// Get the latest turn messages (trailing messages from last user input)
|
|
89
89
|
const trailing = [];
|
|
90
90
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
91
91
|
const msg = messages[i];
|
|
@@ -95,22 +95,18 @@ function checkSessionOutcome(messages) {
|
|
|
95
95
|
}
|
|
96
96
|
if (trailing.length === 0)
|
|
97
97
|
return null;
|
|
98
|
-
// If any message has active tool calls, still in progress
|
|
99
98
|
for (const msg of trailing) {
|
|
100
99
|
if (hasActiveToolCalls(msg))
|
|
101
100
|
return null;
|
|
102
101
|
}
|
|
103
|
-
// Check from last message backwards for a result
|
|
104
102
|
for (let i = trailing.length - 1; i >= 0; i--) {
|
|
105
103
|
const msg = trailing[i];
|
|
106
104
|
const msgType = String(msg.type ?? "").trim();
|
|
107
|
-
// Error/warning/info messages
|
|
108
105
|
if (["error", "warning", "info"].includes(msgType)) {
|
|
109
106
|
const text = extractMessageText(msg).trim();
|
|
110
107
|
if (text)
|
|
111
108
|
return ["error", text];
|
|
112
109
|
}
|
|
113
|
-
// Gemini response message
|
|
114
110
|
if (msgType === "gemini") {
|
|
115
111
|
const text = extractMessageText(msg).trim();
|
|
116
112
|
if (text)
|
|
@@ -151,21 +147,19 @@ async function findLatestSessionFile(afterTime, promptText) {
|
|
|
151
147
|
}
|
|
152
148
|
}
|
|
153
149
|
}
|
|
154
|
-
// Sort newest first, then validate content matches our prompt
|
|
155
150
|
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
156
151
|
const promptPrefix = promptText.slice(0, 50);
|
|
157
152
|
for (const candidate of candidates) {
|
|
158
153
|
const data = await loadJsonFile(candidate.path);
|
|
159
154
|
if (!data || !Array.isArray(data.messages))
|
|
160
155
|
continue;
|
|
161
|
-
// Check first user message matches our prompt
|
|
162
156
|
for (const msg of data.messages) {
|
|
163
157
|
if (String(msg.type ?? "").trim() !== "user")
|
|
164
158
|
continue;
|
|
165
159
|
const text = extractMessageText(msg);
|
|
166
160
|
if (text.startsWith(promptPrefix))
|
|
167
161
|
return candidate.path;
|
|
168
|
-
break;
|
|
162
|
+
break;
|
|
169
163
|
}
|
|
170
164
|
}
|
|
171
165
|
return null;
|
|
@@ -174,33 +168,44 @@ async function findLatestSessionFile(afterTime, promptText) {
|
|
|
174
168
|
return null;
|
|
175
169
|
}
|
|
176
170
|
}
|
|
171
|
+
/** Count how many "user" type messages are in the session */
|
|
172
|
+
function countUserMessages(messages) {
|
|
173
|
+
return messages.filter(m => String(m.type ?? "").trim() === "user").length;
|
|
174
|
+
}
|
|
177
175
|
/**
|
|
178
|
-
* Poll gemini session
|
|
179
|
-
*
|
|
176
|
+
* Poll gemini session file for the response to the current prompt.
|
|
177
|
+
*
|
|
178
|
+
* For persistent sessions:
|
|
179
|
+
* - First prompt: find the session file, wait for first response, keep process alive
|
|
180
|
+
* - Subsequent: session file known, wait for new user message + response
|
|
180
181
|
*/
|
|
181
|
-
function pollGeminiSession(child, startTime, promptText, log) {
|
|
182
|
+
function pollGeminiSession(child, startTime, promptText, log, knownSessionFile, expectedUserCount) {
|
|
182
183
|
return new Promise((resolve) => {
|
|
183
|
-
let sessionFile =
|
|
184
|
+
let sessionFile = knownSessionFile;
|
|
184
185
|
let outcomeAt = null;
|
|
185
186
|
let finalResult = null;
|
|
186
187
|
let settled = false;
|
|
187
188
|
let pollTimeout = null;
|
|
189
|
+
let newUserSeen = knownSessionFile === null; // first prompt: don't wait for user msg
|
|
188
190
|
function settle(result) {
|
|
189
191
|
if (settled)
|
|
190
192
|
return;
|
|
191
193
|
settled = true;
|
|
192
194
|
if (pollTimeout)
|
|
193
195
|
clearTimeout(pollTimeout);
|
|
194
|
-
//
|
|
195
|
-
closeChild(child);
|
|
196
|
+
// DON'T kill the child — persistent session keeps it alive
|
|
196
197
|
resolve(result);
|
|
197
198
|
}
|
|
198
199
|
async function poll() {
|
|
199
200
|
if (settled)
|
|
200
201
|
return;
|
|
201
202
|
const elapsed = Date.now() - startTime;
|
|
202
|
-
// Timeout
|
|
203
203
|
if (elapsed > CLI_TIMEOUT) {
|
|
204
|
+
// Kill the timed-out session to prevent zombie processes
|
|
205
|
+
if (session?.child === child) {
|
|
206
|
+
session.alive = false;
|
|
207
|
+
log(`[gemini] Session timed out — killing process`);
|
|
208
|
+
}
|
|
204
209
|
closeChild(child);
|
|
205
210
|
settle({
|
|
206
211
|
text: "Gemini timed out after 120s.",
|
|
@@ -209,16 +214,18 @@ function pollGeminiSession(child, startTime, promptText, log) {
|
|
|
209
214
|
});
|
|
210
215
|
return;
|
|
211
216
|
}
|
|
212
|
-
// Find session file if not yet found
|
|
217
|
+
// Find session file if not yet found (first prompt only)
|
|
213
218
|
if (!sessionFile) {
|
|
214
219
|
sessionFile = await findLatestSessionFile(startTime, promptText);
|
|
215
220
|
if (sessionFile) {
|
|
216
221
|
log(`[gemini] Session file found: ${path.basename(sessionFile)}`);
|
|
222
|
+
if (session)
|
|
223
|
+
session.geminiSessionFile = sessionFile;
|
|
217
224
|
}
|
|
218
225
|
schedulePoll();
|
|
219
226
|
return;
|
|
220
227
|
}
|
|
221
|
-
// Read session file
|
|
228
|
+
// Read session file
|
|
222
229
|
const conversation = await loadJsonFile(sessionFile);
|
|
223
230
|
if (!conversation) {
|
|
224
231
|
schedulePoll();
|
|
@@ -229,15 +236,23 @@ function pollGeminiSession(child, startTime, promptText, log) {
|
|
|
229
236
|
schedulePoll();
|
|
230
237
|
return;
|
|
231
238
|
}
|
|
239
|
+
// For subsequent prompts: wait until the new user message appears
|
|
240
|
+
if (!newUserSeen) {
|
|
241
|
+
const userCount = countUserMessages(messages);
|
|
242
|
+
if (userCount < expectedUserCount) {
|
|
243
|
+
schedulePoll();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
newUserSeen = true;
|
|
247
|
+
log(`[gemini] New user message detected (turn #${expectedUserCount})`);
|
|
248
|
+
}
|
|
232
249
|
const outcome = checkSessionOutcome(messages);
|
|
233
250
|
if (!outcome) {
|
|
234
|
-
// Still in progress, reset stability timer
|
|
235
251
|
outcomeAt = null;
|
|
236
252
|
finalResult = null;
|
|
237
253
|
schedulePoll();
|
|
238
254
|
return;
|
|
239
255
|
}
|
|
240
|
-
// Outcome detected — wait for stability (2s) before returning
|
|
241
256
|
if (!outcomeAt) {
|
|
242
257
|
outcomeAt = Date.now();
|
|
243
258
|
finalResult = outcome;
|
|
@@ -261,12 +276,16 @@ function pollGeminiSession(child, startTime, promptText, log) {
|
|
|
261
276
|
return;
|
|
262
277
|
pollTimeout = setTimeout(() => { poll(); }, SESSION_POLL_INTERVAL);
|
|
263
278
|
}
|
|
264
|
-
// Start polling
|
|
265
279
|
schedulePoll();
|
|
266
|
-
//
|
|
267
|
-
|
|
280
|
+
// Handle unexpected process exit
|
|
281
|
+
const onClose = (code) => {
|
|
268
282
|
if (settled)
|
|
269
283
|
return;
|
|
284
|
+
// Mark session as dead
|
|
285
|
+
if (session?.child === child) {
|
|
286
|
+
session.alive = false;
|
|
287
|
+
log(`[gemini] Session process exited with code ${code}`);
|
|
288
|
+
}
|
|
270
289
|
// Give a final chance to read the session file
|
|
271
290
|
setTimeout(async () => {
|
|
272
291
|
if (settled)
|
|
@@ -291,14 +310,14 @@ function pollGeminiSession(child, startTime, promptText, log) {
|
|
|
291
310
|
durationMs: Date.now() - startTime,
|
|
292
311
|
});
|
|
293
312
|
}, 500);
|
|
294
|
-
}
|
|
313
|
+
};
|
|
314
|
+
child.on("close", onClose);
|
|
295
315
|
});
|
|
296
316
|
}
|
|
297
|
-
/** Gracefully close a child process:
|
|
317
|
+
/** Gracefully close a child process: SIGTERM → SIGKILL. */
|
|
298
318
|
function closeChild(child) {
|
|
299
319
|
if (child.killed || child.exitCode !== null)
|
|
300
320
|
return;
|
|
301
|
-
// Try SIGTERM first
|
|
302
321
|
child.kill("SIGTERM");
|
|
303
322
|
setTimeout(() => {
|
|
304
323
|
if (!child.killed && child.exitCode === null) {
|
|
@@ -306,29 +325,29 @@ function closeChild(child) {
|
|
|
306
325
|
}
|
|
307
326
|
}, SIGKILL_DELAY);
|
|
308
327
|
}
|
|
309
|
-
/**
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
328
|
+
/** Close the persistent session and clear conversation history. */
|
|
329
|
+
function closeSession() {
|
|
330
|
+
if (!session)
|
|
331
|
+
return Promise.resolve();
|
|
332
|
+
const s = session;
|
|
333
|
+
session = null;
|
|
334
|
+
if (!s.alive)
|
|
315
335
|
return Promise.resolve();
|
|
316
|
-
}
|
|
317
336
|
return new Promise((resolve) => {
|
|
318
|
-
|
|
319
|
-
child
|
|
320
|
-
closeChild(child);
|
|
321
|
-
// Safety: resolve after SIGKILL_DELAY + 1s even if no close event
|
|
337
|
+
s.child.once("close", () => resolve());
|
|
338
|
+
closeChild(s.child);
|
|
322
339
|
setTimeout(() => resolve(), SIGKILL_DELAY + 1000);
|
|
323
340
|
});
|
|
324
341
|
}
|
|
325
|
-
// ── System Prompt ─────────────────────────────────────────
|
|
326
342
|
/**
|
|
327
|
-
*
|
|
328
|
-
* knows about the connected phone and how to use zhihand MCP tools.
|
|
343
|
+
* Kill the active session. Called by daemon on shutdown or backend switch.
|
|
329
344
|
*/
|
|
330
|
-
function
|
|
331
|
-
|
|
345
|
+
export async function killActiveChild() {
|
|
346
|
+
await closeSession();
|
|
347
|
+
conversationHistory.length = 0;
|
|
348
|
+
}
|
|
349
|
+
// ── System Prompt ─────────────────────────────────────────
|
|
350
|
+
const SYSTEM_CONTEXT = `You are ZhiHand, an AI assistant connected to the user's mobile phone via MCP tools.
|
|
332
351
|
|
|
333
352
|
## Available MCP Tools
|
|
334
353
|
|
|
@@ -358,23 +377,54 @@ Control the phone. Requires "action" parameter. All coordinates use normalized r
|
|
|
358
377
|
- When the user asks to see their screen, ALWAYS call zhihand_screenshot first.
|
|
359
378
|
- When the user asks to open an app (e.g. WeChat, Settings), use open_app action.
|
|
360
379
|
- When the user asks to go back/home, use back/home actions.
|
|
361
|
-
- For all tap/click operations, use xRatio and yRatio (0-1 normalized coordinates based on the screenshot)
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
380
|
+
- For all tap/click operations, use xRatio and yRatio (0-1 normalized coordinates based on the screenshot).`;
|
|
381
|
+
/**
|
|
382
|
+
* Build the full system prompt with optional conversation history.
|
|
383
|
+
* Used for first prompt in persistent sessions and all one-shot calls.
|
|
384
|
+
*/
|
|
385
|
+
function wrapPrompt(userPrompt, history) {
|
|
386
|
+
let result = SYSTEM_CONTEXT;
|
|
387
|
+
if (history && history.length > 0) {
|
|
388
|
+
result += "\n\n## Recent Conversation\n";
|
|
389
|
+
for (const turn of history) {
|
|
390
|
+
const label = turn.role === "user" ? "User" : "Assistant";
|
|
391
|
+
// Truncate long assistant responses in history to save tokens
|
|
392
|
+
const text = turn.text.length > 500 ? turn.text.slice(0, 500) + "..." : turn.text;
|
|
393
|
+
result += `\n${label}: ${text}\n`;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
result += `\nUser message:\n${userPrompt}`;
|
|
397
|
+
return result;
|
|
398
|
+
}
|
|
399
|
+
// ── Conversation History Helpers ─────────────────────────────
|
|
400
|
+
function recordTurn(role, text) {
|
|
401
|
+
conversationHistory.push({ role, text });
|
|
402
|
+
// Trim to keep last N exchanges (2 turns per exchange)
|
|
403
|
+
while (conversationHistory.length > MAX_HISTORY_TURNS * 2) {
|
|
404
|
+
conversationHistory.shift();
|
|
405
|
+
}
|
|
365
406
|
}
|
|
366
407
|
// ── Dispatch Entrypoint ────────────────────────────────────
|
|
367
408
|
export function dispatchToCLI(backend, prompt, log, model) {
|
|
368
409
|
const startTime = Date.now();
|
|
369
|
-
const
|
|
410
|
+
const resolvedModel = resolveModel(backend, model);
|
|
411
|
+
// Check if existing session matches — if not, close it
|
|
412
|
+
const canReuse = session?.alive && session.backend === backend && session.model === resolvedModel;
|
|
413
|
+
if (session && !canReuse) {
|
|
414
|
+
log(`[dispatch] Session mismatch (was ${session.backend}/${session.model}), closing old session`);
|
|
415
|
+
closeSession();
|
|
416
|
+
conversationHistory.length = 0;
|
|
417
|
+
}
|
|
418
|
+
const sessionLabel = canReuse ? `#${session.promptCount + 1}` : "new";
|
|
419
|
+
log(`[dispatch] Backend: ${backend}, Model: ${resolvedModel}, Session: ${sessionLabel}`);
|
|
370
420
|
if (backend === "gemini") {
|
|
371
|
-
return
|
|
421
|
+
return dispatchGeminiPersistent(prompt, startTime, log, resolvedModel);
|
|
372
422
|
}
|
|
373
423
|
if (backend === "codex") {
|
|
374
|
-
return
|
|
424
|
+
return dispatchCodexWithHistory(prompt, startTime, log, resolvedModel);
|
|
375
425
|
}
|
|
376
426
|
if (backend === "claudecode") {
|
|
377
|
-
return
|
|
427
|
+
return dispatchClaudeWithHistory(prompt, startTime, log, resolvedModel);
|
|
378
428
|
}
|
|
379
429
|
return Promise.resolve({
|
|
380
430
|
text: `Unsupported backend: ${backend}`,
|
|
@@ -382,13 +432,46 @@ export function dispatchToCLI(backend, prompt, log, model) {
|
|
|
382
432
|
durationMs: 0,
|
|
383
433
|
});
|
|
384
434
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
435
|
+
/**
|
|
436
|
+
* Resolve the model to use for a backend.
|
|
437
|
+
* Priority: explicit parameter > ZHIHAND_MODEL env > backend-specific env > default alias.
|
|
438
|
+
*/
|
|
439
|
+
function resolveModel(backend, explicit) {
|
|
440
|
+
if (explicit)
|
|
441
|
+
return explicit;
|
|
442
|
+
const globalEnv = process.env.ZHIHAND_MODEL;
|
|
443
|
+
if (globalEnv)
|
|
444
|
+
return globalEnv;
|
|
445
|
+
const envMap = {
|
|
446
|
+
gemini: process.env.ZHIHAND_GEMINI_MODEL,
|
|
447
|
+
claudecode: process.env.ZHIHAND_CLAUDE_MODEL,
|
|
448
|
+
codex: process.env.ZHIHAND_CODEX_MODEL,
|
|
449
|
+
};
|
|
450
|
+
const perBackend = envMap[backend];
|
|
451
|
+
if (perBackend)
|
|
452
|
+
return perBackend;
|
|
453
|
+
return DEFAULT_MODELS[backend];
|
|
454
|
+
}
|
|
455
|
+
// ── Gemini Dispatch (Persistent PTY Session) ─────────────────
|
|
456
|
+
async function dispatchGeminiPersistent(prompt, startTime, log, model) {
|
|
457
|
+
// Reuse existing session?
|
|
458
|
+
if (session?.alive && session.backend === "gemini") {
|
|
459
|
+
session.promptCount++;
|
|
460
|
+
const turnNum = session.promptCount;
|
|
461
|
+
log(`[gemini] Reusing session — sending prompt #${turnNum}`);
|
|
462
|
+
// Write raw prompt to PTY stdin (gemini already has system context from first prompt)
|
|
463
|
+
session.child.stdin?.write(prompt + "\n");
|
|
464
|
+
const result = await pollGeminiSession(session.child, startTime, prompt, log, session.geminiSessionFile, turnNum);
|
|
465
|
+
recordTurn("user", prompt);
|
|
466
|
+
recordTurn("assistant", result.text);
|
|
467
|
+
return result;
|
|
468
|
+
}
|
|
469
|
+
// New session — spawn gemini with first prompt
|
|
470
|
+
const wrappedPrompt = wrapPrompt(prompt);
|
|
388
471
|
const cliArgs = [
|
|
389
472
|
"--approval-mode", "yolo",
|
|
390
|
-
"--model",
|
|
391
|
-
"-i",
|
|
473
|
+
"--model", model,
|
|
474
|
+
"-i", wrappedPrompt,
|
|
392
475
|
];
|
|
393
476
|
const env = {
|
|
394
477
|
...process.env,
|
|
@@ -396,54 +479,80 @@ function dispatchGemini(prompt, startTime, log, model) {
|
|
|
396
479
|
TERM: "xterm-256color",
|
|
397
480
|
COLORTERM: "truecolor",
|
|
398
481
|
};
|
|
399
|
-
// Wrap with PTY so gemini sees isatty()==true
|
|
400
482
|
const geminiPath = resolveGemini();
|
|
483
|
+
log(`[gemini] Starting new persistent session (model: ${model})`);
|
|
401
484
|
const child = spawn("python3", [PTY_WRAP_SCRIPT, geminiPath, ...cliArgs], {
|
|
402
485
|
env,
|
|
403
|
-
stdio: ["
|
|
486
|
+
stdio: ["pipe", "pipe", "pipe"], // stdin=pipe for subsequent prompts
|
|
404
487
|
detached: false,
|
|
405
488
|
});
|
|
406
|
-
|
|
407
|
-
|
|
489
|
+
session = {
|
|
490
|
+
child,
|
|
491
|
+
backend: "gemini",
|
|
492
|
+
model,
|
|
493
|
+
promptCount: 1,
|
|
494
|
+
alive: true,
|
|
495
|
+
geminiSessionFile: null,
|
|
496
|
+
};
|
|
497
|
+
// Handle unexpected exit — mark session dead
|
|
498
|
+
child.on("close", (code) => {
|
|
499
|
+
if (session?.child === child) {
|
|
500
|
+
session.alive = false;
|
|
501
|
+
log(`[gemini] Session process exited (code ${code})`);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
// Drain PTY stdout/stderr (we read from session file, not stdout)
|
|
408
505
|
child.stdout?.resume();
|
|
409
506
|
child.stderr?.resume();
|
|
410
|
-
|
|
507
|
+
const result = await pollGeminiSession(child, startTime, wrappedPrompt, log, null, // no known session file yet
|
|
508
|
+
1);
|
|
509
|
+
recordTurn("user", prompt);
|
|
510
|
+
recordTurn("assistant", result.text);
|
|
511
|
+
return result;
|
|
411
512
|
}
|
|
412
|
-
// ── Codex Dispatch
|
|
413
|
-
function
|
|
414
|
-
//
|
|
415
|
-
|
|
513
|
+
// ── Codex Dispatch (One-shot with History) ────────────────────
|
|
514
|
+
async function dispatchCodexWithHistory(prompt, startTime, log, model) {
|
|
515
|
+
// Include conversation history in the prompt for context
|
|
516
|
+
const fullPrompt = wrapPrompt(prompt, conversationHistory);
|
|
416
517
|
const args = ["exec", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "--json"];
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
}
|
|
421
|
-
args.push(prompt);
|
|
518
|
+
args.push("-m", model);
|
|
519
|
+
// Pass prompt via stdin to avoid ARG_MAX limit with long conversation history
|
|
520
|
+
args.push("-");
|
|
422
521
|
const codexPath = resolveCodex();
|
|
522
|
+
log(`[codex] One-shot dispatch (history: ${conversationHistory.length} turns)`);
|
|
423
523
|
const child = spawn(codexPath, args, {
|
|
424
524
|
env: process.env,
|
|
425
|
-
stdio: ["
|
|
525
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
426
526
|
detached: false,
|
|
427
527
|
});
|
|
428
|
-
|
|
429
|
-
|
|
528
|
+
// Write prompt to stdin, then close to signal EOF
|
|
529
|
+
child.stdin?.write(fullPrompt);
|
|
530
|
+
child.stdin?.end();
|
|
531
|
+
const result = await collectCodexOutput(child, startTime);
|
|
532
|
+
recordTurn("user", prompt);
|
|
533
|
+
recordTurn("assistant", result.text);
|
|
534
|
+
return result;
|
|
430
535
|
}
|
|
431
|
-
// ── Claude Dispatch
|
|
432
|
-
function
|
|
536
|
+
// ── Claude Dispatch (One-shot with History) ───────────────────
|
|
537
|
+
async function dispatchClaudeWithHistory(prompt, startTime, log, model) {
|
|
538
|
+
const fullPrompt = wrapPrompt(prompt, conversationHistory);
|
|
433
539
|
const claudePath = resolveClaude();
|
|
434
|
-
|
|
540
|
+
log(`[claude] One-shot dispatch (history: ${conversationHistory.length} turns)`);
|
|
541
|
+
// Pass prompt via stdin (-p -) to avoid ARG_MAX limit with long conversation history
|
|
542
|
+
const child = spawn(claudePath, ["-p", "-", "--model", model, "--output-format", "json"], {
|
|
435
543
|
env: process.env,
|
|
436
|
-
stdio: ["
|
|
544
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
437
545
|
detached: false,
|
|
438
546
|
});
|
|
439
|
-
|
|
440
|
-
|
|
547
|
+
// Write prompt to stdin, then close to signal EOF
|
|
548
|
+
child.stdin?.write(fullPrompt);
|
|
549
|
+
child.stdin?.end();
|
|
550
|
+
const result = await collectChildOutput(child, startTime);
|
|
551
|
+
recordTurn("user", prompt);
|
|
552
|
+
recordTurn("assistant", result.text);
|
|
553
|
+
return result;
|
|
441
554
|
}
|
|
442
|
-
|
|
443
|
-
* Collect codex JSONL output with streaming line parsing.
|
|
444
|
-
* Processes each JSONL line as it arrives so we extract agent text
|
|
445
|
-
* without buffering large binary payloads (e.g. base64 screenshots).
|
|
446
|
-
*/
|
|
555
|
+
// ── Codex JSONL Output Collector ──────────────────────────────
|
|
447
556
|
function collectCodexOutput(child, startTime) {
|
|
448
557
|
return new Promise((resolve) => {
|
|
449
558
|
const texts = [];
|
|
@@ -478,50 +587,35 @@ function collectCodexOutput(child, startTime) {
|
|
|
478
587
|
hasError = true;
|
|
479
588
|
}
|
|
480
589
|
}
|
|
481
|
-
catch {
|
|
482
|
-
// Not valid JSON — skip
|
|
483
|
-
}
|
|
590
|
+
catch { /* skip non-JSON */ }
|
|
484
591
|
}
|
|
485
592
|
const timer = setTimeout(() => { closeChild(child); }, CLI_TIMEOUT);
|
|
486
|
-
|
|
593
|
+
child.stdout?.on("data", (data) => {
|
|
487
594
|
lineBuffer += data.toString("utf8");
|
|
488
595
|
const lines = lineBuffer.split("\n");
|
|
489
|
-
// Keep the last (possibly incomplete) line in the buffer
|
|
490
596
|
lineBuffer = lines.pop() ?? "";
|
|
491
|
-
for (const line of lines)
|
|
597
|
+
for (const line of lines)
|
|
492
598
|
processLine(line);
|
|
493
|
-
|
|
494
|
-
};
|
|
495
|
-
child.stdout?.on("data", onData);
|
|
496
|
-
// stderr is not JSONL, just discard
|
|
599
|
+
});
|
|
497
600
|
child.stderr?.resume();
|
|
498
601
|
child.on("close", (code) => {
|
|
499
602
|
clearTimeout(timer);
|
|
500
|
-
activeChild = null;
|
|
501
|
-
// Process any remaining data in the buffer
|
|
502
603
|
if (lineBuffer.trim())
|
|
503
604
|
processLine(lineBuffer);
|
|
504
605
|
const durationMs = Date.now() - startTime;
|
|
505
606
|
let text = texts.join("\n\n");
|
|
506
607
|
if (!text) {
|
|
507
|
-
text = code === 0
|
|
508
|
-
? "Task completed (no output)."
|
|
509
|
-
: `CLI process exited with code ${code}.`;
|
|
608
|
+
text = code === 0 ? "Task completed (no output)." : `CLI process exited with code ${code}.`;
|
|
510
609
|
}
|
|
511
610
|
settle({ text, success: !hasError && code === 0, durationMs });
|
|
512
611
|
});
|
|
513
612
|
child.on("error", (err) => {
|
|
514
613
|
clearTimeout(timer);
|
|
515
|
-
|
|
516
|
-
settle({
|
|
517
|
-
text: `CLI launch failed: ${err.message}`,
|
|
518
|
-
success: false,
|
|
519
|
-
durationMs: Date.now() - startTime,
|
|
520
|
-
});
|
|
614
|
+
settle({ text: `CLI launch failed: ${err.message}`, success: false, durationMs: Date.now() - startTime });
|
|
521
615
|
});
|
|
522
616
|
});
|
|
523
617
|
}
|
|
524
|
-
// ── Shared: Collect stdout/stderr from a child process
|
|
618
|
+
// ── Shared: Collect stdout/stderr from a child process ───────
|
|
525
619
|
function collectChildOutput(child, startTime) {
|
|
526
620
|
return new Promise((resolve) => {
|
|
527
621
|
const chunks = [];
|
|
@@ -534,10 +628,7 @@ function collectChildOutput(child, startTime) {
|
|
|
534
628
|
settled = true;
|
|
535
629
|
resolve(result);
|
|
536
630
|
}
|
|
537
|
-
|
|
538
|
-
const timer = setTimeout(() => {
|
|
539
|
-
closeChild(child);
|
|
540
|
-
}, CLI_TIMEOUT);
|
|
631
|
+
const timer = setTimeout(() => { closeChild(child); }, CLI_TIMEOUT);
|
|
541
632
|
const collectOutput = (data) => {
|
|
542
633
|
if (truncated)
|
|
543
634
|
return;
|
|
@@ -554,27 +645,18 @@ function collectChildOutput(child, startTime) {
|
|
|
554
645
|
child.stderr?.on("data", collectOutput);
|
|
555
646
|
child.on("close", (code) => {
|
|
556
647
|
clearTimeout(timer);
|
|
557
|
-
activeChild = null;
|
|
558
648
|
const durationMs = Date.now() - startTime;
|
|
559
649
|
let text = Buffer.concat(chunks).toString("utf8").trim();
|
|
560
|
-
if (truncated)
|
|
650
|
+
if (truncated)
|
|
561
651
|
text += "\n\n[Output truncated at 100KB]";
|
|
562
|
-
}
|
|
563
652
|
if (!text) {
|
|
564
|
-
text = code === 0
|
|
565
|
-
? "Task completed (no output)."
|
|
566
|
-
: `CLI process exited with code ${code}.`;
|
|
653
|
+
text = code === 0 ? "Task completed (no output)." : `CLI process exited with code ${code}.`;
|
|
567
654
|
}
|
|
568
655
|
settle({ text, success: code === 0, durationMs });
|
|
569
656
|
});
|
|
570
657
|
child.on("error", (err) => {
|
|
571
658
|
clearTimeout(timer);
|
|
572
|
-
|
|
573
|
-
settle({
|
|
574
|
-
text: `CLI launch failed: ${err.message}`,
|
|
575
|
-
success: false,
|
|
576
|
-
durationMs: Date.now() - startTime,
|
|
577
|
-
});
|
|
659
|
+
settle({ text: `CLI launch failed: ${err.message}`, success: false, durationMs: Date.now() - startTime });
|
|
578
660
|
});
|
|
579
661
|
});
|
|
580
662
|
}
|
|
@@ -591,7 +673,6 @@ export async function postReply(config, promptId, text) {
|
|
|
591
673
|
body: JSON.stringify({ role: "assistant", text }),
|
|
592
674
|
signal: AbortSignal.timeout(30_000),
|
|
593
675
|
});
|
|
594
|
-
// 4xx = prompt cancelled, that's OK
|
|
595
676
|
return response.ok || (response.status >= 400 && response.status < 500);
|
|
596
677
|
}
|
|
597
678
|
catch {
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { ZhiHandConfig } from "../core/config.ts";
|
|
2
|
+
/** Brain metadata included in every heartbeat, so the app always knows the current backend/model. */
|
|
3
|
+
export interface BrainMeta {
|
|
4
|
+
backend?: string | null;
|
|
5
|
+
model?: string | null;
|
|
6
|
+
}
|
|
7
|
+
/** Update the backend/model metadata that will be sent with the next heartbeat. */
|
|
8
|
+
export declare function setBrainMeta(meta: BrainMeta): void;
|
|
2
9
|
export declare function sendBrainOnline(config: ZhiHandConfig): Promise<boolean>;
|
|
3
10
|
export declare function sendBrainOffline(config: ZhiHandConfig): Promise<boolean>;
|
|
4
11
|
export declare function startHeartbeatLoop(config: ZhiHandConfig, log: (msg: string) => void): void;
|
package/dist/daemon/heartbeat.js
CHANGED
|
@@ -2,18 +2,28 @@ const HEARTBEAT_INTERVAL = 30_000; // 30s
|
|
|
2
2
|
const HEARTBEAT_RETRY_INTERVAL = 5_000; // 5s on failure
|
|
3
3
|
let heartbeatTimer;
|
|
4
4
|
let retryTimer;
|
|
5
|
+
let currentMeta = {};
|
|
6
|
+
/** Update the backend/model metadata that will be sent with the next heartbeat. */
|
|
7
|
+
export function setBrainMeta(meta) {
|
|
8
|
+
currentMeta = meta;
|
|
9
|
+
}
|
|
5
10
|
function buildUrl(config) {
|
|
6
11
|
return `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/brain-status`;
|
|
7
12
|
}
|
|
8
13
|
async function sendHeartbeat(config, online) {
|
|
9
14
|
try {
|
|
15
|
+
const body = { plugin_online: online };
|
|
16
|
+
if (currentMeta.backend)
|
|
17
|
+
body.backend = currentMeta.backend;
|
|
18
|
+
if (currentMeta.model)
|
|
19
|
+
body.model = currentMeta.model;
|
|
10
20
|
const response = await fetch(buildUrl(config), {
|
|
11
21
|
method: "POST",
|
|
12
22
|
headers: {
|
|
13
23
|
"Content-Type": "application/json",
|
|
14
24
|
"x-zhihand-controller-token": config.controllerToken,
|
|
15
25
|
},
|
|
16
|
-
body: JSON.stringify(
|
|
26
|
+
body: JSON.stringify(body),
|
|
17
27
|
signal: AbortSignal.timeout(10_000),
|
|
18
28
|
});
|
|
19
29
|
return response.ok;
|
package/dist/daemon/index.js
CHANGED
|
@@ -5,14 +5,16 @@ import path from "node:path";
|
|
|
5
5
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
6
6
|
// Transport type used only for cleanup interface
|
|
7
7
|
import { createServer as createMcpServer } from "../index.js";
|
|
8
|
-
import { resolveConfig, loadBackendConfig, saveBackendConfig, resolveZhiHandDir, ensureZhiHandDir, } from "../core/config.js";
|
|
9
|
-
import {
|
|
8
|
+
import { resolveConfig, loadBackendConfig, saveBackendConfig, resolveZhiHandDir, ensureZhiHandDir, DEFAULT_MODELS, } from "../core/config.js";
|
|
9
|
+
import { PACKAGE_VERSION } from "../index.js";
|
|
10
|
+
import { startHeartbeatLoop, stopHeartbeatLoop, sendBrainOffline, setBrainMeta } from "./heartbeat.js";
|
|
10
11
|
import { PromptListener } from "./prompt-listener.js";
|
|
11
12
|
import { dispatchToCLI, postReply, killActiveChild } from "./dispatcher.js";
|
|
12
13
|
const DEFAULT_PORT = 18686;
|
|
13
14
|
const PID_FILE = "daemon.pid";
|
|
14
15
|
// ── State ──────────────────────────────────────────────────
|
|
15
16
|
let activeBackend = null;
|
|
17
|
+
let activeModel = null; // user-selected model alias, null = use default
|
|
16
18
|
let isProcessing = false;
|
|
17
19
|
const promptQueue = [];
|
|
18
20
|
function log(msg) {
|
|
@@ -28,7 +30,7 @@ async function processPrompt(config, prompt) {
|
|
|
28
30
|
}
|
|
29
31
|
const preview = prompt.text.length > 40 ? prompt.text.slice(0, 40) + "..." : prompt.text;
|
|
30
32
|
log(`[relay] Prompt: "${preview}" → dispatching to ${activeBackend}...`);
|
|
31
|
-
const result = await dispatchToCLI(activeBackend, prompt.text, log);
|
|
33
|
+
const result = await dispatchToCLI(activeBackend, prompt.text, log, activeModel ?? undefined);
|
|
32
34
|
const ok = await postReply(config, prompt.id, result.text);
|
|
33
35
|
const dur = (result.durationMs / 1000).toFixed(1);
|
|
34
36
|
if (ok) {
|
|
@@ -69,7 +71,7 @@ function handleInternalAPI(req, res) {
|
|
|
69
71
|
});
|
|
70
72
|
req.on("end", () => {
|
|
71
73
|
try {
|
|
72
|
-
const { backend } = JSON.parse(body);
|
|
74
|
+
const { backend, model } = JSON.parse(body);
|
|
73
75
|
const allowed = ["claudecode", "codex", "gemini"];
|
|
74
76
|
if (!allowed.includes(backend)) {
|
|
75
77
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
@@ -77,10 +79,13 @@ function handleInternalAPI(req, res) {
|
|
|
77
79
|
return;
|
|
78
80
|
}
|
|
79
81
|
activeBackend = backend;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
activeModel = model ?? null;
|
|
83
|
+
saveBackendConfig({ activeBackend, model: activeModel });
|
|
84
|
+
const effectiveModel = activeModel ?? DEFAULT_MODELS[activeBackend];
|
|
85
|
+
setBrainMeta({ backend: activeBackend, model: effectiveModel });
|
|
86
|
+
log(`[config] Backend switched to ${activeBackend}, model: ${effectiveModel}`);
|
|
82
87
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
83
|
-
res.end(JSON.stringify({ ok: true, backend: activeBackend }));
|
|
88
|
+
res.end(JSON.stringify({ ok: true, backend: activeBackend, model: effectiveModel }));
|
|
84
89
|
}
|
|
85
90
|
catch {
|
|
86
91
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
@@ -90,9 +95,12 @@ function handleInternalAPI(req, res) {
|
|
|
90
95
|
return true;
|
|
91
96
|
}
|
|
92
97
|
if (url === "/internal/status" && req.method === "GET") {
|
|
98
|
+
const effectiveModel = activeBackend ? (activeModel ?? DEFAULT_MODELS[activeBackend]) : null;
|
|
93
99
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
94
100
|
res.end(JSON.stringify({
|
|
101
|
+
version: PACKAGE_VERSION,
|
|
95
102
|
backend: activeBackend,
|
|
103
|
+
model: effectiveModel,
|
|
96
104
|
processing: isProcessing,
|
|
97
105
|
queueLength: promptQueue.length,
|
|
98
106
|
pid: process.pid,
|
|
@@ -155,9 +163,20 @@ export async function startDaemon(options) {
|
|
|
155
163
|
log("Run 'zhihand setup' to pair a device first.");
|
|
156
164
|
process.exit(1);
|
|
157
165
|
}
|
|
158
|
-
// Load backend
|
|
166
|
+
// Load backend + model
|
|
159
167
|
const backendConfig = loadBackendConfig();
|
|
160
168
|
activeBackend = backendConfig.activeBackend ?? null;
|
|
169
|
+
activeModel = backendConfig.model ?? null;
|
|
170
|
+
// Log startup info + set brain meta for heartbeat
|
|
171
|
+
log(`ZhiHand v${PACKAGE_VERSION} starting...`);
|
|
172
|
+
if (activeBackend) {
|
|
173
|
+
const effectiveModel = activeModel ?? DEFAULT_MODELS[activeBackend];
|
|
174
|
+
log(`[config] Backend: ${activeBackend}, Model: ${effectiveModel}`);
|
|
175
|
+
setBrainMeta({ backend: activeBackend, model: effectiveModel });
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
log(`[config] No backend configured. Use: zhihand gemini / zhihand claude / zhihand codex`);
|
|
179
|
+
}
|
|
161
180
|
// MCP sessions: each client gets its own McpServer + Transport pair
|
|
162
181
|
// because McpServer.connect() can only be called once per instance
|
|
163
182
|
const MAX_MCP_SESSIONS = 20;
|
package/dist/index.d.ts
CHANGED
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
|
+
export const PACKAGE_VERSION = "0.22.0";
|
|
9
9
|
export function createServer(deviceName) {
|
|
10
10
|
const server = new McpServer({
|
|
11
11
|
name: "zhihand",
|