@zhihand/mcp 0.33.0 → 0.34.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
@@ -21,6 +21,8 @@ import {
21
21
  DEFAULT_MODELS,
22
22
  resolveConfig,
23
23
  resolveDefaultEndpoint,
24
+ loadPluginIdentity,
25
+ clearPluginIdentity,
24
26
  } from "../dist/core/config.js";
25
27
  import {
26
28
  executePairingNewUser,
@@ -30,7 +32,7 @@ import { fetchUserCredentials } from "../dist/core/ws.js";
30
32
  import { configureMCP, displayName } from "../dist/cli/mcp-config.js";
31
33
 
32
34
  const DEFAULT_ENDPOINT = "https://api.zhihand.com";
33
- const VERSION = "0.33.0";
35
+ const VERSION = "0.33.1";
34
36
 
35
37
  const CLI_TOOL_MAP = {
36
38
  claude: "claudecode",
@@ -207,6 +209,24 @@ switch (command) {
207
209
  break;
208
210
  }
209
211
 
212
+ case "identity": {
213
+ const subCmd = positionals[1];
214
+ if (subCmd === "reset") {
215
+ clearPluginIdentity();
216
+ console.log("Plugin identity cleared. Next 'zhihand pair' will register a new identity.");
217
+ } else {
218
+ const id = loadPluginIdentity();
219
+ if (id) {
220
+ console.log(`Edge ID: ${id.edge_id}`);
221
+ console.log(`Stable Identity: ${id.stable_identity}`);
222
+ console.log(`Plugin Secret: ${id.plugin_secret.slice(0, 6)}...(redacted)`);
223
+ } else {
224
+ console.log("No plugin identity found. Run 'zhihand pair' to create one.");
225
+ }
226
+ }
227
+ break;
228
+ }
229
+
210
230
  case "mcp":
211
231
  case "serve": {
212
232
  await startStdioServer();
@@ -558,6 +578,9 @@ switch (command) {
558
578
  const { createControlCommand, createSystemCommand } = await import("../dist/core/command.js");
559
579
  const { fetchScreenshot, getSnapshotStaleThresholdMs } = await import("../dist/core/screenshot.js");
560
580
  const { fetchDeviceProfileOnce, extractStatic, computeCapabilities, formatDeviceStatus } = await import("../dist/core/device.js");
581
+ const { setDebugEnabled: setCoreDebug, setTimestampEnabled, log: coreLog } = await import("../dist/core/logger.js");
582
+ if (values.debug) setCoreDebug(true);
583
+ setTimestampEnabled(true);
561
584
 
562
585
  const KIND_CAPABILITY = {
563
586
  profile: "none", status: "none", screenshot: "screen", hid: "hid", system: "none",
@@ -633,17 +656,26 @@ switch (command) {
633
656
  const DAEMON_PORT = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || 18686;
634
657
  const DAEMON_BASE = `http://127.0.0.1:${DAEMON_PORT}`;
635
658
  let daemonOk = false;
659
+ let daemonStatus = null;
636
660
  try {
637
661
  const resp = await fetch(`${DAEMON_BASE}/internal/status`, { signal: AbortSignal.timeout(2000) });
638
662
  daemonOk = resp.ok;
663
+ if (resp.ok) daemonStatus = await resp.json();
639
664
  } catch { /* daemon not reachable */ }
640
665
  if (!daemonOk) {
641
666
  console.error("❌ Daemon is not running. Start it first: zhihand start");
642
667
  process.exit(1);
643
668
  }
644
669
 
670
+ const testDbg = values.debug
671
+ ? (msg) => coreLog.debug(`[test] ${msg}`)
672
+ : () => {};
673
+
645
674
  // Execute command via daemon's /internal/exec endpoint
646
675
  async function execViaDaemon(command, timeoutMs = 10_000) {
676
+ const action = command?.payload?.action ?? command?.type ?? "?";
677
+ testDbg(`exec action=${action} timeout=${timeoutMs}ms`);
678
+ const t0 = Date.now();
647
679
  const resp = await fetch(`${DAEMON_BASE}/internal/exec`, {
648
680
  method: "POST",
649
681
  headers: { "Content-Type": "application/json" },
@@ -652,9 +684,12 @@ switch (command) {
652
684
  });
653
685
  if (!resp.ok) {
654
686
  const body = await resp.text();
687
+ testDbg(`exec FAILED ${resp.status} ${Date.now() - t0}ms`);
655
688
  throw new Error(`Daemon exec failed: ${resp.status} ${body}`);
656
689
  }
657
- return resp.json();
690
+ const result = await resp.json();
691
+ testDbg(`exec done action=${action} acked=${result.acked} id=${result.id ?? "-"} ${Date.now() - t0}ms`);
692
+ return result;
658
693
  }
659
694
 
660
695
  const forceRun = values.force === true;
@@ -681,7 +716,11 @@ switch (command) {
681
716
 
682
717
  console.log("🔧 ZhiHand Device Test");
683
718
  console.log(` Device: ${testConfig.credentialId}`);
684
- console.log(` Endpoint: ${testConfig.controlPlaneEndpoint}\n`);
719
+ console.log(` Endpoint: ${testConfig.controlPlaneEndpoint}`);
720
+ if (daemonStatus) {
721
+ console.log(` Daemon: v${daemonStatus.version} pid=${daemonStatus.pid} backend=${daemonStatus.backend ?? "none"}`);
722
+ }
723
+ console.log("");
685
724
 
686
725
  // Pre-fetch device profile
687
726
  let currentRawAttrs = {};
@@ -34,9 +34,20 @@ export interface BackendConfig {
34
34
  model?: string | null;
35
35
  }
36
36
  export declare const DEFAULT_MODELS: Record<Exclude<BackendName, "openclaw">, string>;
37
+ export interface PluginIdentity {
38
+ stable_identity: string;
39
+ edge_id: string;
40
+ plugin_secret: string;
41
+ }
37
42
  export declare function resolveZhiHandDir(): string;
38
43
  export declare function ensureZhiHandDir(): void;
39
44
  export declare function getConfigPath(): string;
45
+ /** Read persisted Plugin identity. Returns null if missing or malformed. */
46
+ export declare function loadPluginIdentity(): PluginIdentity | null;
47
+ /** Atomically persist Plugin identity (write-to-tmp + rename, mode 0o600). */
48
+ export declare function savePluginIdentity(identity: PluginIdentity): void;
49
+ /** Delete identity.json (used by `zhihand identity reset`). */
50
+ export declare function clearPluginIdentity(): void;
40
51
  export declare function loadConfig(): ZhihandConfigV3;
41
52
  /**
42
53
  * Atomically write config: write to .tmp, then rename. Prevents corruption
@@ -11,6 +11,7 @@ const ZHIHAND_DIR = path.join(os.homedir(), ".zhihand");
11
11
  const CONFIG_PATH = path.join(ZHIHAND_DIR, "config.json");
12
12
  const STATE_PATH = path.join(ZHIHAND_DIR, "state.json");
13
13
  const BACKEND_PATH = path.join(ZHIHAND_DIR, "backend.json");
14
+ const IDENTITY_PATH = path.join(ZHIHAND_DIR, "identity.json");
14
15
  export function resolveZhiHandDir() {
15
16
  return ZHIHAND_DIR;
16
17
  }
@@ -20,6 +21,36 @@ export function ensureZhiHandDir() {
20
21
  export function getConfigPath() {
21
22
  return CONFIG_PATH;
22
23
  }
24
+ // ── Plugin identity I/O ──────────────────────────────────
25
+ /** Read persisted Plugin identity. Returns null if missing or malformed. */
26
+ export function loadPluginIdentity() {
27
+ try {
28
+ const raw = JSON.parse(fs.readFileSync(IDENTITY_PATH, "utf-8"));
29
+ if (raw.stable_identity && raw.edge_id && raw.plugin_secret) {
30
+ return raw;
31
+ }
32
+ return null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /** Atomically persist Plugin identity (write-to-tmp + rename, mode 0o600). */
39
+ export function savePluginIdentity(identity) {
40
+ ensureZhiHandDir();
41
+ const tmp = IDENTITY_PATH + ".tmp";
42
+ fs.writeFileSync(tmp, JSON.stringify(identity, null, 2), { mode: 0o600 });
43
+ fs.renameSync(tmp, IDENTITY_PATH);
44
+ }
45
+ /** Delete identity.json (used by `zhihand identity reset`). */
46
+ export function clearPluginIdentity() {
47
+ try {
48
+ fs.unlinkSync(IDENTITY_PATH);
49
+ }
50
+ catch {
51
+ // already gone
52
+ }
53
+ }
23
54
  // ── v3 config I/O ──────────────────────────────────────────
24
55
  let legacyWarningPrinted = false;
25
56
  function emptyConfig() {
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * Unified logger — all log output goes to stderr so stdout stays clean
3
- * for MCP JSON-RPC. Replaces ad-hoc process.stderr.write and dbg() calls
4
- * in core/ and tools/ code.
3
+ * for MCP JSON-RPC.
5
4
  *
6
- * The daemon has its own stdout-based log() in daemon/index.ts — that is
7
- * intentional (it writes to daemon.log). The daemon's debug logger
8
- * (daemon/logger.ts) remains for daemon-specific verbose output.
5
+ * All modules (core/, daemon/, tools/) should use this logger.
6
+ * The daemon's dbg() in daemon/logger.ts delegates here for the debug flag.
9
7
  */
8
+ /**
9
+ * Redact sensitive tokens from log messages.
10
+ * Replaces Bearer tokens and controller_token values with <REDACTED>.
11
+ */
12
+ export declare function redact(msg: string): string;
10
13
  export declare const log: {
11
14
  info: (...args: unknown[]) => void;
12
15
  warn: (...args: unknown[]) => void;
@@ -15,3 +18,5 @@ export declare const log: {
15
18
  };
16
19
  export declare function setDebugEnabled(v: boolean): void;
17
20
  export declare function isDebugEnabled(): boolean;
21
+ /** Enable timestamps in log output (for daemon / CLI long-running processes). */
22
+ export declare function setTimestampEnabled(v: boolean): void;
@@ -1,26 +1,50 @@
1
1
  /**
2
2
  * Unified logger — all log output goes to stderr so stdout stays clean
3
- * for MCP JSON-RPC. Replaces ad-hoc process.stderr.write and dbg() calls
4
- * in core/ and tools/ code.
3
+ * for MCP JSON-RPC.
5
4
  *
6
- * The daemon has its own stdout-based log() in daemon/index.ts — that is
7
- * intentional (it writes to daemon.log). The daemon's debug logger
8
- * (daemon/logger.ts) remains for daemon-specific verbose output.
5
+ * All modules (core/, daemon/, tools/) should use this logger.
6
+ * The daemon's dbg() in daemon/logger.ts delegates here for the debug flag.
9
7
  */
10
8
  let debugEnabled = false;
9
+ let timestampEnabled = false;
10
+ // ── Token redaction ──────────────────────────────────────
11
+ const REDACT_PATTERNS = [
12
+ // Bearer tokens in headers / JSON
13
+ /(Bearer\s+)[^\s"',}]+/gi,
14
+ // controller_token in JSON / key=value
15
+ /(controller_token["']?\s*[:=]\s*["']?)[^\s"',}]+/gi,
16
+ ];
17
+ /**
18
+ * Redact sensitive tokens from log messages.
19
+ * Replaces Bearer tokens and controller_token values with <REDACTED>.
20
+ */
21
+ export function redact(msg) {
22
+ let result = msg;
23
+ for (const pattern of REDACT_PATTERNS) {
24
+ result = result.replace(pattern, "$1<REDACTED>");
25
+ }
26
+ return result;
27
+ }
28
+ // ── Logger ───────────────────────────────────────────────
29
+ function prefix(level) {
30
+ if (timestampEnabled) {
31
+ return `[${new Date().toLocaleTimeString()}] [${level}] `;
32
+ }
33
+ return `[${level.padEnd(5)}] `;
34
+ }
11
35
  export const log = {
12
36
  info: (...args) => {
13
- process.stderr.write(`[info] ${args.map(String).join(" ")}\n`);
37
+ process.stderr.write(`${prefix("info")}${redact(args.map(String).join(" "))}\n`);
14
38
  },
15
39
  warn: (...args) => {
16
- process.stderr.write(`[warn] ${args.map(String).join(" ")}\n`);
40
+ process.stderr.write(`${prefix("warn")}${redact(args.map(String).join(" "))}\n`);
17
41
  },
18
42
  error: (...args) => {
19
- process.stderr.write(`[error] ${args.map(String).join(" ")}\n`);
43
+ process.stderr.write(`${prefix("error")}${redact(args.map(String).join(" "))}\n`);
20
44
  },
21
45
  debug: (...args) => {
22
46
  if (debugEnabled) {
23
- process.stderr.write(`[debug] ${args.map(String).join(" ")}\n`);
47
+ process.stderr.write(`${prefix("debug")}${redact(args.map(String).join(" "))}\n`);
24
48
  }
25
49
  },
26
50
  };
@@ -30,3 +54,7 @@ export function setDebugEnabled(v) {
30
54
  export function isDebugEnabled() {
31
55
  return debugEnabled;
32
56
  }
57
+ /** Enable timestamps in log output (for daemon / CLI long-running processes). */
58
+ export function setTimestampEnabled(v) {
59
+ timestampEnabled = v;
60
+ }
@@ -1,4 +1,4 @@
1
- import type { DeviceRecord, UserRecord } from "./config.ts";
1
+ import type { DeviceRecord, UserRecord, PluginIdentity } from "./config.ts";
2
2
  export interface PairingSession {
3
3
  session_id: string;
4
4
  pair_url: string;
@@ -30,7 +30,14 @@ export declare function registerPlugin(endpoint: string, options: {
30
30
  adapterKind?: string;
31
31
  }): Promise<{
32
32
  edge_id: string;
33
+ plugin_secret: string;
33
34
  }>;
35
+ /**
36
+ * Ensure a persistent Plugin identity exists. Reuses existing identity
37
+ * if present; otherwise registers a new plugin and persists the result.
38
+ * All pair operations share the same identity → same EdgeID.
39
+ */
40
+ export declare function ensurePluginIdentity(endpoint: string): Promise<PluginIdentity>;
34
41
  /**
35
42
  * Poll pairing session until claimed or expired.
36
43
  */
package/dist/core/pair.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import QRCode from "qrcode";
2
- import { addUser, addDeviceToUser, ensureZhiHandDir, saveState, resolveDefaultEndpoint, getUserRecord, cleanupLegacyConfig, } from "./config.js";
2
+ import os from "node:os";
3
+ import { addUser, addDeviceToUser, ensureZhiHandDir, saveState, resolveDefaultEndpoint, getUserRecord, cleanupLegacyConfig, loadPluginIdentity, savePluginIdentity, } from "./config.js";
3
4
  import { fetchDeviceProfileOnce, extractStatic } from "./device.js";
4
5
  import { fetchUserCredentials } from "./ws.js";
5
6
  // ── Server API helpers ─────────────────────────────────────
@@ -58,7 +59,24 @@ export async function registerPlugin(endpoint, options) {
58
59
  throw new Error(`Register plugin failed: ${response.status} ${await response.text()}`);
59
60
  }
60
61
  const payload = (await response.json());
61
- return { edge_id: payload.plugin.edge_id };
62
+ return { edge_id: payload.plugin.edge_id, plugin_secret: payload.plugin.plugin_secret };
63
+ }
64
+ /**
65
+ * Ensure a persistent Plugin identity exists. Reuses existing identity
66
+ * if present; otherwise registers a new plugin and persists the result.
67
+ * All pair operations share the same identity → same EdgeID.
68
+ */
69
+ export async function ensurePluginIdentity(endpoint) {
70
+ const existing = loadPluginIdentity();
71
+ const stableId = existing?.stable_identity ?? `mcp-${os.hostname()}-${Date.now().toString(36)}`;
72
+ const plugin = await registerPlugin(endpoint, { stableIdentity: stableId });
73
+ const identity = {
74
+ stable_identity: stableId,
75
+ edge_id: plugin.edge_id,
76
+ plugin_secret: plugin.plugin_secret,
77
+ };
78
+ savePluginIdentity(identity);
79
+ return identity;
62
80
  }
63
81
  /**
64
82
  * Poll pairing session until claimed or expired.
@@ -97,10 +115,9 @@ export async function executePairingNewUser(preferredLabel) {
97
115
  cleanupLegacyConfig();
98
116
  const userId = userResp.user_id;
99
117
  const controllerToken = userResp.controller_token;
100
- // 2. Register plugin (get edge_id)
101
- const stableIdentity = `mcp-${Date.now().toString(36)}`;
102
- const plugin = await registerPlugin(endpoint, { stableIdentity });
103
- const edgeId = plugin.edge_id;
118
+ // 2. Ensure persistent plugin identity (same EdgeID across all pairs)
119
+ const identity = await ensurePluginIdentity(endpoint);
120
+ const edgeId = identity.edge_id;
104
121
  // 3. Create pairing session
105
122
  const session = await createPairingSession(endpoint, userId, controllerToken, edgeId, 300);
106
123
  saveState({
@@ -186,10 +203,9 @@ export async function executePairingAddDevice(userId, preferredLabel) {
186
203
  if (!user)
187
204
  throw new Error(`User '${userId}' not found in config`);
188
205
  const controllerToken = user.controller_token;
189
- // Register plugin (get edge_id)
190
- const stableIdentity = `mcp-${Date.now().toString(36)}`;
191
- const plugin = await registerPlugin(endpoint, { stableIdentity });
192
- const edgeId = plugin.edge_id;
206
+ // Ensure persistent plugin identity (same EdgeID across all pairs)
207
+ const identity = await ensurePluginIdentity(endpoint);
208
+ const edgeId = identity.edge_id;
193
209
  // Get existing credential IDs before pairing
194
210
  const existingCreds = await fetchUserCredentials(endpoint, userId, controllerToken);
195
211
  const existingIds = new Set(existingCreds.map((c) => c.credential_id));
package/dist/core/ws.js CHANGED
@@ -283,12 +283,15 @@ export async function fetchUserCredentials(endpoint, userId, controllerToken, on
283
283
  export async function waitForCommandAck(_config, options) {
284
284
  const timeoutMs = options.timeoutMs ?? 15_000;
285
285
  log.debug(`[ws-cmd] Waiting for ACK: commandId=${options.commandId}, timeout=${timeoutMs}ms`);
286
+ const t0 = Date.now();
286
287
  return new Promise((resolve, reject) => {
287
288
  const timeout = setTimeout(() => {
289
+ log.debug(`[ws-cmd] ACK timeout: commandId=${options.commandId} after ${Date.now() - t0}ms`);
288
290
  cleanup();
289
291
  resolve({ acked: false });
290
292
  }, timeoutMs);
291
293
  const unsubscribe = subscribeToCommandAck(options.commandId, (ackedCommand) => {
294
+ log.debug(`[ws-cmd] ACK received: commandId=${options.commandId} status=${ackedCommand.ack_status ?? "ok"} ${Date.now() - t0}ms`);
292
295
  cleanup();
293
296
  resolve({ acked: true, command: ackedCommand });
294
297
  });
@@ -1,12 +1,17 @@
1
- import type { ZhiHandRuntimeConfig } from "../core/config.ts";
2
1
  /** Brain metadata included in every heartbeat, so the app always knows the current backend/model. */
3
2
  export interface BrainMeta {
4
3
  backend?: string | null;
5
4
  model?: string | null;
6
5
  }
6
+ /** Plugin-level heartbeat target. Uses edgeId + pluginSecret instead of per-credential auth. */
7
+ export interface HeartbeatTarget {
8
+ controlPlaneEndpoint: string;
9
+ edgeId: string;
10
+ pluginSecret: string;
11
+ }
7
12
  /** Update the backend/model metadata that will be sent with the next heartbeat. */
8
13
  export declare function setBrainMeta(meta: BrainMeta): void;
9
- export declare function sendBrainOnline(config: ZhiHandRuntimeConfig): Promise<boolean>;
10
- export declare function sendBrainOffline(config: ZhiHandRuntimeConfig): Promise<boolean>;
11
- export declare function startHeartbeatLoop(config: ZhiHandRuntimeConfig, log: (msg: string) => void): void;
14
+ export declare function sendBrainOnline(target: HeartbeatTarget): Promise<boolean>;
15
+ export declare function sendBrainOffline(target: HeartbeatTarget): Promise<boolean>;
16
+ export declare function startHeartbeatLoop(target: HeartbeatTarget, log: (msg: string) => void): void;
12
17
  export declare function stopHeartbeatLoop(): void;
@@ -9,23 +9,23 @@ let currentMeta = {};
9
9
  export function setBrainMeta(meta) {
10
10
  currentMeta = meta;
11
11
  }
12
- function buildUrl(config) {
13
- return `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/brain-status`;
12
+ function buildUrl(target) {
13
+ return `${target.controlPlaneEndpoint}/v1/plugins/${encodeURIComponent(target.edgeId)}/brain-status`;
14
14
  }
15
- async function sendHeartbeat(config, online) {
15
+ async function sendHeartbeat(target, online) {
16
16
  try {
17
17
  const body = { plugin_online: online };
18
18
  if (currentMeta.backend)
19
19
  body.backend = currentMeta.backend;
20
20
  if (currentMeta.model)
21
21
  body.model = currentMeta.model;
22
- const url = buildUrl(config);
22
+ const url = buildUrl(target);
23
23
  dbg(`[heartbeat] POST ${url} body=${JSON.stringify(body)}`);
24
24
  const response = await fetch(url, {
25
25
  method: "POST",
26
26
  headers: {
27
27
  "Content-Type": "application/json",
28
- "Authorization": `Bearer ${config.controllerToken}`,
28
+ "Authorization": `Bearer ${target.pluginSecret}`,
29
29
  },
30
30
  body: JSON.stringify(body),
31
31
  signal: AbortSignal.timeout(10_000),
@@ -38,20 +38,20 @@ async function sendHeartbeat(config, online) {
38
38
  return false;
39
39
  }
40
40
  }
41
- export async function sendBrainOnline(config) {
42
- return sendHeartbeat(config, true);
41
+ export async function sendBrainOnline(target) {
42
+ return sendHeartbeat(target, true);
43
43
  }
44
- export async function sendBrainOffline(config) {
45
- return sendHeartbeat(config, false);
44
+ export async function sendBrainOffline(target) {
45
+ return sendHeartbeat(target, false);
46
46
  }
47
- export function startHeartbeatLoop(config, log) {
47
+ export function startHeartbeatLoop(target, log) {
48
48
  let retrying = false;
49
49
  stopped = false;
50
50
  async function beat() {
51
51
  // Skip main-timer beats while retry loop is active (avoids overlap & flapping)
52
52
  if (retrying || stopped)
53
53
  return;
54
- const ok = await sendBrainOnline(config);
54
+ const ok = await sendBrainOnline(target);
55
55
  if (stopped)
56
56
  return; // check after await — stopHeartbeatLoop() may have been called
57
57
  if (ok) {
@@ -70,7 +70,7 @@ export function startHeartbeatLoop(config, log) {
70
70
  retryTimer = setTimeout(async () => {
71
71
  if (!retrying || stopped)
72
72
  return;
73
- const recovered = await sendBrainOnline(config);
73
+ const recovered = await sendBrainOnline(target);
74
74
  if (stopped)
75
75
  return; // check after await
76
76
  if (recovered) {
@@ -5,7 +5,7 @@ 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, DEFAULT_MODELS, } from "../core/config.js";
8
+ import { resolveConfig, loadBackendConfig, saveBackendConfig, resolveZhiHandDir, resolveDefaultEndpoint, ensureZhiHandDir, loadPluginIdentity, DEFAULT_MODELS, } from "../core/config.js";
9
9
  import { PACKAGE_VERSION } from "../index.js";
10
10
  import { startHeartbeatLoop, stopHeartbeatLoop, sendBrainOffline, setBrainMeta } from "./heartbeat.js";
11
11
  import { PromptListener } from "./prompt-listener.js";
@@ -21,9 +21,9 @@ let activeBackend = null;
21
21
  let activeModel = null; // user-selected model alias, null = use default
22
22
  let isProcessing = false;
23
23
  const promptQueue = [];
24
+ import { log as coreLog, setTimestampEnabled } from "../core/logger.js";
24
25
  function log(msg) {
25
- const ts = new Date().toLocaleTimeString();
26
- process.stdout.write(`[${ts}] ${msg}\n`);
26
+ coreLog.info(msg);
27
27
  }
28
28
  // ── Prompt Processing ──────────────────────────────────────
29
29
  async function processPrompt(config, prompt) {
@@ -106,7 +106,6 @@ function handleInternalAPI(req, res) {
106
106
  }
107
107
  // Execute command via daemon's WS (used by zhihand test)
108
108
  if (url === "/internal/exec" && req.method === "POST") {
109
- dbg(`[api] POST /internal/exec`);
110
109
  let body = "";
111
110
  const MAX_BODY = 10 * 1024;
112
111
  req.on("data", (chunk) => {
@@ -118,19 +117,28 @@ function handleInternalAPI(req, res) {
118
117
  }
119
118
  });
120
119
  req.on("end", async () => {
120
+ const t0 = Date.now();
121
121
  try {
122
122
  const { command, credentialId, timeoutMs } = JSON.parse(body);
123
+ const cmdType = command.type ?? "unknown";
124
+ const cmdAction = command.payload?.action ?? "-";
125
+ dbg(`[api] POST /internal/exec cred=${credentialId} type=${cmdType} action=${cmdAction} timeout=${timeoutMs ?? 10_000}ms`);
123
126
  const cfg = resolveConfig(credentialId);
124
127
  const effectiveTimeout = timeoutMs ?? 10_000;
125
128
  const queued = await enqueueCommand(cfg, command);
129
+ dbg(`[api] /internal/exec enqueued id=${queued.id}`);
126
130
  const ack = await waitForCommandAck(cfg, {
127
131
  commandId: queued.id,
128
132
  timeoutMs: effectiveTimeout,
129
133
  });
134
+ const ms = Date.now() - t0;
135
+ const ackStatus = ack.acked ? (ack.command?.ack_status ?? "ok") : "timeout";
136
+ dbg(`[api] /internal/exec done id=${queued.id} acked=${ack.acked} status=${ackStatus} ${ms}ms`);
130
137
  res.writeHead(200, { "Content-Type": "application/json" });
131
138
  res.end(JSON.stringify({ id: queued.id, ...ack }));
132
139
  }
133
140
  catch (err) {
141
+ dbg(`[api] /internal/exec error: ${err.message} ${Date.now() - t0}ms`);
134
142
  res.writeHead(500, { "Content-Type": "application/json" });
135
143
  res.end(JSON.stringify({ error: err.message }));
136
144
  }
@@ -190,6 +198,7 @@ export function isAlreadyRunning() {
190
198
  }
191
199
  // ── Main Daemon Entry ──────���───────────────────────────────
192
200
  export async function startDaemon(options) {
201
+ setTimestampEnabled(true);
193
202
  if (options?.debug)
194
203
  setDebugEnabled(true);
195
204
  const port = options?.port ?? (parseInt(process.env.ZHIHAND_PORT ?? "", 10) || DEFAULT_PORT);
@@ -350,15 +359,35 @@ export async function startDaemon(options) {
350
359
  httpServer.listen(port, "127.0.0.1", () => resolve());
351
360
  });
352
361
  writePid();
353
- // Start heartbeat
354
- startHeartbeatLoop(config, log);
355
- // Start prompt listener
356
- const promptListener = new PromptListener(config, (prompt) => onPromptReceived(config, prompt), log);
362
+ // Load Plugin identity for edge-level heartbeat + prompt WS
363
+ const identity = loadPluginIdentity();
364
+ if (!identity) {
365
+ log("[identity] No plugin identity found. Run 'zhihand pair' first.");
366
+ process.exit(1);
367
+ }
368
+ log(`[identity] Loaded: edge_id=${identity.edge_id}, stable_identity=${identity.stable_identity}`);
369
+ const heartbeatTarget = {
370
+ controlPlaneEndpoint: resolveDefaultEndpoint(),
371
+ edgeId: identity.edge_id,
372
+ pluginSecret: identity.plugin_secret,
373
+ };
374
+ // Start heartbeat (edge-level, pluginSecret auth)
375
+ startHeartbeatLoop(heartbeatTarget, log);
376
+ // Start prompt listener (edge-level single WS)
377
+ const promptListener = new PromptListener({
378
+ controlPlaneEndpoint: resolveDefaultEndpoint(),
379
+ edgeId: identity.edge_id,
380
+ pluginSecret: identity.plugin_secret,
381
+ }, (prompt) => onPromptReceived(config, prompt), log, (reason) => {
382
+ log(`[fatal] ${reason}`);
383
+ process.exit(1);
384
+ });
357
385
  promptListener.start();
358
386
  log(`ZhiHand daemon started.`);
359
387
  log(` PID: ${process.pid}`);
360
388
  log(` MCP: http://127.0.0.1:${port}/mcp`);
361
389
  log(` Backend: ${activeBackend ?? "(none)"}`);
390
+ log(` Edge: ${identity.edge_id}`);
362
391
  log(` Device: ${config.credentialId}`);
363
392
  log(`Listening for prompts...`);
364
393
  // Graceful shutdown
@@ -368,7 +397,7 @@ export async function startDaemon(options) {
368
397
  stopHeartbeatLoop();
369
398
  clearInterval(sessionCleanupTimer);
370
399
  await killActiveChild();
371
- await sendBrainOffline(config);
400
+ await sendBrainOffline(heartbeatTarget);
372
401
  // Close all MCP sessions
373
402
  for (const session of mcpSessions.values()) {
374
403
  try {
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Debug logger for ZhiHand daemon.
3
3
  *
4
- * Enable with `zhihand start --debug` to see detailed request/response,
5
- * CLI spawn args, stdin/stdout data, SSE events, and timing information.
4
+ * Delegates to core/logger.ts for the debug flag, redaction, and output.
5
+ * All output goes to stderr to keep stdout clean for MCP JSON-RPC.
6
+ *
7
+ * Enable with `zhihand start --debug`.
6
8
  */
7
9
  export declare function setDebugEnabled(enabled: boolean): void;
8
10
  export declare function isDebugEnabled(): boolean;
9
- /** Debug log — only outputs when --debug is active. */
11
+ /** Debug log — only outputs when --debug is active. Writes to stderr with redaction. */
10
12
  export declare function dbg(msg: string): void;
@@ -1,22 +1,21 @@
1
1
  /**
2
2
  * Debug logger for ZhiHand daemon.
3
3
  *
4
- * Enable with `zhihand start --debug` to see detailed request/response,
5
- * CLI spawn args, stdin/stdout data, SSE events, and timing information.
4
+ * Delegates to core/logger.ts for the debug flag, redaction, and output.
5
+ * All output goes to stderr to keep stdout clean for MCP JSON-RPC.
6
+ *
7
+ * Enable with `zhihand start --debug`.
6
8
  */
7
- let debugEnabled = false;
9
+ import { log, setDebugEnabled as coreSetDebug, isDebugEnabled as coreIsDebug, } from "../core/logger.js";
8
10
  export function setDebugEnabled(enabled) {
9
- debugEnabled = enabled;
11
+ coreSetDebug(enabled);
10
12
  }
11
13
  export function isDebugEnabled() {
12
- return debugEnabled;
13
- }
14
- function ts() {
15
- return new Date().toLocaleTimeString();
14
+ return coreIsDebug();
16
15
  }
17
- /** Debug log — only outputs when --debug is active. */
16
+ /** Debug log — only outputs when --debug is active. Writes to stderr with redaction. */
18
17
  export function dbg(msg) {
19
- if (!debugEnabled)
18
+ if (!coreIsDebug())
20
19
  return;
21
- process.stdout.write(`[${ts()}] [DEBUG] ${msg}\n`);
20
+ log.debug(msg);
22
21
  }
@@ -1,5 +1,3 @@
1
- import type { ZhiHandRuntimeConfig } from "../core/config.ts";
2
- type ZhiHandConfig = ZhiHandRuntimeConfig;
3
1
  export interface MobilePrompt {
4
2
  id: string;
5
3
  credential_id: string;
@@ -11,14 +9,21 @@ export interface MobilePrompt {
11
9
  attachments?: unknown[];
12
10
  }
13
11
  export type PromptHandler = (prompt: MobilePrompt) => void;
12
+ /** Edge-level prompt listener config. Uses pluginSecret instead of per-user controllerToken. */
13
+ export interface PromptListenerConfig {
14
+ controlPlaneEndpoint: string;
15
+ edgeId: string;
16
+ pluginSecret: string;
17
+ }
14
18
  export declare class PromptListener {
15
19
  private config;
16
20
  private handler;
17
21
  private log;
22
+ private onFatalError?;
18
23
  private processedIds;
19
24
  private rws;
20
25
  private stopped;
21
- constructor(config: ZhiHandConfig, handler: PromptHandler, log: (msg: string) => void);
26
+ constructor(config: PromptListenerConfig, handler: PromptHandler, log: (msg: string) => void, onFatalError?: (reason: string) => void);
22
27
  start(): void;
23
28
  stop(): void;
24
29
  private dispatchPrompt;
@@ -26,4 +31,3 @@ export declare class PromptListener {
26
31
  private handleWSMessage;
27
32
  private handleEvent;
28
33
  }
29
- export {};
@@ -4,13 +4,15 @@ export class PromptListener {
4
4
  config;
5
5
  handler;
6
6
  log;
7
+ onFatalError;
7
8
  processedIds = new Set();
8
9
  rws = null;
9
10
  stopped = false;
10
- constructor(config, handler, log) {
11
+ constructor(config, handler, log, onFatalError) {
11
12
  this.config = config;
12
13
  this.handler = handler;
13
14
  this.log = log;
15
+ this.onFatalError = onFatalError;
14
16
  }
15
17
  start() {
16
18
  this.stopped = false;
@@ -27,7 +29,7 @@ export class PromptListener {
27
29
  return;
28
30
  }
29
31
  this.processedIds.add(prompt.id);
30
- dbg(`[prompt] Dispatching prompt: id=${prompt.id}, status=${prompt.status}, text="${prompt.text.slice(0, 100)}${prompt.text.length > 100 ? "..." : ""}"`);
32
+ dbg(`[prompt] Dispatching prompt: id=${prompt.id}, cred=${prompt.credential_id}, status=${prompt.status}, text="${prompt.text.slice(0, 100)}${prompt.text.length > 100 ? "..." : ""}"`);
31
33
  // Prevent unbounded growth
32
34
  if (this.processedIds.size > 500) {
33
35
  const arr = [...this.processedIds];
@@ -38,21 +40,18 @@ export class PromptListener {
38
40
  connectWS() {
39
41
  if (this.stopped)
40
42
  return;
41
- const wsUrl = `${this.config.controlPlaneEndpoint.replace(/^http/, "ws")}/v1/credentials/${encodeURIComponent(this.config.credentialId)}/ws?topic=prompts`;
43
+ // Edge-level WS: single connection for all credentials under this edge
44
+ const wsUrl = `${this.config.controlPlaneEndpoint.replace(/^http/, "ws")}/v1/plugins/${encodeURIComponent(this.config.edgeId)}/ws?topic=prompts`;
42
45
  dbg(`[ws] Connecting to ${wsUrl}`);
43
46
  this.rws = new ReconnectingWebSocket({
44
47
  url: wsUrl,
45
- headers: {
46
- "Authorization": `Bearer ${this.config.controllerToken}`,
47
- },
48
+ headers: {},
48
49
  onOpen: () => {
49
- // Send auth message as first frame (required by server).
50
+ // Send auth frame with pluginSecret (server verifies before streaming)
50
51
  this.rws.send(JSON.stringify({
51
52
  type: "auth",
52
- controller_token: this.config.controllerToken,
53
- topics: ["prompts"],
53
+ plugin_secret: this.config.pluginSecret,
54
54
  }));
55
- // onConnected deferred until auth_ok is received (see handleWSMessage)
56
55
  },
57
56
  onClose: (_code, _reason) => {
58
57
  dbg("[ws] Disconnected. ReconnectingWebSocket will retry.");
@@ -68,42 +67,38 @@ export class PromptListener {
68
67
  }
69
68
  handleWSMessage(data) {
70
69
  const msg = data;
71
- // Auth responses
72
70
  if (msg.type === "auth_ok") {
73
- this.log("[ws] Connected to prompt stream.");
71
+ this.log("[ws] Connected to Edge prompt stream.");
74
72
  return;
75
73
  }
76
74
  if (msg.type === "auth_error") {
77
- this.log(`[ws] Auth failed: ${msg.error}`);
75
+ const reason = `Plugin auth failed: ${msg.error}. Run 'zhihand pair' to re-register.`;
76
+ this.log(`[ws] ${reason}`);
78
77
  this.rws?.stop();
79
78
  this.rws = null;
79
+ this.onFatalError?.(reason);
80
80
  return;
81
81
  }
82
- // Application-level ping (if server sends these alongside protocol pings)
83
82
  if (msg.type === "ping") {
84
83
  this.rws?.send(JSON.stringify({ type: "pong" }));
85
84
  return;
86
85
  }
87
- // Event dispatch
88
86
  if (msg.type === "event" || msg.kind) {
89
87
  this.handleEvent(msg);
90
88
  }
91
89
  }
92
90
  handleEvent(event) {
93
91
  const kind = event.kind;
94
- dbg(`[ws] Event: kind=${kind}, prompt=${event.prompt?.id ?? "-"}, prompts=${event.prompts?.length ?? 0}`);
92
+ dbg(`[ws] Event: kind=${kind}, prompt=${event.prompt?.id ?? "-"}`);
95
93
  if (kind === "prompt.queued" && event.prompt) {
96
94
  this.dispatchPrompt(event.prompt);
97
95
  }
98
- else if (kind === "prompt.snapshot" && event.prompts) {
99
- for (const p of event.prompts) {
100
- if (p.status === "pending" || p.status === "processing") {
101
- this.dispatchPrompt(p);
102
- }
96
+ else if (kind === "prompt.snapshot" && event.prompt) {
97
+ // Edge WS sends individual snapshot events (one per pending prompt)
98
+ const p = event.prompt;
99
+ if (p.status === "pending" || p.status === "processing") {
100
+ this.dispatchPrompt(p);
103
101
  }
104
102
  }
105
- else if (kind === "device_profile.updated") {
106
- this.log("[device] device_profile.updated event received on prompts stream (ignored; registry handles it)");
107
- }
108
103
  }
109
104
  }
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.33.0";
2
+ export declare const PACKAGE_VERSION = "0.34.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.33.0";
11
+ export const PACKAGE_VERSION = "0.34.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.33.0",
3
+ "version": "0.34.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",