@zhihand/mcp 0.33.1 → 0.35.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,
@@ -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();
@@ -585,7 +605,7 @@ switch (command) {
585
605
  { id: 17, phase: "Text+Keys", label: "Key combo (select all)", kind: "hid", platformAware: "select_all" },
586
606
  { id: 18, phase: "Navigation", label: "Press Home", kind: "hid", params: { action: "home" } },
587
607
  { id: 19, phase: "Navigation", label: "Press Back", kind: "hid", params: { action: "back" } },
588
- { id: 20, phase: "Navigation", label: "Open WeChat", kind: "hid", platformAware: "open_wechat" },
608
+ { id: 20, phase: "Navigation", label: "Open Settings", kind: "hid", platformAware: "open_settings" },
589
609
  { id: 21, phase: "Clipboard", label: "Clipboard set", kind: "hid", platformAware: "clipboard_set" },
590
610
  { id: 22, phase: "System Nav", label: "Notification shade", kind: "system", params: { action: "notification" } },
591
611
  { id: 23, phase: "System Nav", label: "Recent apps", kind: "system", params: { action: "recent" } },
@@ -738,10 +758,10 @@ switch (command) {
738
758
 
739
759
  function resolvePlatformAwareParams(variant) {
740
760
  const platform = getDevicePlatform();
741
- if (variant === "open_wechat") {
761
+ if (variant === "open_settings") {
742
762
  return platform === "ios"
743
- ? { action: "open_app", bundleId: "com.tencent.xin" }
744
- : { action: "open_app", appPackage: "com.tencent.mm" };
763
+ ? { action: "open_app", bundleId: "com.apple.Preferences" }
764
+ : { action: "open_app", appPackage: "com.android.settings" };
745
765
  }
746
766
  if (variant === "clipboard_set") {
747
767
  return { action: "clipboard", text: `zhihand_test_${Date.now()}` };
@@ -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,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,36 @@ 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
+ // Notify running daemon to hot-reload identity (best-effort, daemon may not be running)
80
+ notifyDaemonReload();
81
+ return identity;
82
+ }
83
+ /** Best-effort POST to daemon's reload-identity endpoint. */
84
+ function notifyDaemonReload() {
85
+ const port = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || 18686;
86
+ fetch(`http://127.0.0.1:${port}/internal/reload-identity`, {
87
+ method: "POST",
88
+ signal: AbortSignal.timeout(3_000),
89
+ }).catch(() => {
90
+ // Daemon not running — that's fine, next start will pick up identity
91
+ });
62
92
  }
63
93
  /**
64
94
  * Poll pairing session until claimed or expired.
@@ -97,10 +127,9 @@ export async function executePairingNewUser(preferredLabel) {
97
127
  cleanupLegacyConfig();
98
128
  const userId = userResp.user_id;
99
129
  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;
130
+ // 2. Ensure persistent plugin identity (same EdgeID across all pairs)
131
+ const identity = await ensurePluginIdentity(endpoint);
132
+ const edgeId = identity.edge_id;
104
133
  // 3. Create pairing session
105
134
  const session = await createPairingSession(endpoint, userId, controllerToken, edgeId, 300);
106
135
  saveState({
@@ -186,10 +215,9 @@ export async function executePairingAddDevice(userId, preferredLabel) {
186
215
  if (!user)
187
216
  throw new Error(`User '${userId}' not found in config`);
188
217
  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;
218
+ // Ensure persistent plugin identity (same EdgeID across all pairs)
219
+ const identity = await ensurePluginIdentity(endpoint);
220
+ const edgeId = identity.edge_id;
193
221
  // Get existing credential IDs before pairing
194
222
  const existingCreds = await fetchUserCredentials(endpoint, userId, controllerToken);
195
223
  const existingIds = new Set(existingCreds.map((c) => c.credential_id));
@@ -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";
@@ -64,6 +64,8 @@ function onPromptReceived(config, prompt) {
64
64
  }
65
65
  }
66
66
  // ── Internal API ───────────────────────────────────────────
67
+ /** Set by startDaemon to allow identity hot-reload via /internal/reload-identity */
68
+ let reloadIdentityHandler = null;
67
69
  function handleInternalAPI(req, res) {
68
70
  const url = req.url ?? "";
69
71
  if (url === "/internal/backend" && req.method === "POST") {
@@ -145,6 +147,18 @@ function handleInternalAPI(req, res) {
145
147
  });
146
148
  return true;
147
149
  }
150
+ if (url === "/internal/reload-identity" && req.method === "POST") {
151
+ dbg(`[api] POST /internal/reload-identity`);
152
+ if (!reloadIdentityHandler) {
153
+ res.writeHead(503, { "Content-Type": "application/json" });
154
+ res.end(JSON.stringify({ error: "Daemon not ready" }));
155
+ return true;
156
+ }
157
+ const result = reloadIdentityHandler();
158
+ res.writeHead(result.ok ? 200 : 500, { "Content-Type": "application/json" });
159
+ res.end(JSON.stringify(result));
160
+ return true;
161
+ }
148
162
  if (url === "/internal/status" && req.method === "GET") {
149
163
  dbg(`[api] GET /internal/status`);
150
164
  const effectiveModel = activeBackend ? (activeModel ?? DEFAULT_MODELS[activeBackend]) : null;
@@ -359,25 +373,67 @@ export async function startDaemon(options) {
359
373
  httpServer.listen(port, "127.0.0.1", () => resolve());
360
374
  });
361
375
  writePid();
362
- // Start heartbeat
363
- startHeartbeatLoop(config, log);
364
- // Start prompt listener
365
- const promptListener = new PromptListener(config, (prompt) => onPromptReceived(config, prompt), log);
366
- promptListener.start();
376
+ // ── Identity hot-reload support ──────────────────────────
377
+ let currentHeartbeatTarget = null;
378
+ let promptListener = null;
379
+ /** (Re)start heartbeat + prompt listener with current identity. Stops previous if running. */
380
+ function activateIdentity(identity) {
381
+ // Stop previous if running
382
+ if (promptListener)
383
+ promptListener.stop();
384
+ stopHeartbeatLoop();
385
+ currentHeartbeatTarget = {
386
+ controlPlaneEndpoint: resolveDefaultEndpoint(),
387
+ edgeId: identity.edge_id,
388
+ pluginSecret: identity.plugin_secret,
389
+ };
390
+ startHeartbeatLoop(currentHeartbeatTarget, log);
391
+ promptListener = new PromptListener({
392
+ controlPlaneEndpoint: resolveDefaultEndpoint(),
393
+ edgeId: identity.edge_id,
394
+ pluginSecret: identity.plugin_secret,
395
+ }, (prompt) => onPromptReceived(config, prompt), log, (reason) => {
396
+ log(`[fatal] ${reason}`);
397
+ process.exit(1);
398
+ });
399
+ promptListener.start();
400
+ log(`[identity] Active: edge_id=${identity.edge_id}, stable_identity=${identity.stable_identity}`);
401
+ }
402
+ // Wire up the reload-identity API handler
403
+ reloadIdentityHandler = () => {
404
+ const identity = loadPluginIdentity();
405
+ if (!identity) {
406
+ return { ok: false, error: "No identity.json found" };
407
+ }
408
+ activateIdentity(identity);
409
+ return { ok: true, edge_id: identity.edge_id };
410
+ };
411
+ // Try loading identity at startup (non-fatal if missing)
412
+ const initialIdentity = loadPluginIdentity();
413
+ if (initialIdentity) {
414
+ activateIdentity(initialIdentity);
415
+ }
416
+ else {
417
+ log("[identity] No plugin identity found. Waiting for 'zhihand pair'...");
418
+ }
367
419
  log(`ZhiHand daemon started.`);
368
420
  log(` PID: ${process.pid}`);
369
421
  log(` MCP: http://127.0.0.1:${port}/mcp`);
370
422
  log(` Backend: ${activeBackend ?? "(none)"}`);
423
+ if (initialIdentity)
424
+ log(` Edge: ${initialIdentity.edge_id}`);
371
425
  log(` Device: ${config.credentialId}`);
372
426
  log(`Listening for prompts...`);
373
427
  // Graceful shutdown
374
428
  const shutdown = async () => {
375
429
  log("\nShutting down...");
376
- promptListener.stop();
430
+ if (promptListener)
431
+ promptListener.stop();
377
432
  stopHeartbeatLoop();
378
433
  clearInterval(sessionCleanupTimer);
379
434
  await killActiveChild();
380
- await sendBrainOffline(config);
435
+ if (currentHeartbeatTarget)
436
+ await sendBrainOffline(currentHeartbeatTarget);
381
437
  // Close all MCP sessions
382
438
  for (const session of mcpSessions.values()) {
383
439
  try {
@@ -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.1";
2
+ export declare const PACKAGE_VERSION = "0.35.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.1";
11
+ export const PACKAGE_VERSION = "0.35.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.1",
3
+ "version": "0.35.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",