@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 +24 -4
- package/dist/core/config.d.ts +11 -0
- package/dist/core/config.js +31 -0
- package/dist/core/pair.d.ts +8 -1
- package/dist/core/pair.js +38 -10
- package/dist/daemon/heartbeat.d.ts +9 -4
- package/dist/daemon/heartbeat.js +12 -12
- package/dist/daemon/index.js +64 -8
- package/dist/daemon/prompt-listener.d.ts +8 -4
- package/dist/daemon/prompt-listener.js +19 -24
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
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
|
|
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 === "
|
|
761
|
+
if (variant === "open_settings") {
|
|
742
762
|
return platform === "ios"
|
|
743
|
-
? { action: "open_app", bundleId: "com.
|
|
744
|
-
: { action: "open_app", appPackage: "com.
|
|
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()}` };
|
package/dist/core/config.d.ts
CHANGED
|
@@ -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
|
package/dist/core/config.js
CHANGED
|
@@ -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() {
|
package/dist/core/pair.d.ts
CHANGED
|
@@ -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
|
|
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.
|
|
101
|
-
const
|
|
102
|
-
const
|
|
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
|
-
//
|
|
190
|
-
const
|
|
191
|
-
const
|
|
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(
|
|
10
|
-
export declare function sendBrainOffline(
|
|
11
|
-
export declare function startHeartbeatLoop(
|
|
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;
|
package/dist/daemon/heartbeat.js
CHANGED
|
@@ -9,23 +9,23 @@ let currentMeta = {};
|
|
|
9
9
|
export function setBrainMeta(meta) {
|
|
10
10
|
currentMeta = meta;
|
|
11
11
|
}
|
|
12
|
-
function buildUrl(
|
|
13
|
-
return `${
|
|
12
|
+
function buildUrl(target) {
|
|
13
|
+
return `${target.controlPlaneEndpoint}/v1/plugins/${encodeURIComponent(target.edgeId)}/brain-status`;
|
|
14
14
|
}
|
|
15
|
-
async function sendHeartbeat(
|
|
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(
|
|
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 ${
|
|
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(
|
|
42
|
-
return sendHeartbeat(
|
|
41
|
+
export async function sendBrainOnline(target) {
|
|
42
|
+
return sendHeartbeat(target, true);
|
|
43
43
|
}
|
|
44
|
-
export async function sendBrainOffline(
|
|
45
|
-
return sendHeartbeat(
|
|
44
|
+
export async function sendBrainOffline(target) {
|
|
45
|
+
return sendHeartbeat(target, false);
|
|
46
46
|
}
|
|
47
|
-
export function startHeartbeatLoop(
|
|
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(
|
|
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(
|
|
73
|
+
const recovered = await sendBrainOnline(target);
|
|
74
74
|
if (stopped)
|
|
75
75
|
return; // check after await
|
|
76
76
|
if (recovered) {
|
package/dist/daemon/index.js
CHANGED
|
@@ -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
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
430
|
+
if (promptListener)
|
|
431
|
+
promptListener.stop();
|
|
377
432
|
stopHeartbeatLoop();
|
|
378
433
|
clearInterval(sessionCleanupTimer);
|
|
379
434
|
await killActiveChild();
|
|
380
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
50
|
+
// Send auth frame with pluginSecret (server verifies before streaming)
|
|
50
51
|
this.rws.send(JSON.stringify({
|
|
51
52
|
type: "auth",
|
|
52
|
-
|
|
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
|
-
|
|
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 ?? "-"}
|
|
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.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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.
|
|
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.
|
|
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
|
}
|