@zhihand/mcp 0.16.0 → 0.17.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/dist/daemon/dispatcher.d.ts +1 -1
- package/dist/daemon/dispatcher.js +487 -50
- package/dist/daemon/index.js +62 -21
- package/dist/index.js +1 -1
- package/package.json +1 -1
|
@@ -9,5 +9,5 @@ export interface DispatchResult {
|
|
|
9
9
|
* when the child has exited (or immediately if no child).
|
|
10
10
|
*/
|
|
11
11
|
export declare function killActiveChild(): Promise<void>;
|
|
12
|
-
export declare function dispatchToCLI(backend: Exclude<BackendName, "openclaw">, prompt: string, model?: string): Promise<DispatchResult>;
|
|
12
|
+
export declare function dispatchToCLI(backend: Exclude<BackendName, "openclaw">, prompt: string, log: (msg: string) => void, model?: string): Promise<DispatchResult>;
|
|
13
13
|
export declare function postReply(config: ZhiHandConfig, promptId: string, text: string): Promise<boolean>;
|
|
@@ -1,31 +1,310 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
2
6
|
const CLI_TIMEOUT = 120_000; // 120s
|
|
3
7
|
const SIGKILL_DELAY = 2_000; // 2s after SIGTERM
|
|
4
8
|
const MAX_OUTPUT_BYTES = 100 * 1024; // 100KB
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
env: {
|
|
14
|
-
GEMINI_SANDBOX: "false",
|
|
15
|
-
TERM: "xterm-256color",
|
|
16
|
-
COLORTERM: "truecolor",
|
|
17
|
-
},
|
|
18
|
-
},
|
|
19
|
-
claudecode: {
|
|
20
|
-
command: "claude",
|
|
21
|
-
buildArgs: (prompt) => ["-p", prompt, "--output-format", "json"],
|
|
22
|
-
},
|
|
23
|
-
codex: {
|
|
24
|
-
command: "codex",
|
|
25
|
-
buildArgs: (prompt) => ["-q", prompt, "--json"],
|
|
26
|
-
},
|
|
27
|
-
};
|
|
9
|
+
// Gemini session file polling
|
|
10
|
+
const SESSION_POLL_INTERVAL = 1_000; // 1s
|
|
11
|
+
const SESSION_STABILITY_DELAY = 2_000; // wait 2s after outcome before returning
|
|
12
|
+
// Resolve pty-wrap.py relative to this file (works from both src/ and dist/)
|
|
13
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const PTY_WRAP_SCRIPT = path.resolve(__dirname, "../../scripts/pty-wrap.py");
|
|
15
|
+
// Gemini session directories
|
|
16
|
+
const GEMINI_TMP_DIR = path.join(os.homedir(), ".gemini", "tmp");
|
|
28
17
|
let activeChild = null;
|
|
18
|
+
// ── Gemini Session File Monitoring ─────────────────────────
|
|
19
|
+
/** Safely read and parse a JSON file (single attempt, async). */
|
|
20
|
+
async function loadJsonFile(filePath) {
|
|
21
|
+
try {
|
|
22
|
+
const raw = await fsp.readFile(filePath, "utf8");
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// File locked or partial write — next poll cycle will retry
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Extract text content from a gemini session message. */
|
|
32
|
+
function extractMessageText(message) {
|
|
33
|
+
const content = message.content;
|
|
34
|
+
if (typeof content === "string")
|
|
35
|
+
return content;
|
|
36
|
+
if (Array.isArray(content)) {
|
|
37
|
+
return content
|
|
38
|
+
.map((item) => {
|
|
39
|
+
if (typeof item === "string")
|
|
40
|
+
return item;
|
|
41
|
+
if (typeof item === "object" && item !== null) {
|
|
42
|
+
const obj = item;
|
|
43
|
+
if (typeof obj.text === "string")
|
|
44
|
+
return obj.text;
|
|
45
|
+
if (typeof obj.output === "string")
|
|
46
|
+
return obj.output;
|
|
47
|
+
}
|
|
48
|
+
return "";
|
|
49
|
+
})
|
|
50
|
+
.join("");
|
|
51
|
+
}
|
|
52
|
+
if (typeof content === "object" && content !== null) {
|
|
53
|
+
const obj = content;
|
|
54
|
+
if (typeof obj.text === "string")
|
|
55
|
+
return obj.text;
|
|
56
|
+
}
|
|
57
|
+
// Fallback to displayContent
|
|
58
|
+
const display = message.displayContent;
|
|
59
|
+
if (typeof display === "string")
|
|
60
|
+
return display;
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
/** Check if a message has active (non-terminal) tool calls. */
|
|
64
|
+
function hasActiveToolCalls(message) {
|
|
65
|
+
if (String(message.type ?? "").trim() !== "gemini")
|
|
66
|
+
return false;
|
|
67
|
+
const toolCalls = message.toolCalls;
|
|
68
|
+
if (!Array.isArray(toolCalls))
|
|
69
|
+
return false;
|
|
70
|
+
const terminalStatuses = new Set(["completed", "cancelled", "errored", "failed"]);
|
|
71
|
+
for (const tc of toolCalls) {
|
|
72
|
+
if (typeof tc !== "object" || tc === null)
|
|
73
|
+
continue;
|
|
74
|
+
const status = String(tc.status ?? "").trim().toLowerCase();
|
|
75
|
+
if (status && !terminalStatuses.has(status))
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Check session messages for completion.
|
|
82
|
+
* Returns [status, text] or null if still in progress.
|
|
83
|
+
*/
|
|
84
|
+
function checkSessionOutcome(messages) {
|
|
85
|
+
if (messages.length === 0)
|
|
86
|
+
return null;
|
|
87
|
+
// Get the latest turn messages (trailing messages from last user input)
|
|
88
|
+
const trailing = [];
|
|
89
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
90
|
+
const msg = messages[i];
|
|
91
|
+
if (String(msg.type ?? "").trim() === "user")
|
|
92
|
+
break;
|
|
93
|
+
trailing.unshift(msg);
|
|
94
|
+
}
|
|
95
|
+
if (trailing.length === 0)
|
|
96
|
+
return null;
|
|
97
|
+
// If any message has active tool calls, still in progress
|
|
98
|
+
for (const msg of trailing) {
|
|
99
|
+
if (hasActiveToolCalls(msg))
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
// Check from last message backwards for a result
|
|
103
|
+
for (let i = trailing.length - 1; i >= 0; i--) {
|
|
104
|
+
const msg = trailing[i];
|
|
105
|
+
const msgType = String(msg.type ?? "").trim();
|
|
106
|
+
// Error/warning/info messages
|
|
107
|
+
if (["error", "warning", "info"].includes(msgType)) {
|
|
108
|
+
const text = extractMessageText(msg).trim();
|
|
109
|
+
if (text)
|
|
110
|
+
return ["error", text];
|
|
111
|
+
}
|
|
112
|
+
// Gemini response message
|
|
113
|
+
if (msgType === "gemini") {
|
|
114
|
+
const text = extractMessageText(msg).trim();
|
|
115
|
+
if (text)
|
|
116
|
+
return ["success", text];
|
|
117
|
+
if (hasActiveToolCalls(msg))
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Find the most recently created session file in the gemini tmp directory
|
|
125
|
+
* that was created after `afterTime`. Validates that the session contains
|
|
126
|
+
* our prompt text to avoid picking up unrelated gemini sessions.
|
|
127
|
+
*/
|
|
128
|
+
async function findLatestSessionFile(afterTime, promptText) {
|
|
129
|
+
try {
|
|
130
|
+
const entries = await fsp.readdir(GEMINI_TMP_DIR, { withFileTypes: true });
|
|
131
|
+
const candidates = [];
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
if (!entry.isDirectory())
|
|
134
|
+
continue;
|
|
135
|
+
const chatsDir = path.join(GEMINI_TMP_DIR, entry.name, "chats");
|
|
136
|
+
try {
|
|
137
|
+
await fsp.access(chatsDir);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const chatFiles = await fsp.readdir(chatsDir);
|
|
143
|
+
for (const f of chatFiles) {
|
|
144
|
+
if (!f.startsWith("session-") || !f.endsWith(".json"))
|
|
145
|
+
continue;
|
|
146
|
+
const fullPath = path.join(chatsDir, f);
|
|
147
|
+
const stat = await fsp.stat(fullPath);
|
|
148
|
+
if (stat.mtimeMs > afterTime) {
|
|
149
|
+
candidates.push({ path: fullPath, mtime: stat.mtimeMs });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Sort newest first, then validate content matches our prompt
|
|
154
|
+
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
155
|
+
const promptPrefix = promptText.slice(0, 50);
|
|
156
|
+
for (const candidate of candidates) {
|
|
157
|
+
const data = await loadJsonFile(candidate.path);
|
|
158
|
+
if (!data || !Array.isArray(data.messages))
|
|
159
|
+
continue;
|
|
160
|
+
// Check first user message matches our prompt
|
|
161
|
+
for (const msg of data.messages) {
|
|
162
|
+
if (String(msg.type ?? "").trim() !== "user")
|
|
163
|
+
continue;
|
|
164
|
+
const text = extractMessageText(msg);
|
|
165
|
+
if (text.startsWith(promptPrefix))
|
|
166
|
+
return candidate.path;
|
|
167
|
+
break; // Only check first user message
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Poll gemini session files for the response.
|
|
178
|
+
* Returns the final text when gemini completes, or null on timeout.
|
|
179
|
+
*/
|
|
180
|
+
function pollGeminiSession(child, startTime, promptText, log) {
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
let sessionFile = null;
|
|
183
|
+
let outcomeAt = null;
|
|
184
|
+
let finalResult = null;
|
|
185
|
+
let settled = false;
|
|
186
|
+
let pollTimeout = null;
|
|
187
|
+
function settle(result) {
|
|
188
|
+
if (settled)
|
|
189
|
+
return;
|
|
190
|
+
settled = true;
|
|
191
|
+
if (pollTimeout)
|
|
192
|
+
clearTimeout(pollTimeout);
|
|
193
|
+
// Kill the gemini process now that we have the answer
|
|
194
|
+
closeChild(child);
|
|
195
|
+
resolve(result);
|
|
196
|
+
}
|
|
197
|
+
async function poll() {
|
|
198
|
+
if (settled)
|
|
199
|
+
return;
|
|
200
|
+
const elapsed = Date.now() - startTime;
|
|
201
|
+
// Timeout
|
|
202
|
+
if (elapsed > CLI_TIMEOUT) {
|
|
203
|
+
closeChild(child);
|
|
204
|
+
settle({
|
|
205
|
+
text: "Gemini timed out after 120s.",
|
|
206
|
+
success: false,
|
|
207
|
+
durationMs: elapsed,
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// Find session file if not yet found
|
|
212
|
+
if (!sessionFile) {
|
|
213
|
+
sessionFile = await findLatestSessionFile(startTime, promptText);
|
|
214
|
+
if (sessionFile) {
|
|
215
|
+
log(`[gemini] Session file found: ${path.basename(sessionFile)}`);
|
|
216
|
+
}
|
|
217
|
+
schedulePoll();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// Read session file and check for outcome
|
|
221
|
+
const conversation = await loadJsonFile(sessionFile);
|
|
222
|
+
if (!conversation) {
|
|
223
|
+
schedulePoll();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const messages = conversation.messages;
|
|
227
|
+
if (!Array.isArray(messages)) {
|
|
228
|
+
schedulePoll();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const outcome = checkSessionOutcome(messages);
|
|
232
|
+
if (!outcome) {
|
|
233
|
+
// Still in progress, reset stability timer
|
|
234
|
+
outcomeAt = null;
|
|
235
|
+
finalResult = null;
|
|
236
|
+
schedulePoll();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
// Outcome detected — wait for stability (2s) before returning
|
|
240
|
+
if (!outcomeAt) {
|
|
241
|
+
outcomeAt = Date.now();
|
|
242
|
+
finalResult = outcome;
|
|
243
|
+
schedulePoll();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (Date.now() - outcomeAt >= SESSION_STABILITY_DELAY) {
|
|
247
|
+
const [status, text] = finalResult ?? outcome;
|
|
248
|
+
settle({
|
|
249
|
+
text,
|
|
250
|
+
success: status === "success",
|
|
251
|
+
durationMs: Date.now() - startTime,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
schedulePoll();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function schedulePoll() {
|
|
259
|
+
if (settled)
|
|
260
|
+
return;
|
|
261
|
+
pollTimeout = setTimeout(() => { poll(); }, SESSION_POLL_INTERVAL);
|
|
262
|
+
}
|
|
263
|
+
// Start polling
|
|
264
|
+
schedulePoll();
|
|
265
|
+
// Also handle process exit (in case it crashes before producing session file)
|
|
266
|
+
child.on("close", (code) => {
|
|
267
|
+
if (settled)
|
|
268
|
+
return;
|
|
269
|
+
// Give a final chance to read the session file
|
|
270
|
+
setTimeout(async () => {
|
|
271
|
+
if (settled)
|
|
272
|
+
return;
|
|
273
|
+
if (sessionFile) {
|
|
274
|
+
const conversation = await loadJsonFile(sessionFile);
|
|
275
|
+
if (conversation && Array.isArray(conversation.messages)) {
|
|
276
|
+
const outcome = checkSessionOutcome(conversation.messages);
|
|
277
|
+
if (outcome) {
|
|
278
|
+
settle({
|
|
279
|
+
text: outcome[1],
|
|
280
|
+
success: outcome[0] === "success",
|
|
281
|
+
durationMs: Date.now() - startTime,
|
|
282
|
+
});
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
settle({
|
|
288
|
+
text: `Gemini process exited with code ${code} before producing a response.`,
|
|
289
|
+
success: false,
|
|
290
|
+
durationMs: Date.now() - startTime,
|
|
291
|
+
});
|
|
292
|
+
}, 500);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
/** Gracefully close a child process: EOF → SIGTERM → SIGKILL. */
|
|
297
|
+
function closeChild(child) {
|
|
298
|
+
if (child.killed || child.exitCode !== null)
|
|
299
|
+
return;
|
|
300
|
+
// Try SIGTERM first
|
|
301
|
+
child.kill("SIGTERM");
|
|
302
|
+
setTimeout(() => {
|
|
303
|
+
if (!child.killed && child.exitCode === null) {
|
|
304
|
+
child.kill("SIGKILL");
|
|
305
|
+
}
|
|
306
|
+
}, SIGKILL_DELAY);
|
|
307
|
+
}
|
|
29
308
|
/**
|
|
30
309
|
* Kill the active child process. Returns a promise that resolves
|
|
31
310
|
* when the child has exited (or immediately if no child).
|
|
@@ -37,28 +316,140 @@ export function killActiveChild() {
|
|
|
37
316
|
return new Promise((resolve) => {
|
|
38
317
|
const child = activeChild;
|
|
39
318
|
child.once("close", () => resolve());
|
|
40
|
-
child
|
|
41
|
-
setTimeout(() => {
|
|
42
|
-
if (!child.killed) {
|
|
43
|
-
child.kill("SIGKILL");
|
|
44
|
-
}
|
|
45
|
-
}, SIGKILL_DELAY);
|
|
319
|
+
closeChild(child);
|
|
46
320
|
// Safety: resolve after SIGKILL_DELAY + 1s even if no close event
|
|
47
321
|
setTimeout(() => resolve(), SIGKILL_DELAY + 1000);
|
|
48
322
|
});
|
|
49
323
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
324
|
+
// ── System Prompt ─────────────────────────────────────────
|
|
325
|
+
/**
|
|
326
|
+
* Wrap the user's raw prompt with system context so the CLI backend
|
|
327
|
+
* knows about the connected phone and how to use zhihand MCP tools.
|
|
328
|
+
*/
|
|
329
|
+
function wrapPrompt(userPrompt) {
|
|
330
|
+
return `You are ZhiHand, an AI assistant connected to the user's mobile phone via MCP tools.
|
|
331
|
+
|
|
332
|
+
You have the following MCP tools to interact with the phone:
|
|
333
|
+
- zhihand_screenshot: Take a screenshot of the phone screen. Use this when the user asks to see, check, or look at their screen.
|
|
334
|
+
- zhihand_control: Control the phone — click, type, swipe, scroll, key combos, clipboard, wait. Requires "action" parameter. For clicks, provide xRatio/yRatio (0-1 normalized coordinates).
|
|
335
|
+
- zhihand_pair: Pair a new device (rarely needed).
|
|
336
|
+
|
|
337
|
+
When the user asks you to see their screen, look at something, or check what's on the phone, ALWAYS call zhihand_screenshot first.
|
|
338
|
+
When the user asks you to tap, click, type, swipe, or interact with the phone, use zhihand_control.
|
|
339
|
+
|
|
340
|
+
User message:
|
|
341
|
+
${userPrompt}`;
|
|
342
|
+
}
|
|
343
|
+
// ── Dispatch Entrypoint ────────────────────────────────────
|
|
344
|
+
export function dispatchToCLI(backend, prompt, log, model) {
|
|
59
345
|
const startTime = Date.now();
|
|
60
|
-
const
|
|
61
|
-
|
|
346
|
+
const wrappedPrompt = wrapPrompt(prompt);
|
|
347
|
+
if (backend === "gemini") {
|
|
348
|
+
return dispatchGemini(wrappedPrompt, startTime, log, model);
|
|
349
|
+
}
|
|
350
|
+
if (backend === "codex") {
|
|
351
|
+
return dispatchCodex(wrappedPrompt, startTime, model);
|
|
352
|
+
}
|
|
353
|
+
if (backend === "claudecode") {
|
|
354
|
+
return dispatchClaude(wrappedPrompt, startTime, model);
|
|
355
|
+
}
|
|
356
|
+
return Promise.resolve({
|
|
357
|
+
text: `Unsupported backend: ${backend}`,
|
|
358
|
+
success: false,
|
|
359
|
+
durationMs: 0,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
// ── Gemini Dispatch (PTY + Session File Monitoring) ────────
|
|
363
|
+
function dispatchGemini(prompt, startTime, log, model) {
|
|
364
|
+
const geminiModel = model ?? process.env.CLAUDE_GEMINI_MODEL ?? "gemini-3.1-pro-preview";
|
|
365
|
+
const cliArgs = [
|
|
366
|
+
"--approval-mode", "yolo",
|
|
367
|
+
"--model", geminiModel,
|
|
368
|
+
"-i", prompt,
|
|
369
|
+
];
|
|
370
|
+
const env = {
|
|
371
|
+
...process.env,
|
|
372
|
+
GEMINI_SANDBOX: "false",
|
|
373
|
+
TERM: "xterm-256color",
|
|
374
|
+
COLORTERM: "truecolor",
|
|
375
|
+
};
|
|
376
|
+
// Wrap with PTY so gemini sees isatty()==true
|
|
377
|
+
const child = spawn("python3", [PTY_WRAP_SCRIPT, "gemini", ...cliArgs], {
|
|
378
|
+
env,
|
|
379
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
380
|
+
detached: false,
|
|
381
|
+
});
|
|
382
|
+
activeChild = child;
|
|
383
|
+
// Drain PTY output (discard — we read from session file instead)
|
|
384
|
+
child.stdout?.resume();
|
|
385
|
+
child.stderr?.resume();
|
|
386
|
+
return pollGeminiSession(child, startTime, prompt, log);
|
|
387
|
+
}
|
|
388
|
+
// ── Codex Dispatch ─────────────────────────────────────────
|
|
389
|
+
function dispatchCodex(prompt, startTime, model) {
|
|
390
|
+
// codex exec --full-auto --skip-git-repo-check --json [-m model] <prompt>
|
|
391
|
+
const args = ["exec", "--full-auto", "--skip-git-repo-check", "--json"];
|
|
392
|
+
const codexModel = model ?? process.env.CLAUDE_CODEX_MODEL;
|
|
393
|
+
if (codexModel) {
|
|
394
|
+
args.push("-m", codexModel);
|
|
395
|
+
}
|
|
396
|
+
args.push(prompt);
|
|
397
|
+
const child = spawn("codex", args, {
|
|
398
|
+
env: process.env,
|
|
399
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
400
|
+
detached: false,
|
|
401
|
+
});
|
|
402
|
+
activeChild = child;
|
|
403
|
+
return collectCodexOutput(child, startTime);
|
|
404
|
+
}
|
|
405
|
+
// ── Claude Dispatch ────────────────────────────────────────
|
|
406
|
+
function dispatchClaude(prompt, startTime, model) {
|
|
407
|
+
const child = spawn("claude", ["-p", prompt, "--output-format", "json"], {
|
|
408
|
+
env: process.env,
|
|
409
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
410
|
+
detached: false,
|
|
411
|
+
});
|
|
412
|
+
activeChild = child;
|
|
413
|
+
return collectChildOutput(child, startTime);
|
|
414
|
+
}
|
|
415
|
+
// ── Codex JSONL Output Parser ──────────────────────────────
|
|
416
|
+
/** Parse codex JSONL output and extract agent message text. */
|
|
417
|
+
function parseCodexJsonl(raw) {
|
|
418
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
419
|
+
const texts = [];
|
|
420
|
+
let hasError = false;
|
|
421
|
+
for (const line of lines) {
|
|
422
|
+
try {
|
|
423
|
+
const event = JSON.parse(line);
|
|
424
|
+
const type = String(event.type ?? "");
|
|
425
|
+
// Extract text from completed agent messages
|
|
426
|
+
if (type === "item.completed") {
|
|
427
|
+
const item = event.item;
|
|
428
|
+
if (item && typeof item.text === "string" && item.text.trim()) {
|
|
429
|
+
texts.push(item.text.trim());
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// Capture errors
|
|
433
|
+
if (type === "error") {
|
|
434
|
+
const msg = String(event.message ?? "");
|
|
435
|
+
if (msg)
|
|
436
|
+
texts.push(`Error: ${msg}`);
|
|
437
|
+
hasError = true;
|
|
438
|
+
}
|
|
439
|
+
if (type === "turn.failed") {
|
|
440
|
+
hasError = true;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
// Not valid JSON — skip (truncated line or stderr mixed in)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (texts.length > 0) {
|
|
448
|
+
return { text: texts.join("\n\n"), success: !hasError };
|
|
449
|
+
}
|
|
450
|
+
return { text: raw.trim(), success: false };
|
|
451
|
+
}
|
|
452
|
+
function collectCodexOutput(child, startTime) {
|
|
62
453
|
return new Promise((resolve) => {
|
|
63
454
|
const chunks = [];
|
|
64
455
|
let totalBytes = 0;
|
|
@@ -70,19 +461,64 @@ export function dispatchToCLI(backend, prompt, model) {
|
|
|
70
461
|
settled = true;
|
|
71
462
|
resolve(result);
|
|
72
463
|
}
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
464
|
+
const timer = setTimeout(() => { closeChild(child); }, CLI_TIMEOUT);
|
|
465
|
+
const collectOutput = (data) => {
|
|
466
|
+
if (truncated)
|
|
467
|
+
return;
|
|
468
|
+
totalBytes += data.length;
|
|
469
|
+
if (totalBytes > MAX_OUTPUT_BYTES) {
|
|
470
|
+
truncated = true;
|
|
471
|
+
chunks.push(data.subarray(0, MAX_OUTPUT_BYTES - (totalBytes - data.length)));
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
chunks.push(data);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
child.stdout?.on("data", collectOutput);
|
|
478
|
+
child.stderr?.on("data", collectOutput);
|
|
479
|
+
child.on("close", (code) => {
|
|
480
|
+
clearTimeout(timer);
|
|
481
|
+
activeChild = null;
|
|
482
|
+
const durationMs = Date.now() - startTime;
|
|
483
|
+
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
484
|
+
const parsed = parseCodexJsonl(raw);
|
|
485
|
+
let text = parsed.text;
|
|
486
|
+
if (truncated)
|
|
487
|
+
text += "\n\n[Output truncated at 100KB]";
|
|
488
|
+
if (!text) {
|
|
489
|
+
text = code === 0
|
|
490
|
+
? "Task completed (no output)."
|
|
491
|
+
: `CLI process exited with code ${code}.`;
|
|
492
|
+
}
|
|
493
|
+
settle({ text, success: parsed.success && code === 0, durationMs });
|
|
494
|
+
});
|
|
495
|
+
child.on("error", (err) => {
|
|
496
|
+
clearTimeout(timer);
|
|
497
|
+
activeChild = null;
|
|
498
|
+
settle({
|
|
499
|
+
text: `CLI launch failed: ${err.message}`,
|
|
500
|
+
success: false,
|
|
501
|
+
durationMs: Date.now() - startTime,
|
|
502
|
+
});
|
|
77
503
|
});
|
|
78
|
-
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
// ── Shared: Collect stdout/stderr from a child process ─────
|
|
507
|
+
function collectChildOutput(child, startTime) {
|
|
508
|
+
return new Promise((resolve) => {
|
|
509
|
+
const chunks = [];
|
|
510
|
+
let totalBytes = 0;
|
|
511
|
+
let truncated = false;
|
|
512
|
+
let settled = false;
|
|
513
|
+
function settle(result) {
|
|
514
|
+
if (settled)
|
|
515
|
+
return;
|
|
516
|
+
settled = true;
|
|
517
|
+
resolve(result);
|
|
518
|
+
}
|
|
79
519
|
// Timeout with two-stage kill
|
|
80
520
|
const timer = setTimeout(() => {
|
|
81
|
-
child
|
|
82
|
-
setTimeout(() => {
|
|
83
|
-
if (!child.killed)
|
|
84
|
-
child.kill("SIGKILL");
|
|
85
|
-
}, SIGKILL_DELAY);
|
|
521
|
+
closeChild(child);
|
|
86
522
|
}, CLI_TIMEOUT);
|
|
87
523
|
const collectOutput = (data) => {
|
|
88
524
|
if (truncated)
|
|
@@ -124,6 +560,7 @@ export function dispatchToCLI(backend, prompt, model) {
|
|
|
124
560
|
});
|
|
125
561
|
});
|
|
126
562
|
}
|
|
563
|
+
// ── Reply ──────────────────────────────────────────────────
|
|
127
564
|
export async function postReply(config, promptId, text) {
|
|
128
565
|
try {
|
|
129
566
|
const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/prompts/${encodeURIComponent(promptId)}/reply`;
|
package/dist/daemon/index.js
CHANGED
|
@@ -28,7 +28,7 @@ async function processPrompt(config, prompt) {
|
|
|
28
28
|
}
|
|
29
29
|
const preview = prompt.text.length > 40 ? prompt.text.slice(0, 40) + "..." : prompt.text;
|
|
30
30
|
log(`[relay] Prompt: "${preview}" → dispatching to ${activeBackend}...`);
|
|
31
|
-
const result = await dispatchToCLI(activeBackend, prompt.text);
|
|
31
|
+
const result = await dispatchToCLI(activeBackend, prompt.text, log);
|
|
32
32
|
const ok = await postReply(config, prompt.id, result.text);
|
|
33
33
|
const dur = (result.durationMs / 1000).toFixed(1);
|
|
34
34
|
if (ok) {
|
|
@@ -158,10 +158,22 @@ export async function startDaemon(options) {
|
|
|
158
158
|
// Load backend
|
|
159
159
|
const backendConfig = loadBackendConfig();
|
|
160
160
|
activeBackend = backendConfig.activeBackend ?? null;
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
161
|
+
// MCP sessions: each client gets its own McpServer + Transport pair
|
|
162
|
+
// because McpServer.connect() can only be called once per instance
|
|
163
|
+
const MAX_MCP_SESSIONS = 20;
|
|
164
|
+
const SESSION_IDLE_TIMEOUT = 30 * 60 * 1000; // 30 minutes
|
|
165
|
+
const mcpSessions = new Map();
|
|
166
|
+
// Evict idle MCP sessions periodically
|
|
167
|
+
const sessionCleanupTimer = setInterval(() => {
|
|
168
|
+
const now = Date.now();
|
|
169
|
+
for (const [sid, session] of mcpSessions) {
|
|
170
|
+
if (session.activeRequests === 0 && now - session.lastActivity > SESSION_IDLE_TIMEOUT) {
|
|
171
|
+
log(`[mcp] Evicting idle session: ${sid.slice(0, 8)}...`);
|
|
172
|
+
session.transport.close().catch(() => { });
|
|
173
|
+
mcpSessions.delete(sid);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}, 60_000);
|
|
165
177
|
// Create HTTP server
|
|
166
178
|
const httpServer = createHTTPServer(async (req, res) => {
|
|
167
179
|
// Internal API
|
|
@@ -172,36 +184,63 @@ export async function startDaemon(options) {
|
|
|
172
184
|
res.end();
|
|
173
185
|
return;
|
|
174
186
|
}
|
|
175
|
-
// MCP endpoint
|
|
187
|
+
// MCP endpoint — per-session server + transport
|
|
176
188
|
if (req.url === "/mcp" || req.url?.startsWith("/mcp")) {
|
|
177
189
|
try {
|
|
178
|
-
// Check for existing session
|
|
179
190
|
const sessionId = req.headers["mcp-session-id"];
|
|
180
|
-
if (sessionId &&
|
|
181
|
-
//
|
|
182
|
-
const
|
|
183
|
-
|
|
191
|
+
if (sessionId && mcpSessions.has(sessionId)) {
|
|
192
|
+
// Existing session
|
|
193
|
+
const session = mcpSessions.get(sessionId);
|
|
194
|
+
session.lastActivity = Date.now();
|
|
195
|
+
session.activeRequests++;
|
|
196
|
+
try {
|
|
197
|
+
await session.transport.handleRequest(req, res);
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
session.activeRequests--;
|
|
201
|
+
}
|
|
184
202
|
}
|
|
185
|
-
else if (
|
|
186
|
-
// New session: create
|
|
203
|
+
else if (!sessionId) {
|
|
204
|
+
// New session: create dedicated McpServer + Transport
|
|
205
|
+
const server = createMcpServer(options?.deviceName);
|
|
187
206
|
const transport = new StreamableHTTPServerTransport({
|
|
188
207
|
sessionIdGenerator: () => randomUUID(),
|
|
189
208
|
onsessioninitialized: (sid) => {
|
|
190
|
-
|
|
209
|
+
// Evict oldest session if at capacity
|
|
210
|
+
if (mcpSessions.size >= MAX_MCP_SESSIONS) {
|
|
211
|
+
let oldestSid = null;
|
|
212
|
+
let oldestTime = Infinity;
|
|
213
|
+
for (const [s, sess] of mcpSessions) {
|
|
214
|
+
if (sess.activeRequests === 0 && sess.lastActivity < oldestTime) {
|
|
215
|
+
oldestTime = sess.lastActivity;
|
|
216
|
+
oldestSid = s;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (oldestSid) {
|
|
220
|
+
log(`[mcp] Evicting oldest session (at cap): ${oldestSid.slice(0, 8)}...`);
|
|
221
|
+
mcpSessions.get(oldestSid)?.transport.close().catch(() => { });
|
|
222
|
+
mcpSessions.delete(oldestSid);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
mcpSessions.set(sid, { server, transport, lastActivity: Date.now(), activeRequests: 0 });
|
|
191
226
|
log(`[mcp] Session started: ${sid.slice(0, 8)}...`);
|
|
192
227
|
},
|
|
193
228
|
onsessionclosed: (sid) => {
|
|
194
|
-
|
|
229
|
+
mcpSessions.delete(sid);
|
|
195
230
|
log(`[mcp] Session closed: ${sid.slice(0, 8)}...`);
|
|
196
231
|
},
|
|
197
232
|
});
|
|
198
|
-
await
|
|
233
|
+
await server.connect(transport);
|
|
199
234
|
await transport.handleRequest(req, res);
|
|
200
235
|
}
|
|
201
236
|
else {
|
|
202
|
-
// Unknown session ID
|
|
237
|
+
// Unknown/expired session ID
|
|
203
238
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
204
|
-
res.end(JSON.stringify({
|
|
239
|
+
res.end(JSON.stringify({
|
|
240
|
+
jsonrpc: "2.0",
|
|
241
|
+
error: { code: -32000, message: "Invalid or expired session" },
|
|
242
|
+
id: null,
|
|
243
|
+
}));
|
|
205
244
|
}
|
|
206
245
|
}
|
|
207
246
|
catch (err) {
|
|
@@ -249,15 +288,17 @@ export async function startDaemon(options) {
|
|
|
249
288
|
log("\nShutting down...");
|
|
250
289
|
promptListener.stop();
|
|
251
290
|
stopHeartbeatLoop();
|
|
291
|
+
clearInterval(sessionCleanupTimer);
|
|
252
292
|
await killActiveChild();
|
|
253
293
|
await sendBrainOffline(config);
|
|
254
|
-
// Close all
|
|
255
|
-
for (const
|
|
294
|
+
// Close all MCP sessions
|
|
295
|
+
for (const session of mcpSessions.values()) {
|
|
256
296
|
try {
|
|
257
|
-
await transport.close();
|
|
297
|
+
await session.transport.close();
|
|
258
298
|
}
|
|
259
299
|
catch { /* ignore */ }
|
|
260
300
|
}
|
|
301
|
+
mcpSessions.clear();
|
|
261
302
|
httpServer.close();
|
|
262
303
|
removePid();
|
|
263
304
|
log("Daemon stopped.");
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { controlSchema, screenshotSchema, pairSchema } from "./tools/schemas.js"
|
|
|
5
5
|
import { executeControl } from "./tools/control.js";
|
|
6
6
|
import { handleScreenshot } from "./tools/screenshot.js";
|
|
7
7
|
import { handlePair } from "./tools/pair.js";
|
|
8
|
-
const PACKAGE_VERSION = "0.
|
|
8
|
+
const PACKAGE_VERSION = "0.17.0";
|
|
9
9
|
export function createServer(deviceName) {
|
|
10
10
|
const server = new McpServer({
|
|
11
11
|
name: "zhihand",
|