pi-maestro-mobile 0.2.13 → 0.3.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.
Files changed (72) hide show
  1. package/dist/cli.js +85 -21
  2. package/dist/cli.js.map +1 -1
  3. package/dist/extension.d.ts.map +1 -1
  4. package/dist/extension.js +126 -96
  5. package/dist/extension.js.map +1 -1
  6. package/dist/host-controller.d.ts +3 -0
  7. package/dist/host-controller.d.ts.map +1 -1
  8. package/dist/host-controller.js +12 -0
  9. package/dist/host-controller.js.map +1 -1
  10. package/dist/jsonl-index.js +2 -2
  11. package/dist/jsonl-index.js.map +1 -1
  12. package/dist/jsonl-pager.d.ts.map +1 -1
  13. package/dist/jsonl-pager.js +68 -6
  14. package/dist/jsonl-pager.js.map +1 -1
  15. package/dist/jsonl-tail-watcher.d.ts +44 -0
  16. package/dist/jsonl-tail-watcher.d.ts.map +1 -0
  17. package/dist/jsonl-tail-watcher.js +275 -0
  18. package/dist/jsonl-tail-watcher.js.map +1 -0
  19. package/dist/live-sessions.d.ts.map +1 -1
  20. package/dist/live-sessions.js +22 -5
  21. package/dist/live-sessions.js.map +1 -1
  22. package/dist/maestro-state.d.ts.map +1 -1
  23. package/dist/maestro-state.js +19 -4
  24. package/dist/maestro-state.js.map +1 -1
  25. package/dist/mobile-ui-context.d.ts +17 -1
  26. package/dist/mobile-ui-context.d.ts.map +1 -1
  27. package/dist/mobile-ui-context.js +35 -7
  28. package/dist/mobile-ui-context.js.map +1 -1
  29. package/dist/pi/pi-sdk-runtime.d.ts.map +1 -1
  30. package/dist/pi/pi-sdk-runtime.js +8 -7
  31. package/dist/pi/pi-sdk-runtime.js.map +1 -1
  32. package/dist/server/helpers.d.ts +0 -11
  33. package/dist/server/helpers.d.ts.map +1 -1
  34. package/dist/server/helpers.js +24 -44
  35. package/dist/server/helpers.js.map +1 -1
  36. package/dist/server/mobile-host-server.d.ts +88 -1
  37. package/dist/server/mobile-host-server.d.ts.map +1 -1
  38. package/dist/server/mobile-host-server.js +434 -54
  39. package/dist/server/mobile-host-server.js.map +1 -1
  40. package/dist/session-runner.d.ts +22 -1
  41. package/dist/session-runner.d.ts.map +1 -1
  42. package/dist/session-runner.js +67 -5
  43. package/dist/session-runner.js.map +1 -1
  44. package/dist/types.d.ts +1 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/usage-reader.d.ts +6 -2
  47. package/dist/usage-reader.d.ts.map +1 -1
  48. package/dist/usage-reader.js +122 -27
  49. package/dist/usage-reader.js.map +1 -1
  50. package/dist/vendor/shared/protocol.d.ts +13 -0
  51. package/dist/vendor/shared/protocol.d.ts.map +1 -1
  52. package/dist/vendor/shared/protocol.js.map +1 -1
  53. package/dist/vendor/shared/validation.d.ts.map +1 -1
  54. package/dist/vendor/shared/validation.js +10 -0
  55. package/dist/vendor/shared/validation.js.map +1 -1
  56. package/dist/workspace-peer-injector.d.ts +19 -0
  57. package/dist/workspace-peer-injector.d.ts.map +1 -0
  58. package/dist/workspace-peer-injector.js +63 -0
  59. package/dist/workspace-peer-injector.js.map +1 -0
  60. package/dist/workspace-telemetry.d.ts +4 -1
  61. package/dist/workspace-telemetry.d.ts.map +1 -1
  62. package/dist/workspace-telemetry.js +20 -6
  63. package/dist/workspace-telemetry.js.map +1 -1
  64. package/package.json +1 -1
  65. package/dist/jsonl-replay.d.ts +0 -9
  66. package/dist/jsonl-replay.d.ts.map +0 -1
  67. package/dist/jsonl-replay.js +0 -153
  68. package/dist/jsonl-replay.js.map +0 -1
  69. package/dist/server/ws-command-handlers.d.ts +0 -20
  70. package/dist/server/ws-command-handlers.d.ts.map +0 -1
  71. package/dist/server/ws-command-handlers.js +0 -176
  72. package/dist/server/ws-command-handlers.js.map +0 -1
@@ -9,6 +9,7 @@ import { projectMonitorState } from "../monitor-projection.js";
9
9
  import { readSettingsOverview, updateSettingsJson } from "../maestro-settings.js";
10
10
  import { validateClientCommand } from "../vendor/shared/index.js";
11
11
  import { HostSessionListService } from "./helpers.js";
12
+ import { injectMessageToActiveTui } from "../workspace-peer-injector.js";
12
13
  /**
13
14
  * MobileHostServer — 移动端直连服务器(HTTP REST + WebSocket)
14
15
  *
@@ -22,62 +23,285 @@ export class MobileHostServer {
22
23
  controller;
23
24
  options;
24
25
  server;
25
- webSocketServer = new WebSocketServer({ noServer: true, maxPayload: 8 * 1024 * 1024 });
26
+ webSocketServer;
26
27
  clients = new Set();
27
28
  /** 单客户端并发命令上限:超过则拒绝,防命令洪泛(DoS) */
28
29
  static MAX_CONNECTIONS = 32;
29
30
  static MAX_CONCURRENT_COMMANDS = 8;
