@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 +36 -11
- package/dist/core/command.d.ts +0 -1
- package/dist/core/command.js +0 -15
- package/dist/core/ws.d.ts +2 -2
- package/dist/core/ws.js +2 -35
- package/dist/daemon/index.js +35 -0
- package/dist/daemon/prompt-listener.d.ts +0 -6
- package/dist/daemon/prompt-listener.js +1 -60
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
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.
|
|
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
|
|
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
|
|
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 (
|
|
719
|
-
const ackStatus =
|
|
720
|
-
const resultInfo =
|
|
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
|
|
825
|
-
|
|
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;
|
package/dist/core/command.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/command.js
CHANGED
|
@@ -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).
|
|
88
|
+
* registry). WS-only — no polling fallback.
|
|
89
89
|
*/
|
|
90
|
-
export declare function waitForCommandAck(
|
|
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).
|
|
281
|
+
* registry). WS-only — no polling fallback.
|
|
283
282
|
*/
|
|
284
|
-
export async function waitForCommandAck(
|
|
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
|
}
|
package/dist/daemon/index.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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
|
}
|