pi-maestro-mobile 0.1.2
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/README.md +85 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +84 -0
- package/dist/cli.js.map +1 -0
- package/dist/event-log.d.ts +18 -0
- package/dist/event-log.d.ts.map +1 -0
- package/dist/event-log.js +31 -0
- package/dist/event-log.js.map +1 -0
- package/dist/extension.d.ts +3 -0
- package/dist/extension.d.ts.map +1 -0
- package/dist/extension.js +118 -0
- package/dist/extension.js.map +1 -0
- package/dist/host-controller.d.ts +69 -0
- package/dist/host-controller.d.ts.map +1 -0
- package/dist/host-controller.js +195 -0
- package/dist/host-controller.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/jsonl-index.d.ts +19 -0
- package/dist/jsonl-index.d.ts.map +1 -0
- package/dist/jsonl-index.js +84 -0
- package/dist/jsonl-index.js.map +1 -0
- package/dist/jsonl-pager.d.ts +29 -0
- package/dist/jsonl-pager.d.ts.map +1 -0
- package/dist/jsonl-pager.js +237 -0
- package/dist/jsonl-pager.js.map +1 -0
- package/dist/jsonl-replay.d.ts +9 -0
- package/dist/jsonl-replay.d.ts.map +1 -0
- package/dist/jsonl-replay.js +140 -0
- package/dist/jsonl-replay.js.map +1 -0
- package/dist/live-sessions.d.ts +47 -0
- package/dist/live-sessions.d.ts.map +1 -0
- package/dist/live-sessions.js +201 -0
- package/dist/live-sessions.js.map +1 -0
- package/dist/maestro-settings.d.ts +40 -0
- package/dist/maestro-settings.d.ts.map +1 -0
- package/dist/maestro-settings.js +122 -0
- package/dist/maestro-settings.js.map +1 -0
- package/dist/maestro-state.d.ts +31 -0
- package/dist/maestro-state.d.ts.map +1 -0
- package/dist/maestro-state.js +219 -0
- package/dist/maestro-state.js.map +1 -0
- package/dist/mobile-agent.d.ts +86 -0
- package/dist/mobile-agent.d.ts.map +1 -0
- package/dist/mobile-agent.js +2 -0
- package/dist/mobile-agent.js.map +1 -0
- package/dist/mobile-ui-context.d.ts +26 -0
- package/dist/mobile-ui-context.d.ts.map +1 -0
- package/dist/mobile-ui-context.js +164 -0
- package/dist/mobile-ui-context.js.map +1 -0
- package/dist/monitor-projection.d.ts +19 -0
- package/dist/monitor-projection.d.ts.map +1 -0
- package/dist/monitor-projection.js +79 -0
- package/dist/monitor-projection.js.map +1 -0
- package/dist/pi/pi-sdk-runtime.d.ts +17 -0
- package/dist/pi/pi-sdk-runtime.d.ts.map +1 -0
- package/dist/pi/pi-sdk-runtime.js +57 -0
- package/dist/pi/pi-sdk-runtime.js.map +1 -0
- package/dist/server/helpers.d.ts +23 -0
- package/dist/server/helpers.d.ts.map +1 -0
- package/dist/server/helpers.js +179 -0
- package/dist/server/helpers.js.map +1 -0
- package/dist/server/mobile-host-server.d.ts +43 -0
- package/dist/server/mobile-host-server.d.ts.map +1 -0
- package/dist/server/mobile-host-server.js +715 -0
- package/dist/server/mobile-host-server.js.map +1 -0
- package/dist/server/ws-command-handlers.d.ts +18 -0
- package/dist/server/ws-command-handlers.d.ts.map +1 -0
- package/dist/server/ws-command-handlers.js +173 -0
- package/dist/server/ws-command-handlers.js.map +1 -0
- package/dist/session-runner.d.ts +130 -0
- package/dist/session-runner.d.ts.map +1 -0
- package/dist/session-runner.js +576 -0
- package/dist/session-runner.js.map +1 -0
- package/dist/types.d.ts +83 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/usage-reader.d.ts +49 -0
- package/dist/usage-reader.d.ts.map +1 -0
- package/dist/usage-reader.js +189 -0
- package/dist/usage-reader.js.map +1 -0
- package/dist/vendor/shared/index.d.ts +4 -0
- package/dist/vendor/shared/index.d.ts.map +1 -0
- package/dist/vendor/shared/index.js +4 -0
- package/dist/vendor/shared/index.js.map +1 -0
- package/dist/vendor/shared/protocol.d.ts +471 -0
- package/dist/vendor/shared/protocol.d.ts.map +1 -0
- package/dist/vendor/shared/protocol.js +34 -0
- package/dist/vendor/shared/protocol.js.map +1 -0
- package/dist/vendor/shared/timeline.d.ts +15 -0
- package/dist/vendor/shared/timeline.d.ts.map +1 -0
- package/dist/vendor/shared/timeline.js +21 -0
- package/dist/vendor/shared/timeline.js.map +1 -0
- package/dist/vendor/shared/validation.d.ts +8 -0
- package/dist/vendor/shared/validation.d.ts.map +1 -0
- package/dist/vendor/shared/validation.js +25 -0
- package/dist/vendor/shared/validation.js.map +1 -0
- package/dist/version-detector.d.ts +36 -0
- package/dist/version-detector.d.ts.map +1 -0
- package/dist/version-detector.js +141 -0
- package/dist/version-detector.js.map +1 -0
- package/dist/workspace-telemetry.d.ts +10 -0
- package/dist/workspace-telemetry.d.ts.map +1 -0
- package/dist/workspace-telemetry.js +84 -0
- package/dist/workspace-telemetry.js.map +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { URL } from "node:url";
|
|
3
|
+
import { WebSocketServer } from "ws";
|
|
4
|
+
import { readFile, open, stat } from "node:fs/promises";
|
|
5
|
+
import { realpath } from "node:fs/promises";
|
|
6
|
+
import { isAbsolute, normalize } from "node:path";
|
|
7
|
+
import { homedir, tmpdir } from "node:os";
|
|
8
|
+
import { projectMonitorState } from "../monitor-projection.js";
|
|
9
|
+
import { readSettingsOverview, updateSettingsJson } from "../maestro-settings.js";
|
|
10
|
+
import { validateClientCommand } from "../vendor/shared/index.js";
|
|
11
|
+
/**
|
|
12
|
+
* MobileHostServer — 移动端直连服务器(HTTP REST + WebSocket)
|
|
13
|
+
*
|
|
14
|
+
* 直连模式:同一局域网/同机,无 relay。
|
|
15
|
+
* - WS: /ws 实时事件流 + 客户端命令
|
|
16
|
+
* - HTTP: /api/health, /api/status, /api/sessions, /api/maestro
|
|
17
|
+
*
|
|
18
|
+
* 安全:token 可选(Bearer header 或 ?token=)
|
|
19
|
+
*/
|
|
20
|
+
export class MobileHostServer {
|
|
21
|
+
controller;
|
|
22
|
+
options;
|
|
23
|
+
server;
|
|
24
|
+
webSocketServer = new WebSocketServer({ noServer: true, maxPayload: 8 * 1024 * 1024 });
|
|
25
|
+
clients = new Set();
|
|
26
|
+
/** 单客户端并发命令上限:超过则拒绝,防命令洪泛(DoS) */
|
|
27
|
+
static MAX_CONNECTIONS = 32;
|
|
28
|
+
static MAX_CONCURRENT_COMMANDS = 8;
|
|
29
|
+
unsubscribeController;
|
|
30
|
+
boundHost = "0.0.0.0";
|
|
31
|
+
constructor(controller, options = {}) {
|
|
32
|
+
this.controller = controller;
|
|
33
|
+
this.options = options;
|
|
34
|
+
this.server = createServer((request, response) => {
|
|
35
|
+
void this.handleHttp(request, response);
|
|
36
|
+
});
|
|
37
|
+
this.server.on("upgrade", (request, socket, head) => {
|
|
38
|
+
this.handleUpgrade(request, socket, head);
|
|
39
|
+
});
|
|
40
|
+
this.webSocketServer.on("connection", (ws) => {
|
|
41
|
+
if (this.clients.size >= MobileHostServer.MAX_CONNECTIONS) {
|
|
42
|
+
ws.close(1013, "too many connections");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const client = { id: crypto.randomUUID(), ws, inflight: 0 };
|
|
46
|
+
this.clients.add(client);
|
|
47
|
+
// P2-1:host_status 契约是 status: string;HostStatus 对象走独立的 host_info 事件
|
|
48
|
+
ws.send(JSON.stringify({ type: "host_status", status: "connected", seq: 0 }));
|
|
49
|
+
ws.send(JSON.stringify({ type: "host_info", info: this.controller.getStatus(), seq: 0 }));
|
|
50
|
+
ws.on("message", (data) => {
|
|
51
|
+
if (client.inflight >= MobileHostServer.MAX_CONCURRENT_COMMANDS) {
|
|
52
|
+
this.sendError(client, "too_many_commands", "");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
client.inflight++;
|
|
56
|
+
void this.handleClientMessage(client, data).finally(() => {
|
|
57
|
+
client.inflight--;
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
ws.on("close", () => {
|
|
61
|
+
this.clients.delete(client);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
this.unsubscribeController = this.controller.onEvent((event) => {
|
|
65
|
+
const payload = JSON.stringify(event);
|
|
66
|
+
for (const client of this.clients) {
|
|
67
|
+
if (client.ws.readyState === client.ws.OPEN) {
|
|
68
|
+
client.ws.send(payload);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
listen(port, hostname = "0.0.0.0") {
|
|
74
|
+
this.boundHost = hostname;
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
this.server.listen(port, hostname, () => resolve());
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
address() {
|
|
80
|
+
const address = this.server.address();
|
|
81
|
+
if (!address || typeof address === "string") {
|
|
82
|
+
throw new Error("Server is not listening on a TCP port");
|
|
83
|
+
}
|
|
84
|
+
return { port: address.port };
|
|
85
|
+
}
|
|
86
|
+
async close() {
|
|
87
|
+
this.unsubscribeController?.();
|
|
88
|
+
for (const client of this.clients) {
|
|
89
|
+
client.ws.close();
|
|
90
|
+
}
|
|
91
|
+
this.clients.clear();
|
|
92
|
+
this.webSocketServer.close();
|
|
93
|
+
await new Promise((resolve, reject) => {
|
|
94
|
+
this.server.close((error) => (error ? reject(error) : resolve()));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
// ── HTTP ──────────────────────────────────────────────────────────────────
|
|
98
|
+
async handleHttp(request, response) {
|
|
99
|
+
try {
|
|
100
|
+
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
101
|
+
applyCorsHeaders(response, this.options.corsOrigin);
|
|
102
|
+
if (request.method === "OPTIONS") {
|
|
103
|
+
response.statusCode = 204;
|
|
104
|
+
response.end();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!this.authorized(request, url)) {
|
|
108
|
+
writeJson(response, 401, { error: "Unauthorized" });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (request.method === "GET" && url.pathname === "/api/health") {
|
|
112
|
+
writeJson(response, 200, { ok: true });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (request.method === "GET" && url.pathname === "/api/status") {
|
|
116
|
+
const status = this.controller.getStatus();
|
|
117
|
+
writeJson(response, 200, status);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (request.method === "GET" && url.pathname === "/api/sessions") {
|
|
121
|
+
const cwd = url.searchParams.get("cwd") ?? undefined;
|
|
122
|
+
const sessions = await this.controller.listSessions(cwd);
|
|
123
|
+
const list = await toSessionSummaryList(sessions);
|
|
124
|
+
writeJson(response, 200, list);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (request.method === "GET" && url.pathname === "/api/maestro") {
|
|
128
|
+
const state = await this.controller.readMaestroStateNow();
|
|
129
|
+
writeJson(response, 200, state);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (request.method === "GET" && url.pathname === "/api/maestro-settings") {
|
|
133
|
+
const overview = await readSettingsOverview();
|
|
134
|
+
writeJson(response, 200, overview);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (request.method === "GET" && url.pathname === "/api/workspace-telemetry") {
|
|
138
|
+
const telemetry = await this.controller.readTelemetry();
|
|
139
|
+
writeJson(response, 200, telemetry);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (request.method === "GET" && url.pathname === "/api/live-sessions") {
|
|
143
|
+
const list = await this.controller.listLiveSessions();
|
|
144
|
+
writeJson(response, 200, list);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// 图片/文件只读预览:绝对路径 + 图片扩展名 + 大小限制
|
|
148
|
+
if (request.method === "GET" && url.pathname === "/api/file") {
|
|
149
|
+
const filePath = url.searchParams.get("path") ?? "";
|
|
150
|
+
const result = await serveImageFile(filePath);
|
|
151
|
+
if (!result) {
|
|
152
|
+
writeJson(response, 400, { error: "Invalid or unsupported file path" });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const { data, mime } = result;
|
|
156
|
+
response.writeHead(200, {
|
|
157
|
+
"Content-Type": mime,
|
|
158
|
+
"Content-Length": data.length,
|
|
159
|
+
"Cache-Control": "private, max-age=300",
|
|
160
|
+
});
|
|
161
|
+
response.end(data);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (request.method === "GET" && url.pathname === "/api/extension-ui/pending") {
|
|
165
|
+
writeJson(response, 200, { pending: 0 });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
writeJson(response, 404, { error: `No route for ${request.method} ${url.pathname}` });
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
172
|
+
writeJson(response, 500, { error: message });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
authorized(request, url) {
|
|
176
|
+
const token = this.options.token;
|
|
177
|
+
if (!token)
|
|
178
|
+
return true;
|
|
179
|
+
const header = request.headers.authorization;
|
|
180
|
+
if (header && header.startsWith("Bearer ")) {
|
|
181
|
+
return header.slice("Bearer ".length).trim() === token;
|
|
182
|
+
}
|
|
183
|
+
return url.searchParams.get("token") === token;
|
|
184
|
+
}
|
|
185
|
+
handleUpgrade(request, socket, head) {
|
|
186
|
+
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
187
|
+
if (url.pathname !== "/ws") {
|
|
188
|
+
socket.destroy();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// P0-1:浏览器发起的 WS(带 Origin)必须来自白名单,防恶意网页 drive-by 连接;
|
|
192
|
+
// 非浏览器客户端(RN fetch/ws)通常无 Origin,放行后仍由 token 鉴权把关。
|
|
193
|
+
if (!this.isOriginAllowed(request.headers.origin)) {
|
|
194
|
+
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
195
|
+
socket.destroy();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (!this.authorized(request, url)) {
|
|
199
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
200
|
+
socket.destroy();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
this.webSocketServer.handleUpgrade(request, socket, head, (ws) => {
|
|
204
|
+
this.webSocketServer.emit("connection", ws, request);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** WS 握手 Origin 白名单:loopback 变体 + 绑定 host(非 0.0.0.0 时)+ 显式配置 */
|
|
208
|
+
isOriginAllowed(origin) {
|
|
209
|
+
if (!origin)
|
|
210
|
+
return true; // 非浏览器客户端
|
|
211
|
+
let parsed;
|
|
212
|
+
try {
|
|
213
|
+
parsed = new URL(origin);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
219
|
+
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]") {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
const bound = this.boundHost.toLowerCase();
|
|
223
|
+
if (bound !== "0.0.0.0" && bound !== "::" && hostname === bound) {
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
const allowed = this.options.allowedOrigins ?? [];
|
|
227
|
+
return allowed.some((o) => {
|
|
228
|
+
try {
|
|
229
|
+
return new URL(o).hostname.toLowerCase() === hostname;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
// ── WS 命令 ───────────────────────────────────────────────────────────────
|
|
237
|
+
async handleClientMessage(client, data) {
|
|
238
|
+
let command;
|
|
239
|
+
try {
|
|
240
|
+
command = JSON.parse(data.toString());
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
client.ws.send(JSON.stringify({ type: "error", code: "invalid_json", message: "Invalid JSON" }));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
// P3-2:分发前真正走 shared 校验(激活 validation 模块,拦截缺 type 的任意载荷)
|
|
247
|
+
try {
|
|
248
|
+
command = validateClientCommand(command);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
client.ws.send(JSON.stringify({ type: "error", code: "invalid_command", message: "Invalid ClientCommand: missing type" }));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
switch (command.type) {
|
|
256
|
+
case "list_live_sessions": {
|
|
257
|
+
const list = await this.controller.listLiveSessions();
|
|
258
|
+
this.sendAck(client, command, list);
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
case "load_more_history": {
|
|
262
|
+
const runner = this.controller.getSession(command.sessionId);
|
|
263
|
+
if (!runner) {
|
|
264
|
+
this.sendError(client, "session_not_found", command.id ?? "");
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
const result = await runner.loadMoreHistory(command.count);
|
|
268
|
+
this.sendAck(client, command, result);
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
case "search_history": {
|
|
272
|
+
const runner = this.controller.getSession(command.sessionId);
|
|
273
|
+
if (!runner) {
|
|
274
|
+
this.sendError(client, "session_not_found", command.id ?? "");
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
const result = await runner.searchHistory(command.keyword, command.maxResults, command.previewLength);
|
|
278
|
+
this.sendAck(client, command, result);
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
case "list_models": {
|
|
282
|
+
const runner = this.controller.getSession(command.sessionId);
|
|
283
|
+
if (!runner) {
|
|
284
|
+
this.sendError(client, "session_not_found", command.id ?? "");
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
const models = typeof runner.listModels === "function" ? runner.listModels() : [];
|
|
288
|
+
this.sendAck(client, command, models);
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
case "list_skills": {
|
|
292
|
+
const runner = this.controller.getSession(command.sessionId);
|
|
293
|
+
if (!runner) {
|
|
294
|
+
this.sendError(client, "session_not_found", command.id ?? "");
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
// 优先走 SDK resourceLoader(与 TUI 一致),回退到文件扫描
|
|
298
|
+
const loaded = typeof runner.listLoadedSkills === "function" ? runner.listLoadedSkills() : [];
|
|
299
|
+
const skills = loaded.length > 0 ? loaded : await listSkills(runner.state.cwd);
|
|
300
|
+
this.sendAck(client, command, skills);
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
case "get_maestro_settings": {
|
|
304
|
+
const overview = await readSettingsOverview();
|
|
305
|
+
this.sendAck(client, command, overview);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
case "update_maestro_settings": {
|
|
309
|
+
if (command.key !== "settings") {
|
|
310
|
+
this.sendError(client, "unsupported_key", undefined, command.id ?? "");
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
const result = await updateSettingsJson(command.patch);
|
|
314
|
+
this.sendAck(client, command, result);
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
case "set_model": {
|
|
318
|
+
const runner = this.controller.getSession(command.sessionId);
|
|
319
|
+
if (!runner) {
|
|
320
|
+
this.sendError(client, "session_not_found", command.id ?? "");
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
if (typeof runner.setModel !== "function") {
|
|
324
|
+
this.sendError(client, "unsupported_command", undefined, command.id ?? "");
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
const result = await runner.setModel(command.modelId);
|
|
328
|
+
this.sendAck(client, command, result);
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
case "set_thinking": {
|
|
332
|
+
const runner = this.controller.getSession(command.sessionId);
|
|
333
|
+
if (!runner) {
|
|
334
|
+
this.sendError(client, "session_not_found", command.id ?? "");
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
if (typeof runner.setThinking !== "function") {
|
|
338
|
+
this.sendError(client, "unsupported_command", undefined, command.id ?? "");
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
const result = runner.setThinking(command.level);
|
|
342
|
+
this.sendAck(client, command, result);
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
case "compact": {
|
|
346
|
+
const runner = this.controller.getSession(command.sessionId);
|
|
347
|
+
if (!runner) {
|
|
348
|
+
this.sendError(client, "session_not_found", command.id ?? "");
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
if (typeof runner.compact !== "function") {
|
|
352
|
+
this.sendError(client, "unsupported_command", undefined, command.id ?? "");
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
const result = await runner.compact(command.customInstructions);
|
|
356
|
+
this.sendAck(client, command, result);
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
case "rename_session": {
|
|
360
|
+
const runner = this.controller.getSession(command.sessionId);
|
|
361
|
+
if (!runner) {
|
|
362
|
+
this.sendError(client, "session_not_found", command.id ?? "");
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
if (typeof runner.renameSession !== "function") {
|
|
366
|
+
this.sendError(client, "unsupported_command", undefined, command.id ?? "");
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
const result = runner.renameSession(command.name);
|
|
370
|
+
this.sendAck(client, command, result);
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
case "list_host_sessions": {
|
|
374
|
+
const sessions = await this.controller.listSessions(command.cwd);
|
|
375
|
+
const list = await toSessionSummaryList(sessions);
|
|
376
|
+
this.sendAck(client, command, list);
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
case "open_session": {
|
|
380
|
+
const runner = await this.controller.openSession({
|
|
381
|
+
cwd: command.cwd,
|
|
382
|
+
mode: command.mode,
|
|
383
|
+
sessionFile: command.sessionFile,
|
|
384
|
+
});
|
|
385
|
+
this.sendAck(client, command, { sessionId: runner.id });
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
case "close_session": {
|
|
389
|
+
await this.controller.closeSession(command.sessionId);
|
|
390
|
+
this.sendAck(client, command, { closed: true });
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
case "prompt": {
|
|
394
|
+
const runner = this.controller.getSession(command.sessionId);
|
|
395
|
+
if (!runner) {
|
|
396
|
+
this.sendError(client, "session_not_found", command.id ?? "");
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
// P1-3:透传图片(此前被静默丢弃),非法元素显式报错而非静默丢失
|
|
400
|
+
const images = command.images?.map((img) => toSdkImageContent(img)).filter((x) => x !== undefined);
|
|
401
|
+
if (command.images && command.images.length > 0 && images?.length !== command.images.length) {
|
|
402
|
+
this.sendError(client, "invalid_image", "images 元素必须是 base64 data 与 mime 字段齐全的图片", command.id ?? "");
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
await runner.prompt(command.message, undefined, images);
|
|
406
|
+
this.sendAck(client, command, {});
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
case "steer": {
|
|
410
|
+
const runner = this.controller.getSession(command.sessionId);
|
|
411
|
+
if (!runner) {
|
|
412
|
+
this.sendError(client, "session_not_found", command.id ?? "");
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
await runner.steer(command.message);
|
|
416
|
+
this.sendAck(client, command, {});
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
case "steer_window": {
|
|
420
|
+
// 监督会话跨窗口发送:已打开 → 直接 steer;未打开 → 接管(open_session continue)后 steer。
|
|
421
|
+
// 接管语义:Host 打开的会话与原桌面 Pi 进程并行写同一 JSONL,移动端 UI 必须明示「接管并发送」。
|
|
422
|
+
const existing = this.controller.getSession(command.endpointId);
|
|
423
|
+
if (existing) {
|
|
424
|
+
await existing.steer(command.message);
|
|
425
|
+
this.sendAck(client, command, { ok: true, sessionId: command.endpointId, tookOver: false });
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
const runner = await this.controller.openSession({ cwd: command.cwd, mode: "continue", sessionFile: undefined });
|
|
430
|
+
await runner.steer(command.message);
|
|
431
|
+
this.sendAck(client, command, { ok: true, sessionId: runner.id, tookOver: true });
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
435
|
+
this.sendAck(client, command, { ok: false, sessionId: command.endpointId, tookOver: false, error: message });
|
|
436
|
+
}
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
case "follow_up": {
|
|
440
|
+
const runner = this.controller.getSession(command.sessionId);
|
|
441
|
+
if (!runner) {
|
|
442
|
+
this.sendError(client, "session_not_found", command.id ?? "");
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
await runner.followUp(command.message);
|
|
446
|
+
this.sendAck(client, command, {});
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
case "abort": {
|
|
450
|
+
const runner = this.controller.getSession(command.sessionId);
|
|
451
|
+
if (!runner) {
|
|
452
|
+
this.sendError(client, "session_not_found", command.id ?? "");
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
await runner.abort();
|
|
456
|
+
this.sendAck(client, command, {});
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
case "extension_ui_response": {
|
|
460
|
+
const ok = this.controller.respondToExtensionUi(command.sessionId, command.requestId, command.response);
|
|
461
|
+
if (ok) {
|
|
462
|
+
this.sendAck(client, command, {});
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
this.sendError(client, "request_not_found", command.id ?? "");
|
|
466
|
+
}
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
case "get_maestro_state": {
|
|
470
|
+
const state = await this.controller.readMaestroStateNow();
|
|
471
|
+
this.sendAck(client, command, state);
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
case "get_monitor_state": {
|
|
475
|
+
// 与推送路径共用同一投影,避免双投影漂移
|
|
476
|
+
const telemetry = await this.controller.readTelemetry();
|
|
477
|
+
this.sendAck(client, command, projectMonitorState(telemetry));
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
case "get_snapshot": {
|
|
481
|
+
const runner = this.controller.getSession(command.sessionId);
|
|
482
|
+
if (!runner) {
|
|
483
|
+
this.sendError(client, "session_not_found", command.id ?? "");
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
const snapshot = runner.snapshot();
|
|
487
|
+
client.ws.send(JSON.stringify({
|
|
488
|
+
type: "command_result",
|
|
489
|
+
in_reply_to: command.id ?? "",
|
|
490
|
+
ok: true,
|
|
491
|
+
result: snapshot,
|
|
492
|
+
}));
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
case "get_session_usage": {
|
|
496
|
+
const runner = this.controller.getSession(command.sessionId);
|
|
497
|
+
if (!runner) {
|
|
498
|
+
this.sendError(client, "session_not_found", command.id ?? "");
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
const usage = typeof runner.getUsage === "function" ? await runner.getUsage() : { entries: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, totalTokens: 0, cost: 0 };
|
|
502
|
+
const context = typeof runner.getContextUsage === "function" ? runner.getContextUsage() ?? null : null;
|
|
503
|
+
this.sendAck(client, command, { sessionId: command.sessionId, ...usage, context });
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
default:
|
|
507
|
+
this.sendError(client, "unsupported_command", command.id ?? "");
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
catch (error) {
|
|
511
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
512
|
+
this.sendError(client, "command_failed", message);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
sendAck(client, command, result) {
|
|
516
|
+
client.ws.send(JSON.stringify({
|
|
517
|
+
type: "command_result",
|
|
518
|
+
in_reply_to: command.id ?? "",
|
|
519
|
+
ok: true,
|
|
520
|
+
result,
|
|
521
|
+
}));
|
|
522
|
+
}
|
|
523
|
+
sendError(client, code, message, replyTo = "") {
|
|
524
|
+
client.ws.send(JSON.stringify({
|
|
525
|
+
type: "command_result",
|
|
526
|
+
in_reply_to: replyTo,
|
|
527
|
+
ok: false,
|
|
528
|
+
error: { code, message: message ?? code },
|
|
529
|
+
}));
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
function writeJson(response, status, body) {
|
|
533
|
+
response.writeHead(status, {
|
|
534
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
535
|
+
"Cache-Control": "no-store",
|
|
536
|
+
});
|
|
537
|
+
response.end(JSON.stringify(body));
|
|
538
|
+
}
|
|
539
|
+
function applyCorsHeaders(response, origin) {
|
|
540
|
+
if (origin) {
|
|
541
|
+
response.setHeader("Access-Control-Allow-Origin", origin);
|
|
542
|
+
response.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
|
543
|
+
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* 将 SessionManager 返回的完整 SessionInfo 裁剪为移动端友好的摘要。
|
|
548
|
+
* 关键:不携带 allMessagesText 等大字段,避免移动端流量/内存浪费。
|
|
549
|
+
*/
|
|
550
|
+
async function toSessionSummaryList(records) {
|
|
551
|
+
const sessions = [];
|
|
552
|
+
for (const raw of records) {
|
|
553
|
+
const r = raw;
|
|
554
|
+
const cwd = String(r.cwd ?? "");
|
|
555
|
+
const title = String(r.firstMessage ?? r.title ?? "");
|
|
556
|
+
const id = String(r.id ?? "");
|
|
557
|
+
const path = String(r.path ?? r.sessionFile ?? "");
|
|
558
|
+
sessions.push({
|
|
559
|
+
id,
|
|
560
|
+
cwd,
|
|
561
|
+
cwdName: cwd.split("/").filter(Boolean).pop() ?? cwd,
|
|
562
|
+
path,
|
|
563
|
+
title: title.length > 80 ? `${title.slice(0, 80)}…` : title,
|
|
564
|
+
name: typeof r.name === "string" && r.name ? r.name : undefined,
|
|
565
|
+
model: await latestModelFromJsonl(path),
|
|
566
|
+
messageCount: typeof r.messageCount === "number" ? r.messageCount : 0,
|
|
567
|
+
// 规范化为 ISO 字符串:Hermes(iOS)解析不了 "Thu Sep 03 2026 ..." 这种本地化格式
|
|
568
|
+
updatedAt: normalizeIso(String(r.modified ?? r.updatedAt ?? "")),
|
|
569
|
+
...(r.created ? { createdAt: normalizeIso(String(r.created)) } : {}),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
return { sessions, observedAt: new Date().toISOString() };
|
|
573
|
+
}
|
|
574
|
+
/** 从 jsonl 里找最近的 model_change,返回 provider/modelId 精简名 */
|
|
575
|
+
async function latestModelFromJsonl(path) {
|
|
576
|
+
if (!path || !path.endsWith(".jsonl"))
|
|
577
|
+
return undefined;
|
|
578
|
+
try {
|
|
579
|
+
// 只读尾部 256KB(model_change 通常在会话活跃期靠后出现),避免整文件扫描
|
|
580
|
+
const handle = await open(path, "r");
|
|
581
|
+
try {
|
|
582
|
+
const { size } = await handle.stat();
|
|
583
|
+
const readLen = Math.min(TRAIL_READ_BYTES, size);
|
|
584
|
+
const buf = Buffer.alloc(readLen);
|
|
585
|
+
await handle.read(buf, 0, readLen, size - readLen);
|
|
586
|
+
const tail = buf.toString("utf8");
|
|
587
|
+
const lines = tail.split("\n");
|
|
588
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
589
|
+
const line = lines[i];
|
|
590
|
+
if (!line.includes("model_change"))
|
|
591
|
+
continue;
|
|
592
|
+
try {
|
|
593
|
+
const o = JSON.parse(line);
|
|
594
|
+
if (o.modelId) {
|
|
595
|
+
const provider = o.provider ? `${o.provider}/` : "";
|
|
596
|
+
return `${provider}${o.modelId}`;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
// ignore malformed
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return undefined;
|
|
604
|
+
}
|
|
605
|
+
finally {
|
|
606
|
+
await handle.close();
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
return undefined;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const TRAIL_READ_BYTES = 256 * 1024;
|
|
614
|
+
/** 尝试解析为 ISO;无法解析时保留原字符串(App 端需兜底) */
|
|
615
|
+
function normalizeIso(raw) {
|
|
616
|
+
if (!raw)
|
|
617
|
+
return "";
|
|
618
|
+
const t = Date.parse(raw);
|
|
619
|
+
return Number.isFinite(t) ? new Date(t).toISOString() : raw;
|
|
620
|
+
}
|
|
621
|
+
/** 协议 images 元素 → Pi SDK ImageContent(type/data/mimeType);非法返回 undefined */
|
|
622
|
+
function toSdkImageContent(img) {
|
|
623
|
+
if (typeof img?.data !== "string" || img.data.length === 0)
|
|
624
|
+
return undefined;
|
|
625
|
+
if (typeof img?.mime !== "string" || !img.mime.startsWith("image/"))
|
|
626
|
+
return undefined;
|
|
627
|
+
return { type: "image", data: img.data, mimeType: img.mime };
|
|
628
|
+
}
|
|
629
|
+
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"]);
|
|
630
|
+
const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20MB
|
|
631
|
+
const IMAGE_MIME = {
|
|
632
|
+
".png": "image/png",
|
|
633
|
+
".jpg": "image/jpeg",
|
|
634
|
+
".jpeg": "image/jpeg",
|
|
635
|
+
".gif": "image/gif",
|
|
636
|
+
".webp": "image/webp",
|
|
637
|
+
".bmp": "image/bmp",
|
|
638
|
+
};
|
|
639
|
+
/** stat 包装(抛错由调用方捕获) */
|
|
640
|
+
function fstat(p) {
|
|
641
|
+
return stat(p);
|
|
642
|
+
}
|
|
643
|
+
/** 安全读取本地图片:绝对路径 + 图片扩展名 + 大小限制 + realpath 防 symlink 绕过 */
|
|
644
|
+
async function serveImageFile(filePath) {
|
|
645
|
+
const trimmed = filePath.trim();
|
|
646
|
+
if (!trimmed || !isAbsolute(trimmed))
|
|
647
|
+
return undefined;
|
|
648
|
+
// 防止路径穿越:normalize 后必须仍是绝对路径且不含 ..
|
|
649
|
+
const normalized = normalize(trimmed);
|
|
650
|
+
if (!isAbsolute(normalized) || normalized.includes(".."))
|
|
651
|
+
return undefined;
|
|
652
|
+
const ext = normalized.slice(normalized.lastIndexOf(".")).toLowerCase();
|
|
653
|
+
if (!IMAGE_EXTENSIONS.has(ext))
|
|
654
|
+
return undefined;
|
|
655
|
+
try {
|
|
656
|
+
// P3-3:先解析真实路径,防止指向任意位置的 symlink 绕过绝对路径约束
|
|
657
|
+
const resolved = await realpath(normalized);
|
|
658
|
+
if (!(await isUnderAllowedRoot(resolved)))
|
|
659
|
+
return undefined;
|
|
660
|
+
// 先 stat 校验大小再读取,避免超大文件先耗尽内存
|
|
661
|
+
const stat = await fstat(resolved);
|
|
662
|
+
if (stat.size === 0 || stat.size > MAX_IMAGE_BYTES)
|
|
663
|
+
return undefined;
|
|
664
|
+
const data = await readFile(resolved);
|
|
665
|
+
return { data, mime: IMAGE_MIME[ext] ?? "application/octet-stream" };
|
|
666
|
+
}
|
|
667
|
+
catch {
|
|
668
|
+
return undefined;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
/** 允许读取的根目录:用户目录、系统临时目录、进程工作目录(会话产物、图片预览所在)。取 realpath 以兼容 /tmp → /private/tmp */
|
|
672
|
+
async function isUnderAllowedRoot(resolved) {
|
|
673
|
+
const roots = [homedir(), tmpdir(), process.cwd()];
|
|
674
|
+
for (const root of roots) {
|
|
675
|
+
try {
|
|
676
|
+
const rp = await realpath(root);
|
|
677
|
+
if (resolved === rp || resolved.startsWith(rp + "/"))
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
// root 不存在时跳过
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
/** 扫描可用的 skill 名录(agent 全局 + 项目本地) */
|
|
687
|
+
async function listSkills(cwd) {
|
|
688
|
+
const { readdir } = await import("node:fs/promises");
|
|
689
|
+
const { join } = await import("node:path");
|
|
690
|
+
const { homedir } = await import("node:os");
|
|
691
|
+
const dirs = [];
|
|
692
|
+
try {
|
|
693
|
+
dirs.push(join(homedir(), ".pi", "agent", "skills"));
|
|
694
|
+
}
|
|
695
|
+
catch { /* skip */ }
|
|
696
|
+
try {
|
|
697
|
+
dirs.push(join(cwd, ".pi", "skills"));
|
|
698
|
+
}
|
|
699
|
+
catch { /* skip */ }
|
|
700
|
+
const names = new Set();
|
|
701
|
+
for (const dir of dirs) {
|
|
702
|
+
try {
|
|
703
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
704
|
+
for (const e of entries) {
|
|
705
|
+
if (e.isDirectory() && !e.name.startsWith("."))
|
|
706
|
+
names.add(e.name);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
// skip
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return [...names].sort();
|
|
714
|
+
}
|
|
715
|
+
//# sourceMappingURL=mobile-host-server.js.map
|