@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 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.16.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 (detached)
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 active backend
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 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)
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 # Switch to Gemini CLI
127
- zhihand claude # Switch to Claude Code
128
- zhihand codex # Switch to Codex CLI
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
- | `rightclick` | `xRatio`, `yRatio` | Right-click (long press) |
164
- | `middleclick` | `xRatio`, `yRatio` | Middle-click |
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 (claudecode/codex/gemini)
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
- if (previous === backendName) {
86
- console.log(`Already using ${displayName(backendName)} as backend.`);
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
- console.log(`Active backend: ${backend.activeBackend ? displayName(backend.activeBackend) : "(none)"}`);
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
@@ -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;
@@ -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 { execSync } from "node:child_process";
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 = execSync(`which ${name}`, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim();
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 child process. Returns a promise that resolves
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 activeChild = null;
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; // Only check first user message
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 files for the response.
179
- * Returns the final text when gemini completes, or null on timeout.
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 = null;
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
- // Kill the gemini process now that we have the answer
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 and check for outcome
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
- // Also handle process exit (in case it crashes before producing session file)
267
- child.on("close", (code) => {
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: EOF → SIGTERM → SIGKILL. */
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
- * Kill the active child process. Returns a promise that resolves
311
- * when the child has exited (or immediately if no child).
312
- */
313
- export function killActiveChild() {
314
- if (!activeChild || activeChild.killed) {
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
- const child = activeChild;
319
- child.once("close", () => resolve());
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
- * Wrap the user's raw prompt with system context so the CLI backend
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 wrapPrompt(userPrompt) {
331
- return `You are ZhiHand, an AI assistant connected to the user's mobile phone via MCP tools.
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
- User message:
364
- ${userPrompt}`;
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 wrappedPrompt = wrapPrompt(prompt);
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 dispatchGemini(wrappedPrompt, startTime, log, model);
421
+ return dispatchGeminiPersistent(prompt, startTime, log, resolvedModel);
372
422
  }
373
423
  if (backend === "codex") {
374
- return dispatchCodex(wrappedPrompt, startTime, model);
424
+ return dispatchCodexWithHistory(prompt, startTime, log, resolvedModel);
375
425
  }
376
426
  if (backend === "claudecode") {
377
- return dispatchClaude(wrappedPrompt, startTime, model);
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
- // ── Gemini Dispatch (PTY + Session File Monitoring) ────────
386
- function dispatchGemini(prompt, startTime, log, model) {
387
- const geminiModel = model ?? process.env.CLAUDE_GEMINI_MODEL ?? "gemini-3.1-pro-preview";
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", geminiModel,
391
- "-i", prompt,
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: ["ignore", "pipe", "pipe"],
486
+ stdio: ["pipe", "pipe", "pipe"], // stdin=pipe for subsequent prompts
404
487
  detached: false,
405
488
  });
406
- activeChild = child;
407
- // Drain PTY output (discard — we read from session file instead)
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
- return pollGeminiSession(child, startTime, prompt, log);
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 dispatchCodex(prompt, startTime, model) {
414
- // --dangerously-bypass-approvals-and-sandbox is required so MCP tool calls
415
- // are not auto-cancelled in non-interactive mode (--full-auto cancels them)
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
- const codexModel = model ?? process.env.CLAUDE_CODEX_MODEL;
418
- if (codexModel) {
419
- args.push("-m", codexModel);
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: ["ignore", "pipe", "pipe"],
525
+ stdio: ["pipe", "pipe", "pipe"],
426
526
  detached: false,
427
527
  });
428
- activeChild = child;
429
- return collectCodexOutput(child, startTime);
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 dispatchClaude(prompt, startTime, model) {
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
- const child = spawn(claudePath, ["-p", prompt, "--output-format", "json"], {
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: ["ignore", "pipe", "pipe"],
544
+ stdio: ["pipe", "pipe", "pipe"],
437
545
  detached: false,
438
546
  });
439
- activeChild = child;
440
- return collectChildOutput(child, startTime);
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
- const onData = (data) => {
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
- activeChild = null;
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
- // Timeout with two-stage kill
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
- activeChild = null;
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;
@@ -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({ plugin_online: online }),
26
+ body: JSON.stringify(body),
17
27
  signal: AbortSignal.timeout(10_000),
18
28
  });
19
29
  return response.ok;
@@ -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 { startHeartbeatLoop, stopHeartbeatLoop, sendBrainOffline } from "./heartbeat.js";
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
- saveBackendConfig({ activeBackend });
81
- log(`[config] Backend switched to ${activeBackend}.`);
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
@@ -1,3 +1,4 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare const PACKAGE_VERSION = "0.22.0";
2
3
  export declare function createServer(deviceName?: string): McpServer;
3
4
  export declare function startStdioServer(deviceName?: string): Promise<void>;
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { controlSchema, screenshotSchema, pairSchema } from "./tools/schemas.js"
5
5
  import { executeControl } from "./tools/control.js";
6
6
  import { handleScreenshot } from "./tools/screenshot.js";
7
7
  import { handlePair } from "./tools/pair.js";
8
- const PACKAGE_VERSION = "0.19.1";
8
+ export const PACKAGE_VERSION = "0.22.0";
9
9
  export function createServer(deviceName) {
10
10
  const server = new McpServer({
11
11
  name: "zhihand",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhihand/mcp",
3
- "version": "0.19.1",
3
+ "version": "0.22.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "ZhiHand MCP Server — phone control tools for Claude Code, Codex, Gemini CLI, and OpenClaw",