@zhihand/mcp 0.19.0 → 0.20.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/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
  }
@@ -131,17 +137,29 @@ switch (command) {
131
137
  case "relay": {
132
138
  if (values.detach) {
133
139
  const { spawn: spawnChild } = await import("node:child_process");
140
+ const fsSync = await import("node:fs");
141
+ const pathMod = await import("node:path");
142
+ const osMod = await import("node:os");
143
+
134
144
  const args = [process.argv[1], "start"];
135
145
  if (values.port) args.push("--port", values.port);
136
146
  if (values.device) args.push("--device", values.device);
137
147
 
148
+ // Write daemon logs to ~/.zhihand/daemon.log
149
+ const zhihandDir = pathMod.default.join(osMod.default.homedir(), ".zhihand");
150
+ fsSync.default.mkdirSync(zhihandDir, { recursive: true });
151
+ const logPath = pathMod.default.join(zhihandDir, "daemon.log");
152
+ const logFd = fsSync.default.openSync(logPath, "a");
153
+
138
154
  const child = spawnChild(process.execPath, args, {
139
155
  detached: true,
140
- stdio: "ignore",
156
+ stdio: ["ignore", logFd, logFd],
141
157
  env: { ...process.env },
142
158
  });
143
159
  child.unref();
160
+ fsSync.default.closeSync(logFd);
144
161
  console.log(`Daemon starting in background (PID ${child.pid}).`);
162
+ console.log(`Logs: ${logPath}`);
145
163
  process.exit(0);
146
164
  }
147
165
  const port = values.port ? parseInt(values.port, 10) : undefined;
@@ -187,7 +205,11 @@ switch (command) {
187
205
  } else {
188
206
  console.log("No paired device. Run: zhihand setup");
189
207
  }
190
- 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})`);
191
213
  console.log(`Daemon: ${daemonPid ? `running (PID ${daemonPid})` : "not running"}`);
192
214
 
193
215
  // If daemon running, get live status
@@ -1,6 +1,7 @@
1
1
  export interface CLITool {
2
2
  name: "claudecode" | "codex" | "gemini" | "openclaw";
3
3
  command: string;
4
+ resolvedPath: string;
4
5
  version: string;
5
6
  loggedIn: boolean;
6
7
  priority: number;
@@ -1,4 +1,5 @@
1
1
  import { execSync } from "node:child_process";
2
+ import { resolveExecutable, resolveGemini, resolveClaude, resolveCodex } from "../core/resolve-path.js";
2
3
  function tryExec(cmd) {
3
4
  try {
4
5
  return execSync(cmd, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim();
@@ -7,41 +8,39 @@ function tryExec(cmd) {
7
8
  return null;
8
9
  }
9
10
  }
10
- function isCommandAvailable(cmd) {
11
- return tryExec(`which ${cmd}`) !== null;
12
- }
13
11
  async function detectClaudeCode() {
14
- if (!isCommandAvailable("claude"))
15
- return null;
16
- const version = tryExec("claude --version") ?? "unknown";
17
- // Check login: claude has config in ~/.claude/
12
+ const resolved = resolveClaude();
13
+ if (resolved === "claude")
14
+ return null; // bare name = not found
15
+ const version = tryExec(`"${resolved}" --version`) ?? "unknown";
18
16
  const loggedIn = tryExec("ls ~/.claude/settings.json") !== null;
19
- return { name: "claudecode", command: "claude", version, loggedIn, priority: 1 };
17
+ return { name: "claudecode", command: "claude", resolvedPath: resolved, version, loggedIn, priority: 1 };
20
18
  }
21
19
  async function detectCodex() {
22
- if (!isCommandAvailable("codex"))
20
+ const resolved = resolveCodex();
21
+ if (resolved === "codex")
23
22
  return null;
24
- const version = tryExec("codex --version") ?? "unknown";
25
- // Check login: OPENAI_API_KEY env var or config
23
+ const version = tryExec(`"${resolved}" --version`) ?? "unknown";
26
24
  const loggedIn = !!process.env.OPENAI_API_KEY || tryExec("ls ~/.codex/") !== null;
27
- return { name: "codex", command: "codex", version, loggedIn, priority: 2 };
25
+ return { name: "codex", command: "codex", resolvedPath: resolved, version, loggedIn, priority: 2 };
28
26
  }
29
27
  async function detectGemini() {
30
- if (!isCommandAvailable("gemini"))
28
+ const resolved = resolveGemini();
29
+ if (resolved === "gemini")
31
30
  return null;
32
- const version = tryExec("gemini --version") ?? "unknown";
33
- // Check login: oauth_creds.json or GOOGLE_API_KEY env var
31
+ const version = tryExec(`"${resolved}" --version`) ?? "unknown";
34
32
  const loggedIn = !!process.env.GOOGLE_API_KEY
35
33
  || !!process.env.GEMINI_API_KEY
36
34
  || tryExec("ls ~/.gemini/oauth_creds.json") !== null;
37
- return { name: "gemini", command: "gemini", version, loggedIn, priority: 3 };
35
+ return { name: "gemini", command: "gemini", resolvedPath: resolved, version, loggedIn, priority: 3 };
38
36
  }
39
37
  async function detectOpenClaw() {
40
- if (!isCommandAvailable("openclaw"))
38
+ const resolved = resolveExecutable("openclaw", []);
39
+ if (resolved === "openclaw")
41
40
  return null;
42
- const version = tryExec("openclaw --version") ?? "unknown";
41
+ const version = tryExec(`"${resolved}" --version`) ?? "unknown";
43
42
  const loggedIn = tryExec("ls ~/.openclaw/openclaw.json") !== null;
44
- return { name: "openclaw", command: "openclaw", version, loggedIn, priority: 4 };
43
+ return { name: "openclaw", command: "openclaw", resolvedPath: resolved, version, loggedIn, priority: 4 };
45
44
  }
46
45
  export async function detectCLITools() {
47
46
  const results = await Promise.allSettled([
@@ -1,21 +1,26 @@
1
1
  import { execSync } from "node:child_process";
2
+ import { resolveClaude, resolveCodex, resolveGemini } from "../core/resolve-path.js";
2
3
  const DEFAULT_PORT = 18686;
3
4
  function mcpUrl() {
4
5
  const port = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || DEFAULT_PORT;
5
6
  return `http://localhost:${port}/mcp`;