31
+ /** 软高水位:best_effort 帧越阈即丢;required 帧持续越阈达宽限期才断线。
32
+ * 必须显著低于硬顶,否则「required 宽限投递」区间被压缩到几乎不存在 */
33
+ static HIGH_WATER_MARK_BYTES = 1 * 1024 * 1024;
34
+ /** 硬上限:约束队列驻留(buffered + 新帧)。默认 32MB:实测合法最大帧(4000 条 snapshot)≈7.4MB,
35
+ * 留 4× 余量;单帧自身越顶不阻断(防重连死循环),只告警 */
36
+ static HARD_LIMIT_BYTES = 32 * 1024 * 1024;
37
+ static SLOW_GRACE_MS = 5_000;
38
+ static DROP_LOG_INTERVAL_MS = 60_000;
39
+ /** 连续未应答次数上限:3 = 容忍两次漏答(默认 30s 周期即约 90s 无响应/无数据才判定死连) */
40
+ static HEARTBEAT_MAX_MISSES = 3;
30
41
  unsubscribeController;
42
+ /** listen() 等待中的错误回调;非空表示正在绑定端口 */
43
+ listenError;
31
44
  boundHost = "0.0.0.0";
32
45
  hostSessionList = new HostSessionListService({
33
46
  indexPath: join(homedir(), ".pi", "agent", "mobile-session-index.json"),
34
47
  });
