@zhihand/mcp 0.29.0 → 0.30.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.
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Device registry — the single source of truth for all paired devices,
3
+ * their live state (profile, online flag, SSE connection), and multi-
4
+ * device routing.
5
+ *
6
+ * Holds a per-credential AbortController for SSE, a per-device heartbeat
7
+ * timer, and a single debounced notifier for list_changed.
8
+ */
9
+ import { loadConfig, addDevice as configAddDevice, removeDevice as configRemoveDevice, renameDevice as configRenameDevice, setDefaultDevice as configSetDefault, updateLastSeen as configUpdateLastSeen, recordToRuntimeConfig, } from "./config.js";
10
+ import { extractStatic, computeCapabilities, fetchDeviceProfileOnce, normalizeProfilePayload, } from "./device.js";
11
+ import { connectSSEForCredential, handleSSEEvent } from "./sse.js";
12
+ import { dbg } from "../daemon/logger.js";
13
+ const HEARTBEAT_INTERVAL_MS = 30_000;
14
+ const ONLINE_PROFILE_TTL_MS = 60_000;
15
+ const LIST_CHANGED_DEBOUNCE_MS = 2500;
16
+ class Registry {
17
+ devices = new Map();
18
+ listChangedSubs = new Set();
19
+ debounceTimer = null;
20
+ lastOnlineSet = new Set();
21
+ initialized = false;
22
+ get(credentialId) {
23
+ return this.devices.get(credentialId) ?? null;
24
+ }
25
+ list() {
26
+ return Array.from(this.devices.values());
27
+ }
28
+ listOnline() {
29
+ return this.list()
30
+ .filter((d) => d.online)
31
+ .sort((a, b) => b.lastSeenAtMs - a.lastSeenAtMs);
32
+ }
33
+ /**
34
+ * Priority:
35
+ * 1. If the user has explicitly set a default via `zhihand default <id>`
36
+ * AND that device is online → return it. Honoring an explicit user
37
+ * preference is the least-surprising UX.
38
+ * 2. Otherwise → most-recently-active online device (online[0] is sorted
39
+ * desc by lastSeenAtMs).
40
+ * 3. No online devices → null.
41
+ */
42
+ resolveDefault() {
43
+ const online = this.listOnline();
44
+ if (online.length === 0)
45
+ return null;
46
+ const cfg = loadConfig();
47
+ if (cfg.default_credential_id) {
48
+ const d = this.devices.get(cfg.default_credential_id);
49
+ if (d && d.online)
50
+ return d;
51
+ }
52
+ return online[0];
53
+ }
54
+ toRuntimeConfig(state) {
55
+ return recordToRuntimeConfig(state.record);
56
+ }
57
+ subscribe(cb) {
58
+ this.listChangedSubs.add(cb);
59
+ return () => this.listChangedSubs.delete(cb);
60
+ }
61
+ computeOnlineSet() {
62
+ const out = new Set();
63
+ for (const d of this.devices.values()) {
64
+ if (d.online)
65
+ out.add(d.credentialId);
66
+ }
67
+ return out;
68
+ }
69
+ setsEqual(a, b) {
70
+ if (a.size !== b.size)
71
+ return false;
72
+ for (const x of a)
73
+ if (!b.has(x))
74
+ return false;
75
+ return true;
76
+ }
77
+ scheduleListChanged() {
78
+ const now = this.computeOnlineSet();
79
+ if (this.setsEqual(now, this.lastOnlineSet)) {
80
+ return;
81
+ }
82
+ if (this.debounceTimer)
83
+ return;
84
+ this.debounceTimer = setTimeout(() => {
85
+ this.debounceTimer = null;
86
+ const current = this.computeOnlineSet();
87
+ if (this.setsEqual(current, this.lastOnlineSet))
88
+ return;
89
+ this.lastOnlineSet = current;
90
+ for (const cb of this.listChangedSubs) {
91
+ try {
92
+ cb();
93
+ }
94
+ catch { /* swallow */ }
95
+ }
96
+ }, LIST_CHANGED_DEBOUNCE_MS);
97
+ }
98
+ updateOnlineFlag(state) {
99
+ const profileFresh = state.profileReceivedAtMs > 0 &&
100
+ (Date.now() - state.profileReceivedAtMs) < ONLINE_PROFILE_TTL_MS;
101
+ const newOnline = state.sseConnected && profileFresh;
102
+ if (newOnline !== state.online) {
103
+ state.online = newOnline;
104
+ dbg(`[registry] ${state.credentialId} online=${newOnline}`);
105
+ this.scheduleListChanged();
106
+ }
107
+ }
108
+ touchLastSeen(state) {
109
+ state.lastSeenAtMs = Date.now();
110
+ const iso = new Date(state.lastSeenAtMs).toISOString();
111
+ try {
112
+ configUpdateLastSeen(state.credentialId, iso);
113
+ state.record.last_seen_at = iso;
114
+ }
115
+ catch {
116
+ // non-fatal
117
+ }
118
+ }
119
+ async refreshProfile(state) {
120
+ const cfg = this.toRuntimeConfig(state);
121
+ const result = await fetchDeviceProfileOnce(cfg);
122
+ if (!result) {
123
+ state.online = false;
124
+ this.scheduleListChanged();
125
+ return false;
126
+ }
127
+ state.rawAttributes = result.rawAttrs;
128
+ state.profileReceivedAtMs = result.receivedAtMs;
129
+ state.profile = extractStatic(result.rawAttrs);
130
+ state.capabilities = computeCapabilities(result.rawAttrs, result.receivedAtMs);
131
+ // Infer platform from profile
132
+ const plat = state.profile.platform;
133
+ if (plat === "ios" || plat === "android") {
134
+ state.platform = plat;
135
+ state.record.platform = plat;
136
+ }
137
+ this.touchLastSeen(state);
138
+ this.updateOnlineFlag(state);
139
+ return true;
140
+ }
141
+ startHeartbeat(state) {
142
+ if (state.heartbeatTimer)
143
+ return;
144
+ state.heartbeatTimer = setInterval(() => {
145
+ this.refreshProfile(state).catch(() => { });
146
+ }, HEARTBEAT_INTERVAL_MS);
147
+ }
148
+ stopHeartbeat(state) {
149
+ if (state.heartbeatTimer) {
150
+ clearInterval(state.heartbeatTimer);
151
+ state.heartbeatTimer = null;
152
+ }
153
+ }
154
+ startSSE(state) {
155
+ if (state.sseController)
156
+ return;
157
+ const cfg = this.toRuntimeConfig(state);
158
+ state.sseController = connectSSEForCredential(cfg, {
159
+ onEvent: (ev) => {
160
+ // Dispatch command ACKs globally
161
+ handleSSEEvent(ev);
162
+ if (ev.kind === "device_profile.updated" && ev.device_profile) {
163
+ const attrs = normalizeProfilePayload(ev.device_profile);
164
+ state.rawAttributes = attrs;
165
+ state.profileReceivedAtMs = Date.now();
166
+ state.profile = extractStatic(attrs);
167
+ state.capabilities = computeCapabilities(attrs, state.profileReceivedAtMs);
168
+ const plat = state.profile.platform;
169
+ if (plat === "ios" || plat === "android") {
170
+ state.platform = plat;
171
+ state.record.platform = plat;
172
+ }
173
+ this.touchLastSeen(state);
174
+ this.updateOnlineFlag(state);
175
+ }
176
+ else if (ev.kind === "credential.revoked") {
177
+ dbg(`[registry] ${state.credentialId} credential.revoked`);
178
+ state.online = false;
179
+ this.scheduleListChanged();
180
+ }
181
+ },
182
+ onConnected: () => {
183
+ dbg(`[registry] SSE connected: ${state.credentialId}`);
184
+ state.sseConnected = true;
185
+ this.updateOnlineFlag(state);
186
+ },
187
+ onDisconnected: () => {
188
+ dbg(`[registry] SSE disconnected: ${state.credentialId}`);
189
+ state.sseConnected = false;
190
+ this.updateOnlineFlag(state);
191
+ },
192
+ });
193
+ }
194
+ stopSSE(state) {
195
+ state.sseController?.abort();
196
+ state.sseController = null;
197
+ state.sseConnected = false;
198
+ }
199
+ makeState(record) {
200
+ return {
201
+ credentialId: record.credential_id,
202
+ label: record.label,
203
+ platform: record.platform,
204
+ online: false,
205
+ lastSeenAtMs: 0,
206
+ profile: null,
207
+ capabilities: null,
208
+ profileReceivedAtMs: 0,
209
+ rawAttributes: {},
210
+ sseController: null,
211
+ sseConnected: false,
212
+ heartbeatTimer: null,
213
+ record,
214
+ };
215
+ }
216
+ async init() {
217
+ if (this.initialized)
218
+ return;
219
+ this.initialized = true;
220
+ const cfg = loadConfig();
221
+ const records = Object.values(cfg.devices);
222
+ for (const r of records) {
223
+ const s = this.makeState(r);
224
+ this.devices.set(r.credential_id, s);
225
+ this.startSSE(s);
226
+ this.startHeartbeat(s);
227
+ }
228
+ // Fire off initial profile fetches in parallel, with overall ~5s cap
229
+ const fetches = records.map((r) => {
230
+ const s = this.devices.get(r.credential_id);
231
+ return this.refreshProfile(s).catch(() => false);
232
+ });
233
+ await Promise.race([
234
+ Promise.all(fetches),
235
+ new Promise((r) => setTimeout(r, 5000)),
236
+ ]);
237
+ }
238
+ async addDevice(record) {
239
+ configAddDevice(record);
240
+ let s = this.devices.get(record.credential_id);
241
+ if (!s) {
242
+ s = this.makeState(record);
243
+ this.devices.set(record.credential_id, s);
244
+ }
245
+ else {
246
+ s.record = record;
247
+ s.label = record.label;
248
+ }
249
+ this.startSSE(s);
250
+ this.startHeartbeat(s);
251
+ await this.refreshProfile(s).catch(() => false);
252
+ }
253
+ removeDevice(credentialId) {
254
+ const s = this.devices.get(credentialId);
255
+ if (s) {
256
+ this.stopSSE(s);
257
+ this.stopHeartbeat(s);
258
+ this.devices.delete(credentialId);
259
+ }
260
+ configRemoveDevice(credentialId);
261
+ this.scheduleListChanged();
262
+ }
263
+ renameDevice(credentialId, label) {
264
+ configRenameDevice(credentialId, label);
265
+ const s = this.devices.get(credentialId);
266
+ if (s) {
267
+ s.label = label;
268
+ s.record.label = label;
269
+ }
270
+ }
271
+ setDefault(credentialId) {
272
+ configSetDefault(credentialId);
273
+ }
274
+ shutdown() {
275
+ for (const s of this.devices.values()) {
276
+ this.stopSSE(s);
277
+ this.stopHeartbeat(s);
278
+ }
279
+ this.devices.clear();
280
+ if (this.debounceTimer) {
281
+ clearTimeout(this.debounceTimer);
282
+ this.debounceTimer = null;
283
+ }
284
+ this.listChangedSubs.clear();
285
+ this.initialized = false;
286
+ }
287
+ }
288
+ export const registry = new Registry();
@@ -1,4 +1,4 @@
1
- import type { ZhiHandConfig } from "./config.ts";
1
+ import type { ZhiHandRuntimeConfig } from "./config.ts";
2
2
  export declare function getSnapshotStaleThresholdMs(): number;