6
7
  }
8
+ /** Quote a path for shell execution (handles spaces in paths) */
9
+ function q(p) {
10
+ return `"${p}"`;
11
+ }
7
12
  const MCP_COMMANDS = {
8
13
  claudecode: {
9
- add: () => `claude mcp add --transport http zhihand ${mcpUrl()}`,
10
- remove: "claude mcp remove zhihand",
14
+ add: () => `${q(resolveClaude())} mcp add --transport http zhihand ${mcpUrl()}`,
15
+ remove: () => `${q(resolveClaude())} mcp remove zhihand`,
11
16
  },
12
17
  codex: {
13
- add: () => `codex mcp add zhihand --url ${mcpUrl()}`,
14
- remove: "codex mcp remove zhihand",
18
+ add: () => `${q(resolveCodex())} mcp add zhihand --url ${mcpUrl()}`,
19
+ remove: () => `${q(resolveCodex())} mcp remove zhihand`,
15
20
  },
16
21
  gemini: {
17
- add: () => `gemini mcp add --transport http --scope user zhihand ${mcpUrl()}`,
18
- remove: "gemini mcp remove --scope user zhihand",
22
+ add: () => `${q(resolveGemini())} mcp add --transport http --scope user zhihand ${mcpUrl()}`,
23
+ remove: () => `${q(resolveGemini())} mcp remove --scope user zhihand`,
19
24
  },
20
25
  };