48
+ /** 背压参数(构造时从 options 解析,默认取静态常量) */
49
+ highWaterMarkBytes;
50
+ hardLimitBytes;
51
+ slowGraceMs;
35
52
  constructor(controller, options = {}) {
36
53
  this.controller = controller;
37
54
  this.options = options;
55
+ this.webSocketServer = new WebSocketServer({ noServer: true, maxPayload: this.options.maxPayload ?? 8 * 1024 * 1024 });
56
+ this.highWaterMarkBytes = this.options.highWaterMarkBytes ?? MobileHostServer.HIGH_WATER_MARK_BYTES;
57
+ this.hardLimitBytes = this.options.hardLimitBytes ?? MobileHostServer.HARD_LIMIT_BYTES;
58
+ this.slowGraceMs = this.options.slowGraceMs ?? MobileHostServer.SLOW_GRACE_MS;
38
59
  this.server = createServer((request, response) => {
39
60
  void this.handleHttp(request, response);
40
61
  });
41
62
  this.server.on("upgrade", (request, socket, head) => {
42
63
  this.handleUpgrade(request, socket, head);
43
64
  });
65
+ // 常驻单一 error 入口:启动期错误只交给 listen() 的 reject,以免与友好提示重复打印原始错误;
66
+ // listen 成功后的 server error(EMFILE 等)无监听者时会以 uncaughtException 崩掉守护进程,故仅记录不抛出。
67
+ this.server.on("error", (error) => {
68
+ const pending = this.listenError;
69
+ this.listenError = undefined;
70
+ if (pending)
71
+ pending(error);
72
+ else
73
+ console.error("[maestro-mobile] http server error:", error);
74
+ });
44
75
  this.webSocketServer.on("connection", (ws) => {
45
76
  if (this.clients.size >= MobileHostServer.MAX_CONNECTIONS) {
77
+ console.warn(`[maestro-mobile] ws rejected: too many connections (limit=${MobileHostServer.MAX_CONNECTIONS})`);
46
78
  ws.close(1013, "too many connections");
47
79
  return;
48
80
  }
49
- const client = { id: crypto.randomUUID(), ws, inflight: 0 };
81
+ const client = { id: crypto.randomUUID(), ws, inflight: 0, closing: false, droppedFrames: 0, lastDropLogAt: 0, slowSince: 0, heartbeatMisses: 0 };
50
82
  this.clients.add(client);
51
- // P2-1:host_status 契约是 status: string;HostStatus 对象走独立的 host_info 事件
52
- ws.send(JSON.stringify({ type: "host_status", status: "connected", seq: 0 }));
53
- ws.send(JSON.stringify({ type: "host_info", info: this.controller.getStatus(), seq: 0 }));
83
+ // 故障隔离到连接粒度(本 run 主根因):此前无 error listener,超限/非法帧的 error 事件直接变
84
+ // uncaughtException → cli fatal() → 整个 host 退出(单手机一帧崩掉所有客户端)。现在只断该连接,
85
+ // 其余连接无感。listener 必须先于任何 send 注册。
86
+ ws.on("error", (error) => {
87
+ // 注意:ws 在协议错误路径(receiverOnError)已同步发过 close 帧,此处不得 terminate,
88
+ // 否则抢掉 1009 通知,客户端只能看到裸 TCP 断。幂等 closing 标记下走 graceful 重复 close(无副作用)。
89
+ console.error(`[maestro-mobile] ws error client=${client.id} code=${error.code ?? "-"} message=${sanitizeWsErrorMessage(error.message, [this.options.token ?? ""])} (isolated)`);
90
+ this.closeClient(client, "ws_error");
91
+ });
92
+ // 活性由标准 ping/pong 心跳维护(见 startHeartbeat):客户端栈(RN OkHttp/浏览器/Node ws)
93
+ // 均自动应答 pong,无需改协议。半开连接、死 socket 在下个周期被 terminate,
94
+ // 也封住 close() 等失联连接 graceful close 的≈30s 悬挂。
95
+ ws.on("pong", () => {
96
+ client.heartbeatMisses = 0;
97
+ });
54
98
  ws.on("message", (data) => {
99
+ // 收到任何入站数据即证明连接存活,清零漏答计数(防止 iOS/RN 客户端因底层 pong 延迟被心跳误杀)
100
+ client.heartbeatMisses = 0;
55
101
  if (client.inflight >= MobileHostServer.MAX_CONCURRENT_COMMANDS) {
56
- this.sendError(client, "too_many_commands", "");
102
+ // 限流拒绝也要回 in_reply_to:否则客户端那条命令挂 30s 超时。
103
+ // 此处解析一次只为取 id;解析失败回空(真正的 invalid_json 判定在 handleClientMessage 里)
104
+ let replyTo = "";
105
+ try {
106
+ const parsed = JSON.parse(data.toString());
107
+ if (typeof parsed?.id === "string")
108
+ replyTo = parsed.id;
109
+ }
110
+ catch { /* 非 JSON:无 id 可回 */ }
111
+ this.sendError(client, "too_many_commands", undefined, replyTo);
57
112
  return;
58
113
  }
59
114
  client.inflight++;
60
- void this.handleClientMessage(client, data).finally(() => {
115
+ // .catch 不可省:void 链上任何 rejection(含未来新增分支)都会经 unhandledRejection 冒到 cli fatal() 退进程
116
+ void this.handleClientMessage(client, data)
117
+ .catch((error) => {
118
+ console.error(`[maestro-mobile] ws command crashed client=${client.id}:`, error instanceof Error ? sanitizeWsErrorMessage(error.message, [this.options.token ?? ""]) : error);
119
+ })
120
+ .finally(() => {
61
121
  client.inflight--;
62
122
  });
63
123
  });
64
- ws.on("close", () => {
124
+ ws.on("close", (code, reason) => {
125
+ client.closing = true;
126
+ if (client.droppedFrames > 0) {
127
+ console.warn(`[maestro-mobile] ws closed client=${client.id} code=${code} droppedTotal=${client.droppedFrames}`);
128
+ }
129
+ else if (code !== 1000 && code !== 1001) {
130
+ console.warn(`[maestro-mobile] ws closed client=${client.id} code=${code} reason=${sanitizeWsErrorMessage(reason.toString(), [this.options.token ?? ""]).slice(0, 60)}`);
131
+ }
65
132
  this.clients.delete(client);
66
133
  });
134
+ // P2-1:host_status 契约是 status: string;HostStatus 对象走独立的 host_info 事件
135
+ this.sendFrame(client, { type: "host_status", status: "connected", seq: 0 }, "required");
136
+ this.sendFrame(client, { type: "host_info", info: this.controller.getStatus(), seq: 0 }, "required");
67
137
  });
68
138
  this.unsubscribeController = this.controller.onEvent((event) => {
69
- const payload = JSON.stringify(event);
70
- for (const client of this.clients) {
71
- if (client.ws.readyState === client.ws.OPEN) {
72
- client.ws.send(payload);
73
- }
139
+ let payload;
140
+ try {
141
+ payload = JSON.stringify(event);
142
+ }
143
+ catch (error) {
144
+ // 序列化失败(循环引用/BigInt)属事件自身缺陷:只记一次不抛出(controller 侧已有 per-listener 防护,
145
+ // 但本 listener 抛错会使本事件对其后客户端丢失——提前 return 避免半途截断)
146
+ console.error(`[maestro-mobile] ws broadcast serialize failed type=${event.type ?? "?"}:`, error instanceof Error ? error.message : error);
147
+ return;
148
+ }
149
+ // 协议层可丢性(packages/shared/src/protocol.ts:324-328):只有 timeline_delta 声明「尚不存在可忽略,
150
+ // 终态由 timeline_item 补齐」。其余事件无 server 侧重放保证,一律 required(宁断不默丢)。
151
+ const delivery = event.type === "timeline_delta" ? "best_effort" : "required";
152
+ for (const client of [...this.clients]) {
153
+ this.sendFrame(client, payload, delivery, event.type);
74
154
  }
75
155
  });
76
156
  }
157
+ remoteOf(ws) {
158
+ return ws._socket?.remoteAddress ?? "-";
159
+ }
160
+ /** 心跳周期句柄 */
161
+ heartbeatTimer;
162
+ /**
163
+ * 服务端心跳:每周期对无响应的连接 terminate(半开/死 socket),有响应则 ping。
164
+ * 替代此前没用的 ws.setTimeout(WebSocket 对象无此方法);也让 close():「graceful 等待」不再被失联连接拖≈30s。
165
+ */
166
+ startHeartbeat(intervalMs) {
167
+ if (this.heartbeatTimer)
168
+ return; // 幂等:重复 listen 不得叠加 interval(否则多路 ping/terminate)
169
+ this.heartbeatTimer = setInterval(() => {
170
+ for (const client of [...this.clients]) {
171
+ // 必须容忍一次漏答:本机 event loop 卡顶(实测 load>150)会让 pong 排队跨过半个周期,
172
+ // 单次未应答即 terminate 会误杀健康连接(表现为手机无端断线,需重连+补拉)。
173
+ if (client.heartbeatMisses >= MobileHostServer.HEARTBEAT_MAX_MISSES) {
174
+ console.warn(`[maestro-mobile] ws heartbeat lost client=${client.id} remote=${this.remoteOf(client.ws)} misses=${client.heartbeatMisses} → terminate`);
175
+ this.closeClient(client, "heartbeat_lost");
176
+ continue;
177
+ }
178
+ client.heartbeatMisses++;
179
+ try {
180
+ client.ws.ping();
181
+ }
182
+ catch {
183
+ this.closeClient(client, "ping_failed");
184
+ }
185
+ }
186
+ }, intervalMs);
187
+ this.heartbeatTimer.unref?.(); // 不阻止进程退出
188
+ }
189
+ /**
190
+ * 单一发送出口(故障隔离 + 背压收口):所有 WS 写出必须经此,不得直接 ws.send。
191
+ * - required(command_result/握手/协议错误/timeline_item/session_updated/raw_event/…):
192
+ * 越阈不静默丢;持续越阈达宽限期 close(1013),由客户端重连 + P2-2 snapshot 补拉自愈。
193
+ * 不自研排队/drain 补发:消除 close×drain 竞态,局域网工具不值得该复杂度。
194
+ * - best_effort(仅 timeline_delta):越阈直接丢弃并计数,终态由后续 timeline_item 补齐,不断线。
195
+ */
196
+ sendFrame(client, message, delivery, kind = "object") {
197
+ if (client.closing || client.ws.readyState !== client.ws.OPEN)
198
+ return false;
199
+ let payload;
200
+ try {
201
+ payload = typeof message === "string" ? message : JSON.stringify(message);
202
+ }
203
+ catch (error) {
204
+ console.error(`[maestro-mobile] ws sendFrame serialize failed kind=${kind} client=${client.id}:`, error instanceof Error ? error.message : error);
205
+ return false;
206
+ }
207
+ const buffered = client.ws.bufferedAmount;
208
+ // 硬顶语义:约束的是「队列驻留」,不是「单帧大小」。
209
+ // 实测合法最大帧 = 4000 条 timeline 的 snapshot ≈ 7.4MB(本机 164MB 会话),
210
+ // 若把超限单帧也断线,客户端重连后会再次拉到同一帧 → 重连死循环,比 OOM 更糟。
211
+ // 因此:单帧自身越顶 → 照发(它是唯一副本)+ 告警;积压+新帧越顶 → 断线重连自愈。
212
+ const payloadBytes = Buffer.byteLength(payload);
213
+ if (payloadBytes > this.hardLimitBytes) {
214
+ console.warn(`[maestro-mobile] ws oversized required frame client=${client.id} kind=${kind} bytes=${payloadBytes} limit=${this.hardLimitBytes} (sent anyway; data-layer budget is separate concern)`);
215
+ return this.rawSend(client, payload);
216
+ }
217
+ if (buffered + payloadBytes > this.hardLimitBytes) {
218
+ // 硬顶:无论何种投递都先断线;required 走到这里绝不静默丢数据(断线重连才是既定自愈路径)
219
+ console.warn(`[maestro-mobile] ws buffer hard limit client=${client.id} remote=${this.remoteOf(client.ws)} buffered=${buffered} frame=${payloadBytes} → close(1013)`);
220
+ this.closeClient(client, "slow_consumer");
221
+ return false;
222
+ }
223
+ if (buffered >= this.highWaterMarkBytes) {
224
+ if (delivery === "best_effort") {
225
+ client.droppedFrames++;
226
+ const now = Date.now();
227
+ if (now - client.lastDropLogAt >= MobileHostServer.DROP_LOG_INTERVAL_MS) {
228
+ client.lastDropLogAt = now;
229
+ console.warn(`[maestro-mobile] ws slow consumer client=${client.id} remote=${this.remoteOf(client.ws)} buffered=${buffered} droppedSinceLastLog (best_effort frames, continuing)`);
230
+ }
231
+ return false;
232
+ }
233
+ if (!client.slowSince) {
234
+ client.slowSince = Date.now();
235
+ }
236
+ else if (Date.now() - client.slowSince >= this.slowGraceMs) {
237
+ // required 帧无法送达:与其默默丢,不如在可重连边界上断线(1013 = retryable,与满载拒绝同码)
238
+ console.warn(`[maestro-mobile] ws slow consumer exceeded grace client=${client.id} remote=${this.remoteOf(client.ws)} buffered=${buffered} → close(1013)`);
239
+ this.closeClient(client, "slow_consumer");
240
+ }
241
+ // 宽限期内:不丢、不断,交给 ws 内部缓冲(软/硬双阈值 + 5s 宽限,驻留有界)
242
+ return this.rawSend(client, payload);
243
+ }
244
+ client.slowSince = 0;
245
+ return this.rawSend(client, payload);
246
+ }
247
+ rawSend(client, payload) {
248
+ // 关闭可能就在本次 sendFrame 判定内发生(硬顶/宽限到期):此时不得再写,
249
+ // 否则「已判定必须送达」的那一帧反而静默丢失(ws 对 CLOSING 只累加计数不报错)。
250
+ if (client.closing)
251
+ return false;
252
+ try {
253
+ client.ws.send(payload);
254
+ return true;
255
+ }
256
+ catch (error) {
257
+ // 理论上仅 CONNECTING 态会同步抛(服务端不可达);保留隔离防未来分支变化
258
+ console.error(`[maestro-mobile] ws send threw client=${client.id}:`, error instanceof Error ? error.message : error);
259
+ this.closeClient(client, "send_failed");
260
+ return false;
261
+ }
262
+ }
263
+ /**
264
+ * 幂等关闭:只由首个调用者真正关。默认 graceful close(让 close 帧送达,客户端能看到 1013/reason);
265
+ * ws 内部已在协议错误路径自行发过 close 帧(receiverOnError),重复 close 无副作用。
266
+ * heartbeat_lost/ping_failed 等对端已死的场景直接 terminate 强制拆。
267
+ */
268
+ closeClient(client, reason) {
269
+ if (client.closing)
270
+ return;
271
+ client.closing = true;
272
+ this.clients.delete(client);
273
+ try {
274
+ if (reason === "heartbeat_lost" || reason === "ping_failed" || reason === "send_failed") {
275
+ client.ws.terminate();
276
+ }
277
+ else {
278
+ client.ws.close(1013, reason);
279
+ // graceful close 对端不应答时不等≈30s closeTimeout:2s 后强制拆
280
+ setTimeout(() => {
281
+ try {
282
+ client.ws.terminate();
283
+ }
284
+ catch { /* 已清理 */ }
285
+ }, 2_000).unref?.();
286
+ }
287
+ }
288
+ catch (error) {
289
+ console.error(`[maestro-mobile] ws close failed client=${client.id} reason=${reason}:`, error instanceof Error ? error.message : error);
290
+ }
291
+ }
77
292
  listen(port, hostname = "0.0.0.0") {
78
293
  this.boundHost = hostname;
79
- return new Promise((resolve) => {
80
- this.server.listen(port, hostname, () => resolve());
294
+ return new Promise((resolve, reject) => {
295
+ // EADDRINUSE / EACCES / 非法绑定地址都以 server 的 error 事件产生。此前无监听者:
296
+ // 该 Promise 永不 settle,错误以 uncaughtException 裸崩(而此时 cli 的 handler 尚未注册)。
297
+ this.listenError = reject;
298
+ this.server.listen(port, hostname, () => {
299
+ this.listenError = undefined;
300
+ // 心跳必须在绑定成功后启动:放在 listen() 之前时,EADDRINUSE 会留下一个无人 cleanup 的 interval,
301
+ // 且同实例重试 listen() 会覆盖 heartbeatTimer,使 close() 只能清最后一个。
302
+ this.startHeartbeat(this.options.heartbeatIntervalMs ?? 30_000);
303
+ resolve();
304
+ });
81
305
  });
82
306
  }
83
307
  address() {
@@ -88,12 +312,30 @@ export class MobileHostServer {
88
312
  return { port: address.port };
89
313
  }
90
314
  async close() {
315
+ if (this.heartbeatTimer) {
316
+ clearInterval(this.heartbeatTimer);
317
+ this.heartbeatTimer = undefined; // 必须置空:否则幂等守卫会阻止下次 listen() 重启心跳
318
+ }
91
319
  this.unsubscribeController?.();
92
320
  for (const client of this.clients) {
93
321
  client.ws.close();
94
322
  }
95
323
  this.clients.clear();
96
324
  this.webSocketServer.close();
325
+ // 失联/半开连接的 graceful close 帧无应答,wss.close 会等 ws 内部≈30s closeTimeout:
326
+ // 优雅等待有界(3s),超时后强拆残留 socket,shutdown 不再悬挂。
327
+ await new Promise((resolve) => {
328
+ const forceSockets = () => {
329
+ for (const socket of this.webSocketServer.clients) {
330
+ try {
331
+ socket.terminate();
332
+ }
333
+ catch { /* 已断开 */ }
334
+ }
335
+ };
336
+ const timer = setTimeout(() => { forceSockets(); resolve(); }, 3_000);
337
+ this.webSocketServer.once("close", () => { clearTimeout(timer); resolve(); });
338
+ });
97
339
  await new Promise((resolve, reject) => {
98
340
  this.server.close((error) => (error ? reject(error) : resolve()));
99
341
  });
@@ -256,7 +498,26 @@ export class MobileHostServer {
256
498
  this.webSocketServer.emit("connection", ws, request);
257
499
  });
258
500
  }
259
- /** WS 握手 Origin 白名单:loopback 变体 + 绑定 host(非 0.0.0.0 时)+ 显式配置 */
501
+ /**
502
+ * WS 握手 Origin 白名单:scheme 限定 + loopback 变体 + 绑定 host(非 0.0.0.0 时)
503
+ * + 同源 Host 头 + 显式配置。
504
+ *
505
+ * ISS-20260910-005 收紧(以下均为实测 new URL() 行为,非推断):
506
+ * 1. scheme 必须落在 http/https/ws/wss:旧实现完全不看 protocol,
507
+ * `file://`、`data:` 等只要 hostname 对上就放行。
508
+ * 2. 同源比较改用 parsed.host。旧写法 `${hostname}:${parsed.port}` 在无端口时
509
+ * 产生尾冒号(`new URL("http://192.168.1.10").port === ""`)⇒ 永不匹配,
510
+ * 是无效果死比较。parsed.host 已含非默认端口、并规范掉默认端口
511
+ * (`https://a.example:443`.host === "a.example"),语义止于「主机+非默认端口」。
512
+ * 3. allowedOrigins 支持两种写法:完整 URL(可精确到端口)或裸 hostname。
513
+ * 旧实现用 new URL(o) 解析,而 `new URL("trusted.example.com")` 抛错 ⇒ 该配置
514
+ * 静默永不生效(只能配成完整 URL 才有效),属配置陷阱。
515
+ *
516
+ * 不变的两条(刻意的宽松,改它们会锁死现有使用):
517
+ * - 无 Origin 一律放行(:552 原样保留):RN/原生客户端不发 Origin,仍有 token 把关。
518
+ * - loopback 与 boundHost 分支只看 hostname、忽略 port:本地开发页面常在随机端口
519
+ * (http://localhost:3000 连 4739 的 host),纳port 比较会直接打断现有用法。
520
+ */
260
521
  isOriginAllowed(origin, requestHost = "") {
261
522
  if (!origin)
262
523
  return true; // 非浏览器客户端
@@ -267,29 +528,64 @@ export class MobileHostServer {
267
528
  catch {
268
529
  return false;
269
530
  }
531
+ // scheme 限定:Origin 只可能是页面来源(http/https)或原生客户端回填的 WS URL 自身(ws/wss)
532
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:"
533
+ && parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
534
+ return false;
535
+ }
270
536
  const hostname = parsed.hostname.toLowerCase();
537
+ const originHost = parsed.host.toLowerCase(); // hostname + 非默认端口(无端口时即 hostname)
271
538
  if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]") {
272
539
  return true;
273
540
  }
274
541
  // 原生客户端(React Native OkHttp 等)会把 Origin 设为 WS URL 自身(= 本机地址)。
275
542
  // 这类请求 host 头与 origin 同源,放行;浏览器跨站 drive-by 的 origin 不会等于本机地址。
543
+ // 只比 originHost:实测 new URL("http://[::1]:80").hostname 已带方括号,
544
+ // 再拼 `[${hostname}]` 会得到 `[[::1]]` ⇒ 永不匹配(同族死比较,已删除);
545
+ // 而 hostHeader === hostname 也冗余(无端口时 parsed.host 本来就 === hostname)。
276
546
  const hostHeader = requestHost.toLowerCase();
277
- if (hostHeader && (hostHeader === hostname || hostHeader === `${hostname}:${parsed.port}`)) {
547
+ if (hostHeader && hostHeader === originHost) {
278
548
  return true;
279
549
  }
280
550
  const bound = this.boundHost.toLowerCase();
281
- if (bound !== "0.0.0.0" && bound !== "::" && hostname === bound) {
551
+ if (bound !== "0.0.0.0" && bound !== "::" && bareHost(hostname) === bareHost(bound)) {
552
+ // IPv6 归一:boundHost 来自 server.address()/命令行(无括号),Origin 的 hostname 带括号
282
553
  return true;
283
554
  }
284
555
  const allowed = this.options.allowedOrigins ?? [];
285
- return allowed.some((o) => {
286
- try {
287
- return new URL(o).hostname.toLowerCase() === hostname;
556
+ return allowed.some((o) => this.matchesAllowedOrigin(o, hostname, parsed.port));
557
+ }
558
+ /**
559
+ * 白名单条目匹配:借助标准 new URL() 归一化解析 scheme/host/port。
560
+ * 裸 hostname 或未带 scheme 的写法统一垫入 "http://" 代理解析,避免易碎的手工切片。
561
+ */
562
+ matchesAllowedOrigin(configured, hostname, originPort) {
563
+ const entry = configured.trim().toLowerCase();
564
+ if (!entry)
565
+ return false;
566
+ let urlStr = entry;
567
+ if (!urlStr.includes("://")) {
568
+ const colons = (urlStr.match(/:/g) || []).length;
569
+ if (colons > 1 && !urlStr.startsWith("[")) {
570
+ urlStr = `http://[${urlStr}]`;
288
571
  }
289
- catch {
290
- return false;
572
+ else {
573
+ urlStr = `http://${urlStr}`;
291
574
  }
292
- });
575
+ }
576
+ try {
577
+ const u = new URL(urlStr);
578
+ const entryHost = bareHost(u.hostname);
579
+ const entryPort = u.port || undefined; // 默认端口规范化为空串即 undefined
580
+ if (entryHost !== bareHost(hostname))
581
+ return false;
582
+ if (entryPort !== undefined && entryPort !== originPort)
583
+ return false;
584
+ return true;
585
+ }
586
+ catch {
587
+ return false;
588
+ }
293
589
  }
294
590
  // ── WS 命令 ───────────────────────────────────────────────────────────────
295
591
  async handleClientMessage(client, data) {
@@ -298,19 +594,38 @@ export class MobileHostServer {
298
594
  command = JSON.parse(data.toString());
299
595
  }
300
596
  catch {
301
- client.ws.send(JSON.stringify({ type: "error", code: "invalid_json", message: "Invalid JSON" }));
597
+ // seq 是 HostEvent 必填字段(protocol.ts:336),且客户端 host-client.ts:255 只派发
598
+ // typeof seq === "number" 的帧——缺 seq 会使本错误帧被客户端丢弃。与 :173-174 握手帧同用 0
599
+ // (已核:全客户端域无任何 seq 数值比较,0 不干扰回放语义)。
600
+ // 不进 EventLog:那是跳连接增量回放日志,单连接协议层错误不应回放给其他客户端。
601
+ this.sendFrame(client, { type: "error", code: "invalid_json", message: "Invalid JSON", seq: 0 }, "required", "error");
302
602
  return;
303
603
  }
304
604
  // P3-2:分发前真正走 shared 校验(激活 validation 模块,拦截缺 type 的任意载荷)
305
605
  try {
306
606
  command = validateClientCommand(command);
307
607
  }
308
- catch {
309
- client.ws.send(JSON.stringify({ type: "error", code: "invalid_command", message: "Invalid ClientCommand: missing type" }));
608
+ catch (error) {
609
+ // JSON 已解析成功→可取 id:必须回 command_result 而非裸 error 事件,
610
+ // 否则客户端 pendingCommands 匹配不到,该命令挂满 30s 超时
611
+ const rawId = command?.id;
612
+ const replyTo = typeof rawId === "string" ? rawId : "";
613
+ // 文案跟随实际拒因(缺 type / id 非 string),不再硬编码 “missing type” 误报
614
+ const reason = error instanceof Error ? error.message : "Invalid ClientCommand";
615
+ this.sendFrame(client, {
616
+ type: "command_result",
617
+ in_reply_to: replyTo,
618
+ ok: false,
619
+ error: { code: "invalid_command", message: reason },
620
+ }, "required", "command_result");
310
621
  return;
311
622
  }
312
623
  try {
313
624
  switch (command.type) {
625
+ case "ping": {
626
+ this.sendAck(client, command, { pong: true });
627
+ break;
628
+ }
314
629
  case "list_live_sessions": {
315
630
  const list = await this.controller.listLiveSessions();
316
631
  this.sendAck(client, command, list);
@@ -319,7 +634,7 @@ export class MobileHostServer {
319
634
  case "load_more_history": {
320
635
  const runner = this.controller.getSession(command.sessionId);
321
636
  if (!runner) {
322
- this.sendError(client, "session_not_found", command.id ?? "");
637
+ this.sendError(client, "session_not_found", undefined, command.id ?? "");
323
638
  break;
324
639
  }
325
640
  const result = await runner.loadMoreHistory(command.count);
@@ -329,17 +644,20 @@ export class MobileHostServer {
329
644
  case "search_history": {
330
645
  const runner = this.controller.getSession(command.sessionId);
331
646
  if (!runner) {
332
- this.sendError(client, "session_not_found", command.id ?? "");
647
+ this.sendError(client, "session_not_found", undefined, command.id ?? "");
333
648
  break;
334
649
  }
335
- const result = await runner.searchHistory(command.keyword, command.maxResults, command.previewLength);
650
+ // 服务端硬上限:客户端可传任意值(全扫 165MB 会话 + 无界结果集),不信任入参
651
+ const maxResults = clampCommandInt(command.maxResults, 50, MAX_SEARCH_RESULTS);
652
+ const previewLength = clampCommandInt(command.previewLength, 120, MAX_SEARCH_PREVIEW_LENGTH);
653
+ const result = await runner.searchHistory(command.keyword, maxResults, previewLength);
336
654
  this.sendAck(client, command, result);
337
655
  break;
338
656
  }
339
657
  case "list_models": {
340
658
  const runner = this.controller.getSession(command.sessionId);
341
659
  if (!runner) {
342
- this.sendError(client, "session_not_found", command.id ?? "");
660
+ this.sendError(client, "session_not_found", undefined, command.id ?? "");
343
661
  break;
344
662
  }
345
663
  const models = typeof runner.listModels === "function" ? runner.listModels() : [];
@@ -349,7 +667,7 @@ export class MobileHostServer {
349
667
  case "list_skills": {
350
668
  const runner = this.controller.getSession(command.sessionId);
351
669
  if (!runner) {
352
- this.sendError(client, "session_not_found", command.id ?? "");
670
+ this.sendError(client, "session_not_found", undefined, command.id ?? "");
353
671
  break;
354
672
  }
355
673
  // 优先走 SDK resourceLoader(与 TUI 一致),回退到文件扫描
@@ -375,7 +693,7 @@ export class MobileHostServer {
375
693
  case "set_model": {
376
694
  const runner = this.controller.getSession(command.sessionId);
377
695
  if (!runner) {
378
- this.sendError(client, "session_not_found", command.id ?? "");
696
+ this.sendError(client, "session_not_found", undefined, command.id ?? "");
379
697
  break;
380
698
  }
381
699
  if (typeof runner.setModel !== "function") {
@@ -389,7 +707,7 @@ export class MobileHostServer {
389
707
  case "set_thinking": {
390
708
  const runner = this.controller.getSession(command.sessionId);
391
709
  if (!runner) {
392
- this.sendError(client, "session_not_found", command.id ?? "");
710
+ this.sendError(client, "session_not_found", undefined, command.id ?? "");
393
711
  break;
394
712
  }
395
713
  if (typeof runner.setThinking !== "function") {
@@ -403,7 +721,7 @@ export class MobileHostServer {
403
721
  case "compact": {
404
722
  const runner = this.controller.getSession(command.sessionId);
405
723
  if (!runner) {
406
- this.sendError(client, "session_not_found", command.id ?? "");
724
+ this.sendError(client, "session_not_found", undefined, command.id ?? "");
407
725
  break;
408
726
  }
409
727
  if (typeof runner.compact !== "function") {
@@ -417,7 +735,7 @@ export class MobileHostServer {
417
735
  case "rename_session": {
418
736
  const runner = this.controller.getSession(command.sessionId);
419
737
  if (!runner) {
420
- this.sendError(client, "session_not_found", command.id ?? "");
738
+ this.sendError(client, "session_not_found", undefined, command.id ?? "");
421
739
  break;
422
740
  }
423
741
  if (typeof runner.renameSession !== "function") {
@@ -450,7 +768,7 @@ export class MobileHostServer {
450
768
  case "prompt": {
451
769
  const runner = this.controller.getSession(command.sessionId);
452
770
  if (!runner) {
453
- this.sendError(client, "session_not_found", command.id ?? "");
771
+ this.sendError(client, "session_not_found", undefined, command.id ?? "");
454
772
  break;
455
773
  }
456
774
  // P1-3:透传图片(此前被静默丢弃),非法元素显式报错而非静默丢失
@@ -459,14 +777,31 @@ export class MobileHostServer {
459
777
  this.sendError(client, "invalid_image", "images 元素必须是 base64 data 与 mime 字段齐全的图片", command.id ?? "");
460
778
  break;
461
779
  }
462
- await runner.prompt(command.message, undefined, images);
463
- this.sendAck(client, command, {});
780
+ // 方案 A 双端实时协同:
781
+ // 1. 优先检查当前会话所在的 cwd 是否正是当前桌面活跃的 TUI 窗口(通过 workspace-telemetry)
782
+ // 2. 如果是当前活跃桌面窗口,通过 teammate 跨进程信箱直接注入 steer 到桌面终端!
783
+ // 桌面终端屏幕立刻打字动起来并回答,写盘后由 Watcher 实时推回手机,实现真正的同屏双向同步!
784
+ // 3. 如果当前没有活跃桌面窗口,或者会话已在流式生成中,走已有 runner 驱动逻辑。
785
+ const activeTuiOwner = await this.controller.findActiveOwnerForCwd(runner.state.cwd);
786
+ let injectedToTui = false;
787
+ if (activeTuiOwner && (!images || images.length === 0)) {
788
+ injectedToTui = await injectMessageToActiveTui(activeTuiOwner, command.message);
789
+ }
790
+ if (!injectedToTui) {
791
+ if (runner.state.runState === "streaming") {
792
+ await runner.steer(command.message);
793
+ }
794
+ else {
795
+ await runner.prompt(command.message, undefined, images);
796
+ }
797
+ }
798
+ this.sendAck(client, command, { injectedToTui });
464
799
  break;
465
800
  }
466
801
  case "steer": {
467
802
  const runner = this.controller.getSession(command.sessionId);
468
803
  if (!runner) {
469
- this.sendError(client, "session_not_found", command.id ?? "");
804
+ this.sendError(client, "session_not_found", undefined, command.id ?? "");
470
805
  break;
471
806
  }
472
807
  await runner.steer(command.message);
@@ -496,7 +831,7 @@ export class MobileHostServer {
496
831
  case "follow_up": {
497
832
  const runner = this.controller.getSession(command.sessionId);
498
833
  if (!runner) {
499
- this.sendError(client, "session_not_found", command.id ?? "");
834
+ this.sendError(client, "session_not_found", undefined, command.id ?? "");
500
835
  break;
501
836
  }
502
837
  await runner.followUp(command.message);
@@ -506,7 +841,7 @@ export class MobileHostServer {
506
841
  case "abort": {
507
842
  const runner = this.controller.getSession(command.sessionId);
508
843
  if (!runner) {
509
- this.sendError(client, "session_not_found", command.id ?? "");
844
+ this.sendError(client, "session_not_found", undefined, command.id ?? "");
510
845
  break;
511
846
  }
512
847
  await runner.abort();
@@ -519,7 +854,7 @@ export class MobileHostServer {
519
854
  this.sendAck(client, command, {});
520
855
  }
521
856
  else {
522
- this.sendError(client, "request_not_found", command.id ?? "");
857
+ this.sendError(client, "request_not_found", undefined, command.id ?? "");
523
858
  }
524
859
  break;
525
860
  }
@@ -537,22 +872,22 @@ export class MobileHostServer {
537
872
  case "get_snapshot": {
538
873
  const runner = this.controller.getSession(command.sessionId);
539
874
  if (!runner) {
540
- this.sendError(client, "session_not_found", command.id ?? "");
875
+ this.sendError(client, "session_not_found", undefined, command.id ?? "");
541
876
  break;
542
877
  }
543
878
  const snapshot = runner.snapshot();
544
- client.ws.send(JSON.stringify({
879
+ this.sendFrame(client, {
545
880
  type: "command_result",
546
881
  in_reply_to: command.id ?? "",
547
882
  ok: true,
548
883
  result: snapshot,
549
- }));
884
+ }, "required", "command_result");
550
885
  break;
551
886
  }
552
887
  case "get_session_usage": {
553
888
  const runner = this.controller.getSession(command.sessionId);
554
889
  if (!runner) {
555
- this.sendError(client, "session_not_found", command.id ?? "");
890
+ this.sendError(client, "session_not_found", undefined, command.id ?? "");
556
891
  break;
557
892
  }
558
893
  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 };
@@ -561,30 +896,75 @@ export class MobileHostServer {
561
896
  break;
562
897
  }
563
898
  default:
564
- this.sendError(client, "unsupported_command", command.id ?? "");
899
+ this.sendError(client, "unsupported_command", undefined, command.id ?? "");
565
900
  }
566
901
  }
567
902
  catch (error) {
568
903
  const message = error instanceof Error ? error.message : String(error);
569
- this.sendError(client, "command_failed", message);
904
+ // 必须带 in_reply_to:客户端靠它匹配 pendingCommands,空值会让命令挂满 30s 超时
905
+ this.sendError(client, "command_failed", message, command.id ?? "");
570
906
  }
571
907
  }
572
908
  sendAck(client, command, result) {
573
- client.ws.send(JSON.stringify({
909
+ this.sendFrame(client, {
574
910
  type: "command_result",
575
911
  in_reply_to: command.id ?? "",
576
912
  ok: true,
577
913
  result,
578
- }));
914
+ }, "required", "command_result");
579
915
  }
580
916
  sendError(client, code, message, replyTo = "") {
581
- client.ws.send(JSON.stringify({
917
+ this.sendFrame(client, {
582
918
  type: "command_result",
583
919
  in_reply_to: replyTo,
584
920
  ok: false,
585
- error: { code, message: message ?? code },
586
- }));
921
+ // 回传网络的唯一脉络:必须带上本实例 token 作为已知密串(底层错误文本可能回显含 ?token= 的 URL)
922
+ error: { code, message: message ? sanitizeWsErrorMessage(message, [this.options.token ?? ""]) : code },
923
+ }, "required", "command_result");
924
+ }
925
+ }
926
+ const MAX_SEARCH_RESULTS = 200;
927
+ const MAX_SEARCH_PREVIEW_LENGTH = 400;
928
+ /**
929
+ * 命令参数钳制(导出以供回归测试直接钉本函数,避免测试复制一份算式假通过):
930
+ * 非有限值(NaN/Infinity/null/字符串形态)落默认值,有限值夹到 [1, max]。
931
+ */
932
+ export function clampCommandInt(raw, dflt, max) {
933
+ return Number.isFinite(raw) ? Math.min(Math.max(1, Math.floor(raw)), max) : dflt;
934
+ }
935
+ /**
936
+ * 去 IPv6 方括号。实测 new URL("http://[::1]:80").hostname === "[::1]"(带括号),
937
+ * 而 server.address().address / 命令行传入的绑定地址不带括号,两侧必须归一后才能比较。
938
+ */
939
+ function bareHost(host) {
940
+ return host.replace(/^\[(.*)\]$/, "$1");
941
+ }
942
+ /**
943
+ * WS/日志/响应用错误文本脱敏:限长 + 去换行 + 抹掉已知敏感根。
944
+ * 背景:command_failed 曾把底层 fs/SDK 的 Error.message(含用户目录绝对路径)原样回传网络。
945
+ *
946
+ * 设计:只替换「已知敏感值」(token 本体、home 目录、cwd、盘符路径),不用泛用正则猜“这段像不像路径”——
947
+ * 后者会把 `/api/v1/users`、`http://host/api/v1` 这类诊断信息误删(独立评审 RV-002)。
948
+ * 完整堆栈只进服务端日志的结构化字段,不经此函数回传客户端。
949
+ */
950
+ export function sanitizeWsErrorMessage(raw, secrets = []) {
951
+ if (!raw)
952
+ return "";
953
+ let out = raw.replace(/[ \t\r\n]+/g, " ").slice(0, 200);
954
+ // 1) 显式已知密串(如本实例 token):只要出现在文本里就抹掉,不限形态
955
+ for (const secret of secrets) {
956
+ if (secret && secret.length >= 8)
957
+ out = out.split(secret).join("[redacted]");
958
+ }
959
+ // 2) 用户主目录与进程 cwd:局域网工具里这两个值会泄露用户名/项目位置
960
+ for (const root of [homedir(), process.cwd()]) {
961
+ if (root && root.length > 1)
962
+ out = out.split(root).join("~");
587
963
  }
964
+ // 3) Windows 盘符路径形态无歧义,可直接折叠到文件名。负向后顾防 URL scheme 误伤:
965
+ // "http://host/x" 里的 p:// 会被 [A-Za-z]:[\/] 命中,但它前面是字母 t → 排除
966
+ out = out.replace(/(?<![A-Za-z0-9])[A-Za-z]:[\\/][^\s"']*/g, (m) => m.split(/[\\/]/).filter(Boolean).pop() ?? "[path]");
967
+ return out;
588
968
  }
589
969
  function writeJson(response, status, body) {
590
970
  response.writeHead(status, {