@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 +42 -3
- package/dist/core/config.d.ts +11 -0
- package/dist/core/config.js +31 -0
- package/dist/core/logger.d.ts +10 -5
- package/dist/core/logger.js +37 -9
- package/dist/core/pair.d.ts +8 -1
- package/dist/core/pair.js +26 -10
- package/dist/core/ws.js +3 -0
- package/dist/daemon/heartbeat.d.ts +9 -4
- package/dist/daemon/heartbeat.js +12 -12
- package/dist/daemon/index.js +38 -9
- package/dist/daemon/logger.d.ts +5 -3
- package/dist/daemon/logger.js +10 -11
- 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,
|
|
@@ -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.
|
|
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
|
-
|
|
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}
|
|
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 = {};
|
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/logger.d.ts
CHANGED
|
@@ -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.
|
|
4
|
-
* in core/ and tools/ code.
|
|
3
|
+
* for MCP JSON-RPC.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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;
|
package/dist/core/logger.js
CHANGED
|
@@ -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.
|
|
4
|
-
* in core/ and tools/ code.
|
|
3
|
+
* for MCP JSON-RPC.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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(
|
|
37
|
+
process.stderr.write(`${prefix("info")}${redact(args.map(String).join(" "))}\n`);
|
|
14
38
|
},
|
|
15
39
|
warn: (...args) => {
|
|
16
|
-
process.stderr.write(
|
|
40
|
+
process.stderr.write(`${prefix("warn")}${redact(args.map(String).join(" "))}\n`);
|
|
17
41
|
},
|
|
18
42
|
error: (...args) => {
|
|
19
|
-
process.stderr.write(
|
|
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(
|
|
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
|
+
}
|
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,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.
|
|
101
|
-
const
|
|
102
|
-
const
|
|
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
|
-
//
|
|
190
|
-
const
|
|
191
|
-
const
|
|
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(
|
|
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";
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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(
|
|
400
|
+
await sendBrainOffline(heartbeatTarget);
|
|
372
401
|
// Close all MCP sessions
|
|
373
402
|
for (const session of mcpSessions.values()) {
|
|
374
403
|
try {
|
package/dist/daemon/logger.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Debug logger for ZhiHand daemon.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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;
|
package/dist/daemon/logger.js
CHANGED
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Debug logger for ZhiHand daemon.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
+
import { log, setDebugEnabled as coreSetDebug, isDebugEnabled as coreIsDebug, } from "../core/logger.js";
|
|
8
10
|
export function setDebugEnabled(enabled) {
|
|
9
|
-
|
|
11
|
+
coreSetDebug(enabled);
|
|
10
12
|
}
|
|
11
13
|
export function isDebugEnabled() {
|
|
12
|
-
return
|
|
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 (!
|
|
18
|
+
if (!coreIsDebug())
|
|
20
19
|
return;
|
|
21
|
-
|
|
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:
|
|
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.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.
|
|
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
|
}
|