21
26
  const DISPLAY_NAMES = {
@@ -44,7 +49,7 @@ export function configureMCP(backend, previousBackend) {
44
49
  const cmds = MCP_COMMANDS[previousBackend];
45
50
  if (cmds) {
46
51
  console.log(` Removing MCP config from ${DISPLAY_NAMES[previousBackend]}...`);
47
- removed = tryRun(cmds.remove);
52
+ removed = tryRun(cmds.remove());
48
53
  }
49
54
  }
50
55
  // Add to new backend
@@ -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");
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Resolve an executable by name: first try `which`, then check fallback paths.
3
+ * Supports a single `*` glob segment in fallback paths (for version directories).
4
+ * Returns the full path, or the bare name as last resort.
5
+ */
6
+ export declare function resolveExecutable(name: string, fallbackPaths: string[]): string;
7
+ /** Platform-specific fallback paths for gemini */
8
+ export declare function resolveGemini(): string;
9
+ /** Platform-specific fallback paths for claude */
10
+ export declare function resolveClaude(): string;
11
+ /** Platform-specific fallback paths for codex */
12
+ export declare function resolveCodex(): string;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Platform-aware executable path resolution.
3
+ * Shared by both the CLI detection layer and the daemon dispatcher.
4
+ */
5
+ import { execSync } from "node:child_process";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import os from "node:os";
9
+ /** Cache of resolved executable paths to avoid repeated lookups */
10
+ const cache = new Map();
11
+ /**
12
+ * Resolve an executable by name: first try `which`, then check fallback paths.
13
+ * Supports a single `*` glob segment in fallback paths (for version directories).
14
+ * Returns the full path, or the bare name as last resort.
15
+ */
16
+ export function resolveExecutable(name, fallbackPaths) {
17
+ const cached = cache.get(name);
18
+ if (cached)
19
+ return cached;
20
+ // Try `which` first (works when the binary is in PATH)
21
+ try {
22
+ const resolved = execSync(`which ${name}`, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim();
23
+ if (resolved) {
24
+ cache.set(name, resolved);
25
+ return resolved;
26
+ }
27
+ }
28
+ catch {
29
+ // Not in PATH, try fallback locations
30
+ }
31
+ for (const candidate of fallbackPaths) {
32
+ if (candidate.includes("*")) {
33
+ // Expand one level of wildcard
34
+ try {
35
+ const parts = candidate.split("*");
36
+ if (parts.length === 2) {
37
+ const parentDir = parts[0].replace(/\/$/, "");
38
+ const suffix = parts[1];
39
+ if (fs.existsSync(parentDir)) {
40
+ const entries = fs.readdirSync(parentDir, { withFileTypes: true });
41
+ // Sort descending to prefer latest version
42
+ const dirs = entries
43
+ .filter(e => e.isDirectory())
44
+ .map(e => e.name)
45
+ .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
46
+ for (const d of dirs) {
47
+ const full = parentDir + "/" + d + suffix;
48
+ if (fs.existsSync(full)) {
49
+ cache.set(name, full);
50
+ return full;
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }
56
+ catch { /* skip */ }
57
+ }
58
+ else {
59
+ if (fs.existsSync(candidate)) {
60
+ cache.set(name, candidate);
61
+ return candidate;
62
+ }
63
+ }
64
+ }
65
+ // Last resort: return bare name and let spawn fail with a clear error
66
+ return name;
67
+ }
68
+ /** Platform-specific fallback paths for gemini */
69
+ export function resolveGemini() {
70
+ return resolveExecutable("gemini", [
71
+ "/opt/homebrew/bin/gemini",
72
+ "/usr/local/bin/gemini",
73
+ path.join(os.homedir(), ".local/bin/gemini"),
74
+ path.join(os.homedir(), "bin/gemini"),
75
+ ]);
76
+ }
77
+ /** Platform-specific fallback paths for claude */
78
+ export function resolveClaude() {
79
+ const home = os.homedir();
80
+ const fallbacks = [];
81
+ if (process.platform === "darwin") {
82
+ fallbacks.push(path.join(home, "Library/Application Support/Claude/claude-code/*/claude.app/Contents/MacOS/claude"), "/usr/local/bin/claude", "/opt/homebrew/bin/claude");
83
+ }
84
+ else if (process.platform === "linux") {
85
+ fallbacks.push("/usr/local/bin/claude", path.join(home, ".local/bin/claude"), "/snap/bin/claude");
86
+ }
87
+ else if (process.platform === "win32") {
88
+ fallbacks.push(path.join(process.env.LOCALAPPDATA ?? "", "Programs/Claude/claude.exe"), path.join(process.env.APPDATA ?? "", "npm/claude.cmd"));
89
+ }
90
+ return resolveExecutable("claude", fallbacks);
91
+ }
92
+ /** Platform-specific fallback paths for codex */
93
+ export function resolveCodex() {
94
+ return resolveExecutable("codex", [
95
+ "/opt/homebrew/bin/codex",
96
+ "/usr/local/bin/codex",
97
+ path.join(os.homedir(), ".local/bin/codex"),
98
+ ]);
99
+ }
@@ -1,9 +1,10 @@
1
- import { spawn, execSync } from "node:child_process";
2
- import fs from "node:fs";
1
+ import { spawn } from "node:child_process";
3
2
  import fsp from "node:fs/promises";
4
3
  import path from "node:path";
5
4
  import os from "node:os";
6
5
  import { fileURLToPath } from "node:url";
6
+ import { DEFAULT_MODELS } from "../core/config.js";
7
+ import { resolveGemini, resolveClaude, resolveCodex } from "../core/resolve-path.js";
7
8
  const CLI_TIMEOUT = 120_000; // 120s
8
9
  const SIGKILL_DELAY = 2_000; // 2s after SIGTERM
9
10
  const MAX_OUTPUT_BYTES = 100 * 1024; // 100KB
@@ -13,93 +14,6 @@ const SESSION_STABILITY_DELAY = 2_000; // wait 2s after outcome before returning
13
14
  // Resolve pty-wrap.py relative to this file (works from both src/ and dist/)
14
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
16
  const PTY_WRAP_SCRIPT = path.resolve(__dirname, "../../scripts/pty-wrap.py");
16
- // ── Executable Path Resolution ───────────────────────────────
17
- /** Cache of resolved executable paths to avoid repeated lookups */
18
- const executableCache = new Map();
19
- /**
20
- * Resolve the full path of a CLI executable.
21
- * Searches PATH first via `which`, then falls back to platform-specific known locations.
22
- */
23
- function resolveExecutable(name, fallbackPaths) {
24
- const cached = executableCache.get(name);
25
- if (cached)
26
- return cached;
27
- // Try `which` first (works when the binary is in PATH)
28
- try {
29
- const resolved = execSync(`which ${name}`, { encoding: "utf8", timeout: 5000 }).trim();
30
- if (resolved) {
31
- executableCache.set(name, resolved);
32
- return resolved;
33
- }
34
- }
35
- catch {
36
- // Not in PATH, try fallback locations
37
- }
38
- // Try known platform-specific paths
39
- for (const candidate of fallbackPaths) {
40
- // Support glob-like patterns with * (e.g. version directories)
41
- if (candidate.includes("*")) {
42
- try {
43
- const dir = path.dirname(candidate);
44
- const pattern = path.basename(candidate);
45
- // Walk one level of glob for version directories
46
- const parentDir = path.dirname(dir);
47
- const globSegment = path.basename(dir);
48
- if (globSegment === "*") {
49
- const entries = fs.readdirSync(parentDir, { withFileTypes: true });
50
- // Sort descending to prefer latest version
51
- const dirs = entries
52
- .filter(e => e.isDirectory())
53
- .map(e => e.name)
54
- .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
55
- for (const d of dirs) {
56
- const full = path.join(parentDir, d, pattern);
57
- if (fs.existsSync(full)) {
58
- executableCache.set(name, full);
59
- return full;
60
- }
61
- }
62
- }
63
- }
64
- catch {
65
- // Glob resolution failed, skip
66
- }
67
- }
68
- else {
69
- if (fs.existsSync(candidate)) {
70
- executableCache.set(name, candidate);
71
- return candidate;
72
- }
73
- }
74
- }
75
- // Last resort: return bare name and let spawn fail with a clear error
76
- return name;
77
- }
78
- /** Resolve gemini executable path */
79
- function resolveGemini() {
80
- return resolveExecutable("gemini", [
81
- "/opt/homebrew/bin/gemini", // macOS ARM (Homebrew)
82
- "/usr/local/bin/gemini", // macOS Intel / Linux
83
- path.join(os.homedir(), ".local/bin/gemini"), // pip --user install
84
- path.join(os.homedir(), "bin/gemini"),
85
- ]);
86
- }
87
- /** Resolve claude executable path */
88
- function resolveClaude() {
89
- const platform = process.platform;
90
- const fallbacks = [];
91
- if (platform === "darwin") {
92
- // macOS: Claude Code installed via Claude desktop app
93
- fallbacks.push(path.join(os.homedir(), "Library/Application Support/Claude/claude-code/*/claude.app/Contents/MacOS/claude"), "/usr/local/bin/claude", "/opt/homebrew/bin/claude");
94
- }
95
- else if (platform === "linux") {
96
- fallbacks.push("/usr/local/bin/claude", path.join(os.homedir(), ".local/bin/claude"), "/snap/bin/claude");
97
- }
98
- else if (platform === "win32") {
99
- fallbacks.push(path.join(process.env.LOCALAPPDATA ?? "", "Programs/Claude/claude.exe"), path.join(process.env.APPDATA ?? "", "npm/claude.cmd"));
100
- }
101
- return resolveExecutable("claude", fallbacks);
102
- }
103
17
  // Gemini session directories
104
18
  const GEMINI_TMP_DIR = path.join(os.homedir(), ".gemini", "tmp");
105
19
  let activeChild = null;
@@ -454,14 +368,17 @@ ${userPrompt}`;
454
368
  export function dispatchToCLI(backend, prompt, log, model) {
455
369
  const startTime = Date.now();
456
370
  const wrappedPrompt = wrapPrompt(prompt);
371
+ // Resolve model: explicit > env > default
372
+ const resolvedModel = resolveModel(backend, model);
373
+ log(`[dispatch] Backend: ${backend}, Model: ${resolvedModel}`);
457
374
  if (backend === "gemini") {
458
- return dispatchGemini(wrappedPrompt, startTime, log, model);
375
+ return dispatchGemini(wrappedPrompt, startTime, log, resolvedModel);
459
376
  }
460
377
  if (backend === "codex") {
461
- return dispatchCodex(wrappedPrompt, startTime, model);
378
+ return dispatchCodex(wrappedPrompt, startTime, resolvedModel);
462
379
  }
463
380
  if (backend === "claudecode") {
464
- return dispatchClaude(wrappedPrompt, startTime, model);
381
+ return dispatchClaude(wrappedPrompt, startTime, resolvedModel);
465
382
  }
466
383
  return Promise.resolve({
467
384
  text: `Unsupported backend: ${backend}`,
@@ -469,12 +386,38 @@ export function dispatchToCLI(backend, prompt, log, model) {
469
386
  durationMs: 0,
470
387
  });
471
388
  }
389
+ /**
390
+ * Resolve the model to use for a backend.
391
+ * Priority: explicit parameter > ZHIHAND_MODEL env > backend-specific env > default alias.
392
+ *
393
+ * Each backend CLI handles alias→full-name resolution natively:
394
+ * - Gemini CLI: "flash" → gemini-2.5-flash, "pro" → gemini-2.5-pro
395
+ * - Claude Code: "sonnet" → claude-sonnet-4-*, "opus" → claude-opus-4-*, "haiku" → claude-haiku-4-*
396
+ * - Codex CLI: no alias support — pass full model name directly (e.g. "o4-mini", "codex-mini")
397
+ */
398
+ function resolveModel(backend, explicit) {
399
+ if (explicit)
400
+ return explicit;
401
+ // Global env override
402
+ const globalEnv = process.env.ZHIHAND_MODEL;
403
+ if (globalEnv)
404
+ return globalEnv;
405
+ // Per-backend env override
406
+ const envMap = {
407
+ gemini: process.env.ZHIHAND_GEMINI_MODEL,
408
+ claudecode: process.env.ZHIHAND_CLAUDE_MODEL,
409
+ codex: process.env.ZHIHAND_CODEX_MODEL,
410
+ };
411
+ const perBackend = envMap[backend];
412
+ if (perBackend)
413
+ return perBackend;
414
+ return DEFAULT_MODELS[backend];
415
+ }
472
416
  // ── Gemini Dispatch (PTY + Session File Monitoring) ────────
473
417
  function dispatchGemini(prompt, startTime, log, model) {
474
- const geminiModel = model ?? process.env.CLAUDE_GEMINI_MODEL ?? "gemini-3.1-pro-preview";
475
418
  const cliArgs = [
476
419
  "--approval-mode", "yolo",
477
- "--model", geminiModel,
420
+ "--model", model,
478
421
  "-i", prompt,
479
422
  ];
480
423
  const env = {
@@ -501,12 +444,10 @@ function dispatchCodex(prompt, startTime, model) {
501
444
  // --dangerously-bypass-approvals-and-sandbox is required so MCP tool calls
502
445
  // are not auto-cancelled in non-interactive mode (--full-auto cancels them)
503
446
  const args = ["exec", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "--json"];
504
- const codexModel = model ?? process.env.CLAUDE_CODEX_MODEL;
505
- if (codexModel) {
506
- args.push("-m", codexModel);
507
- }
447
+ args.push("-m", model);
508
448
  args.push(prompt);
509
- const child = spawn("codex", args, {
449
+ const codexPath = resolveCodex();
450
+ const child = spawn(codexPath, args, {
510
451
  env: process.env,
511
452
  stdio: ["ignore", "pipe", "pipe"],
512
453
  detached: false,
@@ -517,7 +458,7 @@ function dispatchCodex(prompt, startTime, model) {
517
458
  // ── Claude Dispatch ────────────────────────────────────────
518
459
  function dispatchClaude(prompt, startTime, model) {
519
460
  const claudePath = resolveClaude();
520
- const child = spawn(claudePath, ["-p", prompt, "--output-format", "json"], {
461
+ const child = spawn(claudePath, ["-p", prompt, "--model", model, "--output-format", "json"], {
521
462
  env: process.env,
522
463
  stdio: ["ignore", "pipe", "pipe"],
523
464
  detached: false,
@@ -5,7 +5,8 @@ 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";
8
+ import { resolveConfig, loadBackendConfig, saveBackendConfig, resolveZhiHandDir, ensureZhiHandDir, DEFAULT_MODELS, } from "../core/config.js";
9
+ import { PACKAGE_VERSION } from "../index.js";
9
10
  import { startHeartbeatLoop, stopHeartbeatLoop, sendBrainOffline } from "./heartbeat.js";
10
11
  import { PromptListener } from "./prompt-listener.js";
11
12
  import { dispatchToCLI, postReply, killActiveChild } from "./dispatcher.js";
@@ -13,6 +14,7 @@ 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,12 @@ 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
+ log(`[config] Backend switched to ${activeBackend}, model: ${effectiveModel}`);
82
86
  res.writeHead(200, { "Content-Type": "application/json" });
83
- res.end(JSON.stringify({ ok: true, backend: activeBackend }));
87
+ res.end(JSON.stringify({ ok: true, backend: activeBackend, model: effectiveModel }));
84
88
  }
85
89
  catch {
86
90
  res.writeHead(400, { "Content-Type": "application/json" });
@@ -90,9 +94,12 @@ function handleInternalAPI(req, res) {
90
94
  return true;
91
95
  }
92
96
  if (url === "/internal/status" && req.method === "GET") {
97
+ const effectiveModel = activeBackend ? (activeModel ?? DEFAULT_MODELS[activeBackend]) : null;
93
98
  res.writeHead(200, { "Content-Type": "application/json" });
94
99
  res.end(JSON.stringify({
100
+ version: PACKAGE_VERSION,
95
101
  backend: activeBackend,
102
+ model: effectiveModel,
96
103
  processing: isProcessing,
97
104
  queueLength: promptQueue.length,
98
105
  pid: process.pid,
@@ -155,9 +162,19 @@ export async function startDaemon(options) {
155
162
  log("Run 'zhihand setup' to pair a device first.");
156
163
  process.exit(1);
157
164
  }
158
- // Load backend
165
+ // Load backend + model
159
166
  const backendConfig = loadBackendConfig();
160
167
  activeBackend = backendConfig.activeBackend ?? null;
168
+ activeModel = backendConfig.model ?? null;
169
+ // Log startup info
170
+ log(`ZhiHand v${PACKAGE_VERSION} starting...`);
171
+ if (activeBackend) {
172
+ const effectiveModel = activeModel ?? DEFAULT_MODELS[activeBackend];
173
+ log(`[config] Backend: ${activeBackend}, Model: ${effectiveModel}`);
174
+ }
175
+ else {
176
+ log(`[config] No backend configured. Use: zhihand gemini / zhihand claude / zhihand codex`);
177
+ }
161
178
  // MCP sessions: each client gets its own McpServer + Transport pair
162
179
  // because McpServer.connect() can only be called once per instance
163
180
  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.20.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.0";
8
+ export const PACKAGE_VERSION = "0.20.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.0",
3
+ "version": "0.20.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",