3
3
  export interface ScreenshotResult {
4
4
  buffer: Buffer;
@@ -9,5 +9,5 @@ export interface ScreenshotResult {
9
9
  sequence: number;
10
10
  stale: boolean;
11
11
  }
12
- export declare function fetchScreenshot(config: ZhiHandConfig): Promise<ScreenshotResult>;
13
- export declare function fetchScreenshotBinary(config: ZhiHandConfig): Promise<Buffer>;
12
+ export declare function fetchScreenshot(config: ZhiHandRuntimeConfig): Promise<ScreenshotResult>;
13
+ export declare function fetchScreenshotBinary(config: ZhiHandRuntimeConfig): Promise<Buffer>;
@@ -1,4 +1,4 @@
1
- import type { ZhiHandConfig } from "./config.ts";
1
+ import type { ZhiHandRuntimeConfig } from "./config.ts";
2
2
  import type { QueuedCommandRecord, WaitForCommandAckResult } from "./command.ts";
3
3
  export interface SSEEvent {
4
4
  id: string;
@@ -6,29 +6,26 @@ export interface SSEEvent {
6
6
  kind: string;
7
7
  credential_id: string;
8
8
  command?: QueuedCommandRecord;
9
+ device_profile?: Record<string, unknown>;
9
10
  sequence: number;
10
11
  }
11
12
  export declare function handleSSEEvent(event: SSEEvent): void;
12
13
  export declare function subscribeToCommandAck(commandId: string, callback: (cmd: QueuedCommandRecord) => void): () => void;
14
+ export interface SSEHandlers {
15
+ onEvent: (e: SSEEvent) => void;
16
+ onConnected: () => void;
17
+ onDisconnected: () => void;
18
+ }
13
19
  /**
14
- * Connect to the SSE event stream for command ACKs.
15
- * Maintains a persistent connection that dispatches events to registered callbacks.
16
- * Reconnects automatically on connection loss.
17
- */
18
- export declare function connectSSE(config: ZhiHandConfig): void;
19
- /**
20
- * Disconnect the SSE event stream.
21
- */
22
- export declare function disconnectSSE(): void;
23
- /**
24
- * Whether the SSE stream is currently connected.
20
+ * Open a per-credential SSE connection. Caller owns the returned AbortController.
21
+ * The loop auto-reconnects with exponential backoff until aborted.
25
22
  */
26
- export declare function isSSEConnected(): boolean;
23
+ export declare function connectSSEForCredential(config: ZhiHandRuntimeConfig, handlers: SSEHandlers): AbortController;
27
24
  /**
28
- * Wait for command ACK via SSE push.
29
- * Falls back to polling if SSE is not active.
25
+ * Wait for command ACK via SSE push (which should already be connected by the
26
+ * registry). Falls back to polling.
30
27
  */
31
- export declare function waitForCommandAck(config: ZhiHandConfig, options: {
28
+ export declare function waitForCommandAck(config: ZhiHandRuntimeConfig, options: {
32
29
  commandId: string;
33
30
  timeoutMs?: number;
34
31
  signal?: AbortSignal;
package/dist/core/sse.js CHANGED
@@ -1,10 +1,7 @@
1
1
  import { getCommand } from "./command.js";
2
2
  import { dbg } from "../daemon/logger.js";
3
- // Per-commandId callback registry for SSE-based ACK
3
+ // Per-commandId callback registry for SSE-based ACK (global — ids are globally unique)
4
4
  const ackCallbacks = new Map();
5
- // Active SSE connection state
6
- let sseAbortController = null;
7
- let sseConnected = false;
8
5
  export function handleSSEEvent(event) {
9
6
  dbg(`[sse-cmd] Event: kind=${event.kind}, command=${event.command?.id ?? "-"}`);
10
7
  if (event.kind === "command.acked" && event.command) {
@@ -21,16 +18,15 @@ export function subscribeToCommandAck(commandId, callback) {
21
18
  return () => { ackCallbacks.delete(commandId); };
22
19
  }
23
20
  /**
24
- * Connect to the SSE event stream for command ACKs.
25
- * Maintains a persistent connection that dispatches events to registered callbacks.
26
- * Reconnects automatically on connection loss.
21
+ * Open a per-credential SSE connection. Caller owns the returned AbortController.
22
+ * The loop auto-reconnects with exponential backoff until aborted.
27
23
  */
28
- export function connectSSE(config) {
29
- if (sseAbortController)
30
- return; // Already connected
31
- sseAbortController = new AbortController();
32
- const { signal } = sseAbortController;
33
- const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/events/stream?topic=commands`;
24
+ export function connectSSEForCredential(config, handlers) {
25
+ const controller = new AbortController();
26
+ const { signal } = controller;
27
+ const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/events/stream`;
28
+ let backoffMs = 1000;
29
+ const BACKOFF_MAX = 30_000;
34
30
  (async () => {
35
31
  while (!signal.aborted) {
36
32
  try {
@@ -44,12 +40,14 @@ export function connectSSE(config) {
44
40
  if (!response.ok) {
45
41
  throw new Error(`SSE connect failed: ${response.status}`);
46
42
  }
47
- sseConnected = true;
43
+ handlers.onConnected();
44
+ backoffMs = 1000;
48
45
  const reader = response.body?.getReader();
49
46
  if (!reader)
50
47
  throw new Error("No response body for SSE");
51
48
  const decoder = new TextDecoder();
52
49
  let buffer = "";
50
+ let eventData = "";
53
51
  while (!signal.aborted) {
54
52
  const { done, value } = await reader.read();
55
53
  if (done)
@@ -57,18 +55,17 @@ export function connectSSE(config) {
57
55
  buffer += decoder.decode(value, { stream: true });
58
56
  const lines = buffer.split("\n");
59
57
  buffer = lines.pop() ?? "";
60
- let eventData = "";
61
58
  for (const line of lines) {
62
59
  if (line.startsWith("data: ")) {
63
- eventData += line.slice(6);
60
+ eventData += (eventData ? "\n" : "") + line.slice(6);
64
61
  }
65
62
  else if (line === "" && eventData) {
66
63
  try {
67
- const event = JSON.parse(eventData);
68
- handleSSEEvent(event);
64
+ const ev = JSON.parse(eventData);
65
+ handlers.onEvent(ev);
69
66
  }
70
67
  catch {
71
- // Malformed event, skip
68
+ // malformed, skip
72
69
  }
73
70
  eventData = "";
74
71
  }
@@ -78,37 +75,22 @@ export function connectSSE(config) {
78
75
  catch (err) {
79
76
  if (signal.aborted)
80
77
  break;
81
- sseConnected = false;
82
- // Backoff before reconnect
83
- await new Promise((r) => setTimeout(r, 3000));
78
+ handlers.onDisconnected();
79
+ await new Promise((r) => setTimeout(r, backoffMs));
80
+ backoffMs = Math.min(backoffMs * 2, BACKOFF_MAX);
84
81
  }
85
82
  }
86
- sseConnected = false;
83
+ handlers.onDisconnected();
87
84
  })();
85
+ return controller;
88
86
  }
89
87
  /**
90
- * Disconnect the SSE event stream.
91
- */
92
- export function disconnectSSE() {
93
- sseAbortController?.abort();
94
- sseAbortController = null;
95
- sseConnected = false;
96
- }
97
- /**
98
- * Whether the SSE stream is currently connected.
99
- */
100
- export function isSSEConnected() {
101
- return sseConnected;
102
- }
103
- /**
104
- * Wait for command ACK via SSE push.
105
- * Falls back to polling if SSE is not active.
88
+ * Wait for command ACK via SSE push (which should already be connected by the
89
+ * registry). Falls back to polling.
106
90
  */
107
91
  export async function waitForCommandAck(config, options) {
108
92
  const timeoutMs = options.timeoutMs ?? 15_000;
109
93
  dbg(`[sse-cmd] Waiting for ACK: commandId=${options.commandId}, timeout=${timeoutMs}ms`);
110
- // Ensure SSE is connected for real-time ACKs
111
- connectSSE(config);
112
94
  return new Promise((resolve, reject) => {
113
95
  let resolved = false;
114
96
  let pollInterval;
@@ -123,28 +105,38 @@ export async function waitForCommandAck(config, options) {
123
105
  cleanup();
124
106
  resolve({ acked: true, command: ackedCommand });
125
107
  });
126
- // Also poll as fallback (SSE may not be connected yet or may be reconnecting)
127
- pollInterval = setInterval(async () => {
108
+ // Delay polling startup by 2s so SSE push ACK normally wins in the
109
+ // registry-connected path. CLI (zhihand test) still resolves via polling
110
+ // after the initial delay. This avoids hammering the backend with 500ms
111
+ // HTTP polls for every command when SSE is healthy.
112
+ const POLL_START_DELAY_MS = 2000;
113
+ const POLL_INTERVAL_MS = 500;
114
+ const startPolling = setTimeout(() => {
128
115
  if (resolved)
129
116
  return;
130
- try {
131
- const cmd = await getCommand(config, options.commandId);
132
- if (cmd.acked_at) {
133
- resolved = true;
134
- cleanup();
135
- resolve({ acked: true, command: cmd });
117
+ pollInterval = setInterval(async () => {
118
+ if (resolved)
119
+ return;
120
+ try {
121
+ const cmd = await getCommand(config, options.commandId);
122
+ if (cmd.acked_at) {
123
+ resolved = true;
124
+ cleanup();
125
+ resolve({ acked: true, command: cmd });
126
+ }
136
127
  }
137
- }
138
- catch {
139
- // Polling failure is non-fatal; SSE or next poll may succeed
140
- }
141
- }, 500);
128
+ catch {
129
+ // non-fatal
130
+ }
131
+ }, POLL_INTERVAL_MS);
132
+ }, POLL_START_DELAY_MS);
142
133
  options.signal?.addEventListener("abort", () => {
143
134
  cleanup();
144
135
  reject(new Error("The operation was aborted"));
145
136
  }, { once: true });
146
137
  function cleanup() {
147
138
  clearTimeout(timeout);
139
+ clearTimeout(startPolling);
148
140
  unsubscribe();
149
141
  if (pollInterval)
150
142
  clearInterval(pollInterval);
@@ -1,4 +1,5 @@
1
- import type { ZhiHandConfig, BackendName } from "../core/config.ts";
1
+ import type { ZhiHandRuntimeConfig, BackendName } from "../core/config.ts";
2
+ type ZhiHandConfig = ZhiHandRuntimeConfig;
2
3
  export interface DispatchResult {
3
4
  text: string;
4
5
  success: boolean;
@@ -10,3 +11,4 @@ export interface DispatchResult {
10
11
  export declare function killActiveChild(): Promise<void>;
11
12
  export declare function dispatchToCLI(backend: Exclude<BackendName, "openclaw">, prompt: string, log: (msg: string) => void, model?: string): Promise<DispatchResult>;
12
13
  export declare function postReply(config: ZhiHandConfig, promptId: string, text: string): Promise<boolean>;
14
+ export {};
@@ -5,7 +5,7 @@ import os from "node:os";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { DEFAULT_MODELS } from "../core/config.js";
7
7
  import { resolveGemini, resolveClaude, resolveCodex } from "../core/resolve-path.js";
8
- import { getStaticContext, isDeviceProfileLoaded } from "../core/device.js";
8
+ import { registry } from "../core/registry.js";
9
9
  import { dbg } from "./logger.js";
10
10
  const CLI_TIMEOUT = 300_000; // 300s (5min) per prompt — MCP tool chains need multiple turns
11
11
  const SIGKILL_DELAY = 2_000; // 2s after SIGTERM
@@ -357,7 +357,8 @@ export async function killActiveChild() {
357
357
  * so the AI sends correct platform-specific parameters (e.g. appPackage vs bundleId).
358
358
  */
359
359
  function buildSystemContext() {
360
- const static_ = isDeviceProfileLoaded() ? getStaticContext() : null;
360
+ const defaultState = registry.resolveDefault();
361
+ const static_ = defaultState?.profile ?? null;
361
362
  const deviceLine = static_
362
363
  ? `Connected device: ${static_.platform} ${static_.model} (${static_.osVersion}), ${static_.screenWidthPx}x${static_.screenHeightPx}, ${static_.formFactor}, ${static_.locale}`
363
364
  : "Connected device: unknown platform";
@@ -1,4 +1,4 @@
1
- import type { ZhiHandConfig } from "../core/config.ts";
1
+ import type { ZhiHandRuntimeConfig } from "../core/config.ts";
2
2
  /** Brain metadata included in every heartbeat, so the app always knows the current backend/model. */
3
3
  export interface BrainMeta {
4
4
  backend?: string | null;
@@ -6,7 +6,7 @@ export interface BrainMeta {
6
6
  }
7
7
  /** Update the backend/model metadata that will be sent with the next heartbeat. */
8
8
  export declare function setBrainMeta(meta: BrainMeta): void;
9
- export declare function sendBrainOnline(config: ZhiHandConfig): Promise<boolean>;
10
- export declare function sendBrainOffline(config: ZhiHandConfig): Promise<boolean>;
11
- export declare function startHeartbeatLoop(config: ZhiHandConfig, log: (msg: string) => void): 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;
12
12
  export declare function stopHeartbeatLoop(): void;
@@ -11,7 +11,7 @@ import { startHeartbeatLoop, stopHeartbeatLoop, sendBrainOffline, setBrainMeta }
11
11
  import { PromptListener } from "./prompt-listener.js";
12
12
  import { dispatchToCLI, postReply, killActiveChild } from "./dispatcher.js";
13
13
  import { setDebugEnabled, dbg } from "./logger.js";
14
- import { fetchDeviceProfile, getStaticContext, isDeviceProfileLoaded } from "../core/device.js";
14
+ import { registry } from "../core/registry.js";
15
15
  const DEFAULT_PORT = 18686;
16
16
  const PID_FILE = "daemon.pid";
17
17
  // ── State ──────────────────────────────────────────────────
@@ -190,10 +190,11 @@ export async function startDaemon(options) {
190
190
  else {
191
191
  log(`[config] No backend configured. Use: zhihand gemini / zhihand claude / zhihand codex`);
192
192
  }
193
- // Fetch device profile (platform, model, screen size) non-blocking, best-effort
194
- await fetchDeviceProfile(config);
195
- if (isDeviceProfileLoaded()) {
196
- const s = getStaticContext();
193
+ // Init the multi-device registry (single-device in daemon context) and log profile.
194
+ await registry.init();
195
+ const defaultState = registry.resolveDefault();
196
+ if (defaultState?.profile) {
197
+ const s = defaultState.profile;
197
198
  log(`[device] ${s.platform} ${s.model} (${s.osVersion}), ${s.screenWidthPx}x${s.screenHeightPx}, ${s.locale}`);
198
199
  }
199
200
  else {
@@ -244,7 +245,7 @@ export async function startDaemon(options) {
244
245
  }
245
246
  else if (!sessionId) {
246
247
  // New session: create dedicated McpServer + Transport
247
- const server = createMcpServer(options?.deviceName);
248
+ const server = createMcpServer();
248
249
  const transport = new StreamableHTTPServerTransport({
249
250
  sessionIdGenerator: () => randomUUID(),
250
251
  onsessioninitialized: (sid) => {
@@ -341,6 +342,7 @@ export async function startDaemon(options) {
341
342
  catch { /* ignore */ }
342
343
  }
343
344
  mcpSessions.clear();
345
+ registry.shutdown();
344
346
  httpServer.close();
345
347
  removePid();
346
348
  log("Daemon stopped.");
@@ -1,4 +1,5 @@
1
- import type { ZhiHandConfig } from "../core/config.ts";
1
+ import type { ZhiHandRuntimeConfig } from "../core/config.ts";
2
+ type ZhiHandConfig = ZhiHandRuntimeConfig;
2
3
  export interface MobilePrompt {
3
4
  id: string;
4
5
  credential_id: string;
@@ -32,3 +33,4 @@ export declare class PromptListener {
32
33
  private stopPolling;
33
34
  private poll;
34
35
  }
36
+ export {};
@@ -1,4 +1,3 @@
1
- import { updateDeviceProfile } from "../core/device.js";
2
1
  import { dbg } from "./logger.js";
3
2
  const SSE_WATCHDOG_TIMEOUT = 120_000; // 120s no data → reconnect (servers may not send keepalive frequently)
4
3
  const SSE_RECONNECT_DELAY = 3_000;
@@ -133,11 +132,8 @@ export class PromptListener {
133
132
  }
134
133
  }
135
134
  else if (event.kind === "device_profile.updated" && event.device_profile) {
136
- // NOTE: This event may only arrive if the server sends cross-topic events on
137
- // the prompts stream, or if the API is updated to support multi-topic SSE.
138
- // If not received, device profile is still fetched at daemon startup.
139
- this.log("[device] Device profile updated via SSE");
140
- updateDeviceProfile(event.device_profile);
135
+ // Registry owns device-profile updates; this listener is only for prompts.
136
+ this.log("[device] device_profile.updated event received on prompts stream (ignored; registry handles it)");
141
137
  }
142
138
  }
143
139
  startPolling() {
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.29.0";
3
- export declare function createServer(deviceName?: string): McpServer;
4
- export declare function startStdioServer(deviceName?: string): Promise<void>;
2
+ export declare const PACKAGE_VERSION = "0.30.0";
3
+ export declare function createServer(): McpServer;
4
+ export declare function startStdioServer(): Promise<void>;