@zhihand/mcp 0.32.5 → 0.33.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
@@ -30,7 +30,7 @@ import { fetchUserCredentials } from "../dist/core/ws.js";
30
30
  import { configureMCP, displayName } from "../dist/cli/mcp-config.js";
31
31
 
32
32
  const DEFAULT_ENDPOINT = "https://api.zhihand.com";
33
- const VERSION = "0.32.5";
33
+ const VERSION = "0.33.0";
34
34
 
35
35
  const CLI_TOOL_MAP = {
36
36
  claude: "claudecode",
@@ -555,8 +555,7 @@ switch (command) {
555
555
  }
556
556
 
557
557
  case "test": {
558
- const { createControlCommand, createSystemCommand, enqueueCommand } = await import("../dist/core/command.js");
559
- const { waitForCommandAck } = await import("../dist/core/ws.js");
558
+ const { createControlCommand, createSystemCommand } = await import("../dist/core/command.js");
560
559
  const { fetchScreenshot, getSnapshotStaleThresholdMs } = await import("../dist/core/screenshot.js");
561
560
  const { fetchDeviceProfileOnce, extractStatic, computeCapabilities, formatDeviceStatus } = await import("../dist/core/device.js");
562
561
 
@@ -630,6 +629,34 @@ switch (command) {
630
629
  process.exit(1);
631
630
  }
632
631
 
632
+ // Require daemon to be running (provides WS connections for command ACK)
633
+ const DAEMON_PORT = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || 18686;
634
+ const DAEMON_BASE = `http://127.0.0.1:${DAEMON_PORT}`;
635
+ let daemonOk = false;
636
+ try {
637
+ const resp = await fetch(`${DAEMON_BASE}/internal/status`, { signal: AbortSignal.timeout(2000) });
638
+ daemonOk = resp.ok;
639
+ } catch { /* daemon not reachable */ }
640
+ if (!daemonOk) {
641
+ console.error("❌ Daemon is not running. Start it first: zhihand start");
642
+ process.exit(1);
643
+ }
644
+
645
+ // Execute command via daemon's /internal/exec endpoint
646
+ async function execViaDaemon(command, timeoutMs = 10_000) {
647
+ const resp = await fetch(`${DAEMON_BASE}/internal/exec`, {
648
+ method: "POST",
649
+ headers: { "Content-Type": "application/json" },
650
+ body: JSON.stringify({ command, credentialId: testConfig.credentialId, timeoutMs }),
651
+ signal: AbortSignal.timeout(timeoutMs + 5000),
652
+ });
653
+ if (!resp.ok) {
654
+ const body = await resp.text();
655
+ throw new Error(`Daemon exec failed: ${resp.status} ${body}`);
656
+ }
657
+ return resp.json();
658
+ }
659
+
633
660
  const forceRun = values.force === true;
634
661
  let selectedIds = null;
635
662
  let includeUnsafe = false;
@@ -712,12 +739,11 @@ switch (command) {
712
739
  process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
713
740
  const t0 = Date.now();
714
741
  try {
715
- const queued = await enqueueCommand(testConfig, command);
716
- const ack = await waitForCommandAck(testConfig, { commandId: queued.id, timeoutMs: 10_000 });
742
+ const result = await execViaDaemon(command);
717
743
  const ms = Date.now() - t0;
718
- if (ack.acked) {
719
- const ackStatus = ack.command?.ack_status ?? "ok";
720
- const resultInfo = ack.command?.ack_result ? ` ${JSON.stringify(ack.command.ack_result)}` : "";
744
+ if (result.acked) {
745
+ const ackStatus = result.command?.ack_status ?? "ok";
746
+ const resultInfo = result.command?.ack_result ? ` ${JSON.stringify(result.command.ack_result)}` : "";
721
747
  if (ackStatus === "ok") {
722
748
  console.log(`✅ (${ms}ms)${resultInfo}`);
723
749
  passed++;
@@ -821,9 +847,8 @@ switch (command) {
821
847
  const t0 = Date.now();
822
848
  try {
823
849
  const cmd = createControlCommand({ action: "screenshot" });
824
- const queued = await enqueueCommand(testConfig, cmd);
825
- const ack = await waitForCommandAck(testConfig, { commandId: queued.id, timeoutMs: 10_000 });
826
- if (!ack.acked) {
850
+ const result = await execViaDaemon(cmd);
851
+ if (!result.acked) {
827
852
  console.log(`⏱️ timeout (${Date.now() - t0}ms)`);
828
853
  failed++;
829
854
  break;
@@ -43,5 +43,4 @@ export interface SystemParams {
43
43
  }
44
44
  export declare function createSystemCommand(params: SystemParams, platform?: string): QueuedControlCommand;
45
45
  export declare function enqueueCommand(config: ZhiHandRuntimeConfig, command: QueuedControlCommand): Promise<QueuedCommandRecord>;
46
- export declare function getCommand(config: ZhiHandRuntimeConfig, commandId: string): Promise<QueuedCommandRecord>;
47
46
  export declare function formatAckSummary(action: string, result: WaitForCommandAckResult): string;
@@ -170,21 +170,6 @@ export async function enqueueCommand(config, command) {
170
170
  dbg(`[cmd] Enqueued: id=${payload.command.id}, status=${payload.command.status}`);
171
171
  return payload.command;
172
172
  }
173
- export async function getCommand(config, commandId) {
174
- const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/commands/${encodeURIComponent(commandId)}`;
175
- dbg(`[cmd] GET ${url}`);
176
- const response = await fetch(url, {
177
- headers: { "Authorization": `Bearer ${config.controllerToken}` },
178
- });
179
- if (!response.ok) {
180
- dbg(`[cmd] Get failed: ${response.status}`);
181
- throw new Error(`Get command failed: ${response.status}`);
182
- }
183
- const payload = (await response.json());
184
- const cmd = payload.command;
185
- dbg(`[cmd] Got: id=${cmd.id}, status=${cmd.status}, acked=${!!cmd.acked_at}, ack_status=${cmd.ack_status ?? "-"}, ack_result=${JSON.stringify(cmd.ack_result ?? null)}`);
186
- return payload.command;
187
- }
188
173
  export function formatAckSummary(action, result) {
189
174
  if (!result.acked) {
190
175
  return `Sent ${action}, waiting for ACK (timed out).`;
package/dist/core/ws.d.ts CHANGED
@@ -85,9 +85,9 @@ export interface CredentialResponse {
85
85
  export declare function fetchUserCredentials(endpoint: string, userId: string, controllerToken: string, onlineFilter?: boolean): Promise<CredentialResponse[]>;
86
86
  /**
87
87
  * Wait for command ACK via WS push (which should already be connected by the
88
- * registry). Falls back to polling.
88
+ * registry). WS-only no polling fallback.
89
89
  */
90
- export declare function waitForCommandAck(config: ZhiHandRuntimeConfig, options: {
90
+ export declare function waitForCommandAck(_config: ZhiHandRuntimeConfig, options: {
91
91
  commandId: string;
92
92
  timeoutMs?: number;
93
93
  signal?: AbortSignal;
package/dist/core/ws.js CHANGED
@@ -9,7 +9,6 @@
9
9
  * - fetchUserCredentials: HTTP REST helper (unchanged from sse.ts).
10
10
  */
11
11
  import WebSocket from "ws";
12
- import { getCommand } from "./command.js";
13
12
  import { log } from "./logger.js";
14
13
  // ── Shared reconnecting base ─────────────────────────────
15
14
  const BACKOFF_INITIAL_MS = 1000;
@@ -279,59 +278,27 @@ export async function fetchUserCredentials(endpoint, userId, controllerToken, on
279
278
  }
280
279
  /**
281
280
  * Wait for command ACK via WS push (which should already be connected by the
282
- * registry). Falls back to polling.
281
+ * registry). WS-only no polling fallback.
283
282
  */
284
- export async function waitForCommandAck(config, options) {
283
+ export async function waitForCommandAck(_config, options) {
285
284
  const timeoutMs = options.timeoutMs ?? 15_000;
286
285
  log.debug(`[ws-cmd] Waiting for ACK: commandId=${options.commandId}, timeout=${timeoutMs}ms`);
287
286
  return new Promise((resolve, reject) => {
288
- let resolved = false;
289
- let pollInterval;
290
287
  const timeout = setTimeout(() => {
291
288
  cleanup();
292
289
  resolve({ acked: false });
293
290
  }, timeoutMs);
294
291
  const unsubscribe = subscribeToCommandAck(options.commandId, (ackedCommand) => {
295
- if (resolved)
296
- return;
297
- resolved = true;
298
292
  cleanup();
299
293
  resolve({ acked: true, command: ackedCommand });
300
294
  });
301
- // Delay polling startup by 2s so WS push ACK normally wins in the
302
- // registry-connected path. CLI (zhihand test) still resolves via polling
303
- // after the initial delay.
304
- const POLL_START_DELAY_MS = 2000;
305
- const POLL_INTERVAL_MS = 500;
306
- const startPolling = setTimeout(() => {
307
- if (resolved)
308
- return;
309
- pollInterval = setInterval(async () => {
310
- if (resolved)
311
- return;
312
- try {
313
- const cmd = await getCommand(config, options.commandId);
314
- if (cmd.acked_at) {
315
- resolved = true;
316
- cleanup();
317
- resolve({ acked: true, command: cmd });
318
- }
319
- }
320
- catch {
321
- // non-fatal
322
- }
323
- }, POLL_INTERVAL_MS);
324
- }, POLL_START_DELAY_MS);
325
295
  options.signal?.addEventListener("abort", () => {
326
296
  cleanup();
327
297
  reject(new Error("The operation was aborted"));
328
298
  }, { once: true });
329
299
  function cleanup() {
330
300
  clearTimeout(timeout);
331
- clearTimeout(startPolling);
332
301
  unsubscribe();
333
- if (pollInterval)
334
- clearInterval(pollInterval);
335
302
  }
336
303
  });
337
304
  }
@@ -12,6 +12,8 @@ import { PromptListener } from "./prompt-listener.js";
12
12
  import { dispatchToCLI, postReply, killActiveChild } from "./dispatcher.js";
13
13
  import { setDebugEnabled, dbg } from "./logger.js";
14
14
  import { registry } from "../core/registry.js";
15
+ import { enqueueCommand } from "../core/command.js";
16
+ import { waitForCommandAck } from "../core/ws.js";
15
17
  const DEFAULT_PORT = 18686;
16
18
  const PID_FILE = "daemon.pid";
17
19
  // ── State ────────���─────────────────────────────────────────
@@ -102,6 +104,39 @@ function handleInternalAPI(req, res) {
102
104
  });
103
105
  return true;
104
106
  }
107
+ // Execute command via daemon's WS (used by zhihand test)
108
+ if (url === "/internal/exec" && req.method === "POST") {
109
+ dbg(`[api] POST /internal/exec`);
110
+ let body = "";
111
+ const MAX_BODY = 10 * 1024;
112
+ req.on("data", (chunk) => {
113
+ body += chunk.toString();
114
+ if (body.length > MAX_BODY) {
115
+ res.writeHead(413, { "Content-Type": "application/json" });
116
+ res.end(JSON.stringify({ error: "Payload too large" }));
117
+ req.destroy();
118
+ }
119
+ });
120
+ req.on("end", async () => {
121
+ try {
122
+ const { command, credentialId, timeoutMs } = JSON.parse(body);
123
+ const cfg = resolveConfig(credentialId);
124
+ const effectiveTimeout = timeoutMs ?? 10_000;
125
+ const queued = await enqueueCommand(cfg, command);
126
+ const ack = await waitForCommandAck(cfg, {
127
+ commandId: queued.id,
128
+ timeoutMs: effectiveTimeout,
129
+ });
130
+ res.writeHead(200, { "Content-Type": "application/json" });
131
+ res.end(JSON.stringify({ id: queued.id, ...ack }));
132
+ }
133
+ catch (err) {
134
+ res.writeHead(500, { "Content-Type": "application/json" });
135
+ res.end(JSON.stringify({ error: err.message }));
136
+ }
137
+ });
138
+ return true;
139
+ }
105
140
  if (url === "/internal/status" && req.method === "GET") {
106
141
  dbg(`[api] GET /internal/status`);
107
142
  const effectiveModel = activeBackend ? (activeModel ?? DEFAULT_MODELS[activeBackend]) : null;
@@ -17,8 +17,6 @@ export declare class PromptListener {
17
17
  private log;
18
18
  private processedIds;
19
19
  private rws;
20
- private pollTimer;
21
- private wsConnected;
22
20
  private stopped;
23
21
  constructor(config: ZhiHandConfig, handler: PromptHandler, log: (msg: string) => void);
24
22
  start(): void;
@@ -27,9 +25,5 @@ export declare class PromptListener {
27
25
  private connectWS;
28
26
  private handleWSMessage;
29
27
  private handleEvent;
30
- private startPolling;
31
- private schedulePoll;
32
- private stopPolling;
33
- private poll;
34
28
  }
35
29
  export {};
@@ -1,14 +1,11 @@
1
1
  import { ReconnectingWebSocket } from "../core/ws.js";
2
2
  import { dbg } from "./logger.js";
3
- const POLL_INTERVAL = 2_000;
4
3
  export class PromptListener {
5
4
  config;
6
5
  handler;
7
6
  log;
8
7
  processedIds = new Set();
9
8
  rws = null;
10
- pollTimer = null;
11
- wsConnected = false;
12
9
  stopped = false;
13
10
  constructor(config, handler, log) {
14
11
  this.config = config;
@@ -23,8 +20,6 @@ export class PromptListener {
23
20
  this.stopped = true;
24
21
  this.rws?.stop();
25
22
  this.rws = null;
26
- this.wsConnected = false;
27
- this.stopPolling();
28
23
  }
29
24
  dispatchPrompt(prompt) {
30
25
  if (this.processedIds.has(prompt.id)) {
@@ -60,11 +55,7 @@ export class PromptListener {
60
55
  // onConnected deferred until auth_ok is received (see handleWSMessage)
61
56
  },
62
57
  onClose: (_code, _reason) => {
63
- if (this.wsConnected) {
64
- this.wsConnected = false;
65
- this.log("[ws] Disconnected. Falling back to polling.");
66
- this.startPolling();
67
- }
58
+ dbg("[ws] Disconnected. ReconnectingWebSocket will retry.");
68
59
  },
69
60
  onMessage: (data) => {
70
61
  this.handleWSMessage(data);
@@ -79,8 +70,6 @@ export class PromptListener {
79
70
  const msg = data;
80
71
  // Auth responses
81
72
  if (msg.type === "auth_ok") {
82
- this.wsConnected = true;
83
- this.stopPolling();
84
73
  this.log("[ws] Connected to prompt stream.");
85
74
  return;
86
75
  }
@@ -88,8 +77,6 @@ export class PromptListener {
88
77
  this.log(`[ws] Auth failed: ${msg.error}`);
89
78
  this.rws?.stop();
90
79
  this.rws = null;
91
- this.wsConnected = false;
92
- this.startPolling();
93
80
  return;
94
81
  }
95
82
  // Application-level ping (if server sends these alongside protocol pings)
@@ -119,50 +106,4 @@ export class PromptListener {
119
106
  this.log("[device] device_profile.updated event received on prompts stream (ignored; registry handles it)");
120
107
  }
121
108
  }
122
- startPolling() {
123
- if (this.pollTimer || this.stopped)
124
- return;
125
- this.schedulePoll();
126
- }
127
- schedulePoll() {
128
- if (this.pollTimer)
129
- return;
130
- this.pollTimer = setTimeout(async () => {
131
- this.pollTimer = null;
132
- await this.poll();
133
- if (!this.wsConnected && !this.stopped) {
134
- this.schedulePoll();
135
- }
136
- }, POLL_INTERVAL);
137
- }
138
- stopPolling() {
139
- if (this.pollTimer) {
140
- clearTimeout(this.pollTimer);
141
- this.pollTimer = null;
142
- }
143
- }
144
- async poll() {
145
- try {
146
- const url = `${this.config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(this.config.credentialId)}/prompts?limit=5`;
147
- dbg(`[poll] GET ${url}`);
148
- const response = await fetch(url, {
149
- headers: { "Authorization": `Bearer ${this.config.controllerToken}` },
150
- signal: AbortSignal.timeout(10_000),
151
- });
152
- if (!response.ok) {
153
- dbg(`[poll] Response: ${response.status}`);
154
- return;
155
- }
156
- const data = (await response.json());
157
- dbg(`[poll] Got ${data.items?.length ?? 0} prompt(s)`);
158
- if (this.stopped)
159
- return; // Guard against late responses after stop()
160
- for (const prompt of data.items ?? []) {
161
- this.dispatchPrompt(prompt);
162
- }
163
- }
164
- catch (err) {
165
- dbg(`[poll] Error: ${err.message}`);
166
- }
167
- }
168
109
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- export declare const PACKAGE_VERSION = "0.32.5";
2
+ export declare const PACKAGE_VERSION = "0.33.0";
3
3
  export declare function createServer(): McpServer;
4
4
  export declare function startStdioServer(): Promise<void>;
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import { handlePair } from "./tools/pair.js";
8
8
  import { resolveTargetDevice } from "./tools/resolve.js";
9
9
  import { buildControlToolDescription, buildSystemToolDescription, buildScreenshotToolDescription, formatDeviceStatus, extractDynamic, } from "./core/device.js";
10
10
  import { registry } from "./core/registry.js";
11
- export const PACKAGE_VERSION = "0.32.5";
11
+ export const PACKAGE_VERSION = "0.33.0";
12
12
  function errorResult(message) {
13
13
  return { content: [{ type: "text", text: message }], isError: true };
14
14
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhihand/mcp",
3
- "version": "0.32.5",
3
+ "version": "0.33.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",