@mrrisega/dsh-remote 0.7.9 → 0.7.10-beta.10

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 (60) hide show
  1. package/README.md +9 -2
  2. package/clients/dsh-remote/agent/README.md +412 -0
  3. package/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  4. package/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  5. package/clients/dsh-remote/agent/codex.mjs +736 -0
  6. package/clients/dsh-remote/agent/detect.mjs +399 -0
  7. package/clients/dsh-remote/agent/events.mjs +511 -0
  8. package/clients/dsh-remote/agent/gemini.mjs +503 -0
  9. package/clients/dsh-remote/agent/index.mjs +339 -0
  10. package/clients/dsh-remote/agent/opencode.mjs +628 -0
  11. package/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  12. package/clients/dsh-remote/agent/proc.mjs +179 -0
  13. package/clients/dsh-remote/agent/version.mjs +148 -0
  14. package/clients/dsh-remote/approval-coordinator.mjs +150 -0
  15. package/clients/dsh-remote/decision-poller.mjs +235 -0
  16. package/clients/dsh-remote/dsh-bridge.mjs +208 -1
  17. package/clients/dsh-remote/mobile-adapter.mjs +775 -11
  18. package/clients/dsh-remote/push-reporter.mjs +533 -0
  19. package/clients/dsh-remote/test/agent-codex.test.mjs +535 -0
  20. package/clients/dsh-remote/test/agent-detect.test.mjs +390 -0
  21. package/clients/dsh-remote/test/agent-events.test.mjs +340 -0
  22. package/clients/dsh-remote/test/agent-gemini.test.mjs +355 -0
  23. package/clients/dsh-remote/test/agent-jsonl-driver.test.mjs +165 -0
  24. package/clients/dsh-remote/test/agent-opencode.test.mjs +369 -0
  25. package/clients/dsh-remote/test/agent-permission-bridge.test.mjs +340 -0
  26. package/clients/dsh-remote/test/agent-proc.test.mjs +175 -0
  27. package/clients/dsh-remote/test/agent-runtime.test.mjs +282 -0
  28. package/clients/dsh-remote/test/agent-version.test.mjs +122 -0
  29. package/clients/dsh-remote/test/approval-coordinator.test.mjs +192 -0
  30. package/clients/dsh-remote/test/bridge-e2ee-reject-observable.test.mjs +90 -0
  31. package/clients/dsh-remote/test/decision-poller.test.mjs +328 -0
  32. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +172 -0
  33. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +188 -4
  34. package/clients/dsh-remote/test/push-reporter.test.mjs +380 -0
  35. package/dsh-setup.mjs +5 -0
  36. package/package.json +2 -2
  37. package/packages/dsh-remote-web/lib/client.js +350 -8
  38. package/packages/dsh-remote-web/lib/index.js +26 -0
  39. package/packages/dsh-remote-web/package.json +1 -1
  40. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/README.md +412 -0
  41. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  42. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  43. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/codex.mjs +736 -0
  44. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/detect.mjs +399 -0
  45. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/events.mjs +511 -0
  46. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/gemini.mjs +503 -0
  47. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/index.mjs +339 -0
  48. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/opencode.mjs +628 -0
  49. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  50. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/proc.mjs +179 -0
  51. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/version.mjs +148 -0
  52. package/packages/dsh-remote-web/runtime/clients/dsh-remote/approval-coordinator.mjs +150 -0
  53. package/packages/dsh-remote-web/runtime/clients/dsh-remote/decision-poller.mjs +235 -0
  54. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +208 -1
  55. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +775 -11
  56. package/packages/dsh-remote-web/runtime/clients/dsh-remote/push-reporter.mjs +533 -0
  57. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +5 -0
  58. package/packages/dsh-remote-web/test/activation-entry.test.mjs +407 -0
  59. package/packages/dsh-remote-web/test/e2ee-reject-diag.test.mjs +70 -0
  60. package/packages/dsh-remote-web/test/setup-output.test.mjs +16 -0
@@ -0,0 +1,533 @@
1
+ /**
2
+ * push-reporter — 把电脑端的事件(任务完成 / 需要拍板 / 出错)上报到 SaaS,由服务端决定
3
+ * 是否推送(规则化审批在服务端做、审计在服务端落),本模块只负责**可靠、克制地把事件送到**。
4
+ *
5
+ * 设计边界(每一条都重要)
6
+ * ---------------------------------------------------------------
7
+ * 1. **默认关闭**:只有服务端明确"推送已开启且本账号有订阅"时才启动;任何一步失败都静默降级。
8
+ * 2. **绝不阻塞主链路**:不 await 在 bridge 的事件循环里,所有网络动作都在自己的队列里串行做;
9
+ * 异常一律 catch,绝不冒泡到隧道帧循环(隧道挂 = 全用户断连)。
10
+ * 3. **隐私最小化**:默认只上报"标题 + 一句结果摘要",**不上报完整命令/代码**;
11
+ * 只有 `DSH_PUSH_DETAIL=1` 时才带 `approval.summary`(由服务端规则引擎判断风险)。
12
+ * 4. **幂等**:同一事件(同一 event_id)重试不会让服务端推两次(服务端也有幂等,这里是第一道)。
13
+ * 5. **有界**:队列最多 50 条,满了丢最旧并计数;网络失败指数退避,最多 3 次。
14
+ *
15
+ * 跨仓库契约(企业端 `/api/notify/event`,字段不可改)
16
+ * ---------------------------------------------------------------
17
+ * POST {apiBase}/api/notify/event Authorization: Bearer <device JWT>
18
+ * {
19
+ * event_id, device_id, kind, at,
20
+ * session: { id, title? },
21
+ * title, body,
22
+ * approval?: { request_id, summary?, options: [{value,label}] }
23
+ * }
24
+ * 响应 200 { ok, notified, suppressed, reason? }
25
+ */
26
+
27
+ import { createEventSubscriber, NODE_KINDS, TURN_END_REASONS } from "./dsh-events.mjs";
28
+
29
+ /** 上报体里 kind 的闭集(与服务端契约一致)。 */
30
+ export const NOTIFY_KINDS = Object.freeze({
31
+ TURN_DONE: "turn_done",
32
+ TURN_ERROR: "turn_error",
33
+ TURN_BLOCKED: "turn_blocked",
34
+ APPROVAL_REQUEST: "approval_request",
35
+ });
36
+
37
+ /** 队列上限 / 重试退避 / 单次请求超时。 */
38
+ export const MAX_QUEUE = 50;
39
+ export const RETRY_DELAYS_MS = Object.freeze([5_000, 15_000, 45_000]);
40
+ export const REQUEST_TIMEOUT_MS = 10_000;
41
+ export const MAX_EVENT_ID_LEN = 128;
42
+ /** 上报正文硬上限(服务端也会截,这里先截,省流量)。 */
43
+ export const MAX_BODY_LEN = 200;
44
+
45
+ /** 结果摘要里不该出现的、像"命令"的东西(默认模式下再兜一层)。 */
46
+ const COMMAND_HINT = /(^|\s)(rm|sudo|npm|pnpm|yarn|git|docker|kubectl|curl|wget|ssh|scp|chmod|chown|systemctl|psql|mysql)\s/i;
47
+
48
+ /**
49
+ * 事件节点 → 上报体。返回 `null` 表示"这类节点不上报"(例如 gap/fault/简报)。
50
+ *
51
+ * 导出是为了单测能直接钉住映射(不依赖真实订阅器)。
52
+ *
53
+ * @param {object} node dsh-events 的分类节点
54
+ * @param {{deviceId:string, detail?:boolean, sessionTitle?:(sessionId:string)=>string, now?:()=>number}} opts
55
+ * @returns {object|null}
56
+ */
57
+ export function toNotifyPayload(node, opts = {}) {
58
+ if (!node || typeof node !== "object") return null;
59
+ const deviceId = String(opts.deviceId || "");
60
+ if (!deviceId) return null;
61
+ const now = typeof opts.now === "function" ? opts.now : () => Date.now();
62
+ const at = Number.isFinite(node.at) ? node.at : now();
63
+ const sessionId = typeof node.sessionId === "string" ? node.sessionId : "";
64
+ const sessionTitleRaw = typeof opts.sessionTitle === "function" ? opts.sessionTitle(sessionId) : "";
65
+ const sessionTitle = truncate(String(sessionTitleRaw || ""), 60);
66
+ const session = { id: sessionId };
67
+ if (sessionTitle) session.title = sessionTitle;
68
+
69
+ if (node.kind === NODE_KINDS.APPROVAL_REQUEST || node.kind === NODE_KINDS.PLAN_REVIEW
70
+ || node.kind === NODE_KINDS.USER_QUESTION) {
71
+ const requestId = String(node.eventId || "");
72
+ if (!requestId) return null;
73
+ const isPlan = node.kind === NODE_KINDS.PLAN_REVIEW;
74
+ const isQuestion = node.kind === NODE_KINDS.USER_QUESTION;
75
+ const tool = String(node.toolName || "").trim();
76
+ // 摘要只给"能看懂在等什么"的最小信息;完整命令默认不外发(隐私)。
77
+ const rawSummary = String(node.reason || tool || "").trim();
78
+ // ★ 默认模式一律只给"动作名",完整命令只有 detail 模式才外发(隐私边界)。
79
+ const action = opts.detail ? truncate(rawSummary, MAX_BODY_LEN) : safeSummary(tool, rawSummary);
80
+ const title = isQuestion ? "等你回答" : isPlan ? "等你批准计划" : "需要你拍板";
81
+ const body = isQuestion
82
+ ? "agent 提问:需要你选一个选项才能继续"
83
+ : isPlan
84
+ ? "agent 在等你批准计划"
85
+ : (action ? `需要你拍板:${action}` : "需要你拍板");
86
+ return {
87
+ event_id: makeEventId(deviceId, "approval", requestId),
88
+ device_id: deviceId,
89
+ kind: NOTIFY_KINDS.APPROVAL_REQUEST,
90
+ at,
91
+ session,
92
+ title,
93
+ body,
94
+ approval: {
95
+ request_id: requestId,
96
+ // 默认模式下 summary 只保留"动作名",不带完整命令(detail 模式才带原文)
97
+ summary: action,
98
+ options: normalizeOptions(node.options)
99
+ }
100
+ };
101
+ }
102
+
103
+ if (node.kind === NODE_KINDS.TURN_END) {
104
+ const reason = String(node.reason || "unknown");
105
+ const known = TURN_END_REASONS.includes(reason);
106
+ let kind = NOTIFY_KINDS.TURN_DONE;
107
+ let title = "任务完成";
108
+ let body = sessionTitle ? `${sessionTitle} 跑完了` : "任务跑完了";
109
+ if (reason === "error" || reason === "max-tokens") {
110
+ kind = NOTIFY_KINDS.TURN_ERROR;
111
+ title = reason === "max-tokens" ? "任务被截断" : "任务出错";
112
+ body = sessionTitle ? `${sessionTitle} ${title}` : title;
113
+ } else if (reason === "blocked") {
114
+ kind = NOTIFY_KINDS.TURN_BLOCKED;
115
+ title = "任务被拦下";
116
+ body = sessionTitle ? `${sessionTitle} 被拦下了` : "任务被拦下了,需要你看一下";
117
+ } else if (!known) {
118
+ /* 未知 reason:当成完成上报,但不假装"成功"——正文如实写原因 */
119
+ body = `${sessionTitle || "任务"} 结束了(${reason})`;
120
+ }
121
+ const key = `turn:${sessionId}:${node.turn ?? "?"}:${node.seq ?? "?"}:${reason}`;
122
+ return {
123
+ event_id: makeEventId(deviceId, "turn", key),
124
+ device_id: deviceId,
125
+ kind,
126
+ at,
127
+ session,
128
+ title,
129
+ body: truncate(body, MAX_BODY_LEN)
130
+ };
131
+ }
132
+
133
+ if (node.kind === NODE_KINDS.SESSION_ERROR) {
134
+ const message = truncate(String(node.message || "任务出错"), MAX_BODY_LEN);
135
+ return {
136
+ event_id: makeEventId(deviceId, "error", `${sessionId}:${node.code || message}:${at}`),
137
+ device_id: deviceId,
138
+ kind: NOTIFY_KINDS.TURN_ERROR,
139
+ at,
140
+ session,
141
+ title: "任务出错",
142
+ body: message
143
+ };
144
+ }
145
+
146
+ // gap / fault / digest / quota / membership / expired:本模块不负责(微信通道/其他模块在做)
147
+ return null;
148
+ }
149
+
150
+ function normalizeOptions(raw) {
151
+ const out = [];
152
+ if (Array.isArray(raw)) {
153
+ for (const o of raw) {
154
+ if (!o || typeof o !== "object") continue;
155
+ const value = typeof o.value === "string" ? o.value : "";
156
+ const label = typeof o.label === "string" ? o.label : "";
157
+ if (!value || !label) continue;
158
+ out.push({ value, label: truncate(label, 20) });
159
+ if (out.length >= 4) break;
160
+ }
161
+ }
162
+ return out.length ? out : [
163
+ { value: "allowed-once", label: "允许一次" },
164
+ { value: "rejected", label: "拒绝" }
165
+ ];
166
+ }
167
+
168
+ /** 默认模式下的安全摘要:只留动作名,绝不把完整命令行外发。 */
169
+ function safeSummary(tool, fallback) {
170
+ const t = String(tool || "").trim();
171
+ if (t) return truncate(t, 40);
172
+ const s = String(fallback || "").trim();
173
+ if (!s) return "需要你拍板";
174
+ if (COMMAND_HINT.test(s)) {
175
+ const m = s.split(/\s+/)[0];
176
+ return truncate(m, 40);
177
+ }
178
+ return truncate(s, 40);
179
+ }
180
+
181
+ /** 服务端契约:event_id 只允许 [A-Za-z0-9._:-](其它字符 400)。 */
182
+ const EVENT_ID_SAFE = /[^A-Za-z0-9._:-]/g;
183
+
184
+ function makeEventId(deviceId, scope, key) {
185
+ const raw = `${deviceId}:${scope}:${String(key || "").slice(0, 80)}`;
186
+ // 同 key 同一毫秒重复 = 同一事件;加上 at 会被去重逻辑判成不同事件,故不加
187
+ const id = raw.replace(EVENT_ID_SAFE, "_"); // 空格/问号/斜杠等一律换成下划线
188
+ return id.length > MAX_EVENT_ID_LEN ? id.slice(0, MAX_EVENT_ID_LEN) : id;
189
+ }
190
+
191
+ function truncate(s, n) {
192
+ const str = String(s == null ? "" : s).replace(/\s+/g, " ").trim();
193
+ if (str.length <= n) return str;
194
+ return str.slice(0, Math.max(0, n - 1)) + "…";
195
+ }
196
+
197
+ /**
198
+ * 创建上报器。不自动启动(见 `start()`)。
199
+ *
200
+ * @param {{
201
+ * apiBase: string,
202
+ * tokenProvider: () => (string|Promise<string>),
203
+ * deviceId: string,
204
+ * upstream: () => string,
205
+ * cookie?: () => string,
206
+ * detail?: boolean,
207
+ * sessionTitle?: (sessionId:string)=>string,
208
+ * log?: (level:string, message:string, meta?:object)=>void,
209
+ * fetchImpl?: typeof fetch,
210
+ * WebSocketImpl?: any,
211
+ * now?: () => number,
212
+ * maxQueue?: number,
213
+ * retryDelaysMs?: number[],
214
+ * startOnInit?: boolean,
215
+ * }} options
216
+ */
217
+ export function createPushReporter(options = {}) {
218
+ const apiBase = String(options.apiBase || "").replace(/\/+$/, "");
219
+ const deviceId = String(options.deviceId || "");
220
+ const tokenProvider = typeof options.tokenProvider === "function" ? options.tokenProvider : () => "";
221
+ const fetchImpl = typeof options.fetchImpl === "function" ? options.fetchImpl : globalThis.fetch;
222
+ const now = typeof options.now === "function" ? options.now : () => Date.now();
223
+ const log = typeof options.log === "function" ? options.log : () => {};
224
+ const detail = options.detail === true;
225
+ const maxQueue = Number.isFinite(options.maxQueue) ? options.maxQueue : MAX_QUEUE;
226
+ const retryDelaysMs = Array.isArray(options.retryDelaysMs) ? options.retryDelaysMs : RETRY_DELAYS_MS;
227
+
228
+ /** @type {{payload:object, tries:number}[]} */
229
+ const queue = [];
230
+ const seen = new Map(); // event_id -> at(有界)
231
+ const stats = { started: false, reason: "", enqueued: 0, sent: 0, dropped: 0, duplicates: 0, failed: 0, lastError: "" };
232
+
233
+ let subscriber = null;
234
+ let flushing = false;
235
+ let stopped = false;
236
+ let flushTimer = null;
237
+
238
+ function remember(id) {
239
+ if (seen.has(id)) return false;
240
+ seen.set(id, now());
241
+ // 有界:超过 500 条淘汰最旧(Map 保插入序)
242
+ if (seen.size > 500) {
243
+ const first = seen.keys().next();
244
+ if (!first.done) seen.delete(first.value);
245
+ }
246
+ return true;
247
+ }
248
+
249
+ function enqueue(payload) {
250
+ if (!payload) return false;
251
+ if (!remember(payload.event_id)) {
252
+ stats.duplicates += 1;
253
+ return false;
254
+ }
255
+ if (queue.length >= maxQueue) {
256
+ queue.shift();
257
+ stats.dropped += 1;
258
+ log("warn", "[push] 上报队列已满,丢弃最旧一条");
259
+ }
260
+ queue.push({ payload, tries: 0 });
261
+ stats.enqueued += 1;
262
+ scheduleFlush(0);
263
+ return true;
264
+ }
265
+
266
+ function scheduleFlush(delayMs) {
267
+ if (stopped) return;
268
+ if (flushTimer) return;
269
+ flushTimer = setTimeout(() => {
270
+ flushTimer = null;
271
+ void flush();
272
+ }, Math.max(0, delayMs));
273
+ // 不阻止进程退出(bridge 退出时最多等 1s,见 stop())
274
+ if (flushTimer && typeof flushTimer.unref === "function") flushTimer.unref();
275
+ }
276
+
277
+ async function flush() {
278
+ if (stopped && queue.length === 0) return;
279
+ if (flushing) return;
280
+ flushing = true;
281
+ try {
282
+ while (queue.length > 0) {
283
+ const item = queue[0];
284
+ const token = await Promise.resolve(tokenProvider()).catch(() => "");
285
+ if (!token) {
286
+ stats.lastError = "no_token";
287
+ log("warn", "[push] 没有可用 token,稍后再上报");
288
+ scheduleFlush(retryDelaysMs[0] || 5_000);
289
+ return;
290
+ }
291
+ const result = await postOnce(token, item.payload, apiBase, fetchImpl);
292
+ if (result.ok) {
293
+ queue.shift();
294
+ stats.sent += 1;
295
+ continue;
296
+ }
297
+ if (result.authFailed) {
298
+ // token 失效:丢这批并等下一次 device-login 刷新(不无限重试)
299
+ queue.shift();
300
+ stats.failed += 1;
301
+ stats.lastError = "auth_failed";
302
+ log("warn", "[push] device token 失效,丢弃本批上报(等下次登录刷新)");
303
+ continue;
304
+ }
305
+ if (result.clientError) {
306
+ // 契约错误(400/404/422):重试没意义
307
+ queue.shift();
308
+ stats.failed += 1;
309
+ stats.lastError = `http_${result.status}`;
310
+ log("warn", `[push] 服务端拒绝上报(HTTP ${result.status}),丢弃该条`);
311
+ continue;
312
+ }
313
+ item.tries += 1;
314
+ stats.failed += 1;
315
+ stats.lastError = result.error || `http_${result.status}`;
316
+ if (item.tries > retryDelaysMs.length) {
317
+ queue.shift();
318
+ log("warn", `[push] 上报重试 ${item.tries - 1} 次仍失败,丢弃该条`);
319
+ continue;
320
+ }
321
+ const delay = retryDelaysMs[item.tries - 1] || 5_000;
322
+ scheduleFlush(delay);
323
+ return;
324
+ }
325
+ } catch (e) {
326
+ stats.lastError = String(e && e.message ? e.message : e);
327
+ log("warn", `[push] 上报循环异常(已忽略):${stats.lastError}`);
328
+ } finally {
329
+ flushing = false;
330
+ }
331
+ }
332
+
333
+ /**
334
+ * 启动门禁(两层,都是服务端说了算 —— 客户端开关只是省流量,不是安全边界):
335
+ * ① `GET /api/me` → `notify.enabled`:服务端总开关(push_enabled)。老服务端没有该字段 = 关闭。
336
+ * ② `GET /api/push/public-key` → `subscribed`:本账号有没有订阅;没有就不必起事件订阅器。
337
+ */
338
+ async function probeEnabled(token) {
339
+ try {
340
+ const me = await fetchWithTimeout(`${apiBase}/api/me`, {
341
+ method: "GET",
342
+ headers: { authorization: `Bearer ${token}` }
343
+ }, fetchImpl, REQUEST_TIMEOUT_MS);
344
+ if (!me) return { ok: false, reason: "no_response" };
345
+ if (me.status === 401 || me.status === 403) return { ok: false, reason: "auth_failed" };
346
+ if (me.status !== 200) return { ok: false, reason: `http_${me.status}` };
347
+ const meBody = await safeJson(me);
348
+ const enabled = !!(meBody && meBody.notify && meBody.notify.enabled === true);
349
+ if (!enabled) return { ok: false, reason: "disabled" };
350
+
351
+ const sub = await fetchWithTimeout(`${apiBase}/api/push/public-key`, {
352
+ method: "GET",
353
+ headers: { authorization: `Bearer ${token}` }
354
+ }, fetchImpl, REQUEST_TIMEOUT_MS);
355
+ if (!sub) return { ok: false, reason: "no_response" };
356
+ if (sub.status === 200) {
357
+ const subBody = await safeJson(sub);
358
+ if (subBody && subBody.subscribed === true) return { ok: true };
359
+ return { ok: false, reason: "no_subscription" };
360
+ }
361
+ // 老服务端没有推送端点:当作没有订阅(不启动)
362
+ return { ok: false, reason: `http_${sub.status}` };
363
+ } catch (e) {
364
+ return { ok: false, reason: String(e && e.message ? e.message : e) };
365
+ }
366
+ }
367
+
368
+ return {
369
+ /** 启动:先问服务端"推送开了吗、本账号订阅了吗",再开始订阅事件。 */
370
+ async start() {
371
+ if (stats.started || stopped) return stats;
372
+ if (!apiBase) { stats.reason = "no_api_base"; return stats; }
373
+ if (!deviceId) { stats.reason = "no_device"; return stats; }
374
+ const token = await Promise.resolve(tokenProvider()).catch(() => "");
375
+ if (!token) { stats.reason = "no_token"; return stats; }
376
+ const gate = await probeEnabled(token);
377
+ if (!gate.ok) { stats.reason = gate.reason || "disabled"; log("info", `[push] 未启动上报:${stats.reason}`); return stats; }
378
+ try {
379
+ subscriber = createEventSubscriber({
380
+ upstream: typeof options.upstream === "function" ? options.upstream : String(options.upstream || ""),
381
+ cookie: typeof options.cookie === "function" ? options.cookie : (options.cookie || ""),
382
+ follow: true,
383
+ log: (level, message) => log(level === "error" ? "warn" : level, `[push/events] ${message}`),
384
+ now,
385
+ WebSocketImpl: options.WebSocketImpl,
386
+ fetchImpl
387
+ });
388
+ subscriber.on("event", (node) => {
389
+ try {
390
+ const payload = toNotifyPayload(node, {
391
+ deviceId,
392
+ detail,
393
+ sessionTitle: options.sessionTitle,
394
+ now
395
+ });
396
+ if (payload) enqueue(payload);
397
+ } catch (e) {
398
+ log("warn", `[push] 事件归一化失败(已忽略):${e && e.message ? e.message : e}`);
399
+ }
400
+ });
401
+ subscriber.on("auth-error", () => log("warn", "[push] 事件订阅鉴权失败,等 harness cookie 自愈"));
402
+ subscriber.on("gap", () => log("warn", "[push] 事件订阅断线窗口,期间通知可能漏发"));
403
+ subscriber.start();
404
+ stats.started = true;
405
+ stats.reason = "started";
406
+ } catch (e) {
407
+ stats.reason = `subscribe_failed:${e && e.message ? e.message : e}`;
408
+ log("warn", `[push] 订阅器启动失败(已忽略):${stats.reason}`);
409
+ }
410
+ return stats;
411
+ },
412
+
413
+ /** 停止:最多 1s 尽力 flush,然后释放订阅器。 */
414
+ async stop() {
415
+ stopped = true;
416
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
417
+ const deadline = now() + 1_000;
418
+ while (queue.length > 0 && now() < deadline) {
419
+ await flush();
420
+ if (queue.length > 0) await new Promise((r) => setTimeout(r, 50));
421
+ }
422
+ try { if (subscriber && typeof subscriber.close === "function") subscriber.close(); } catch { /* 忽略 */ }
423
+ subscriber = null;
424
+ stats.started = false;
425
+ },
426
+
427
+ /**
428
+ * 上报一条"本地 Agent(Claude Code)在等拍板"的事件,让手机收到推送。
429
+ *
430
+ * 与 DSH 事件那条路的区别:这里没有 DSH 事件节点,payload 由我们直接构造;
431
+ * `summary` 只用于服务端风险分级(默认不进推送正文,见服务端 `notify_include_detail`),
432
+ * **本模块不把它写进日志**。
433
+ *
434
+ * @param {{requestId:string, toolName?:string, kind?:string, summary?:string}} info
435
+ * @returns {boolean} 是否真的进了队列(没启动/没订阅时返回 false)
436
+ */
437
+ reportApproval(info = {}) {
438
+ const requestId = String(info.requestId || "");
439
+ if (!requestId) return false;
440
+ if (!stats.started) return false;
441
+ const toolName = String(info.toolName || "");
442
+ const summary = String(info.summary || toolName || "").slice(0, MAX_BODY_LEN);
443
+ const payload = {
444
+ event_id: `${deviceId}:approval:${requestId}`.replace(/[^A-Za-z0-9._:-]/g, "_").slice(0, MAX_EVENT_ID_LEN),
445
+ device_id: deviceId,
446
+ kind: NOTIFY_KINDS.APPROVAL_REQUEST,
447
+ at: now(),
448
+ session: { id: "" },
449
+ title: "需要你拍板",
450
+ body: toolName ? `Claude Code 想执行:${toolName}` : "Claude Code 在等你允许一步操作",
451
+ approval: {
452
+ request_id: requestId,
453
+ summary,
454
+ options: [
455
+ { value: "allowed-once", label: "允许一次" },
456
+ { value: "rejected", label: "拒绝" }
457
+ ]
458
+ }
459
+ };
460
+ return enqueue(payload);
461
+ },
462
+
463
+ /**
464
+ * 把一条审批回执写回 DSH(供决策轮询器调用)。
465
+ *
466
+ * 为什么放在这里:DSH 的审批回执要用订阅器里的 `clientId`(+ 上游地址/cookie),
467
+ * 而订阅器归本模块所有;另起一个订阅器既浪费一条 WS,也会让"谁有资格回执"变得模糊。
468
+ *
469
+ * @param {string} eventId DSH 的审批事件 id(= 上报体里的 approval.request_id)
470
+ * @param {"allowed-once"|"rejected"} outcome
471
+ * @returns {Promise<any>} 原样返回订阅器结果;没有订阅器时抛错(由调用方兜底)
472
+ */
473
+ async answerApproval(eventId, outcome) {
474
+ const sub = subscriber;
475
+ if (!sub || typeof sub.answerApproval !== "function") {
476
+ throw new Error("push-reporter: 事件订阅器未就绪,暂时无法回执审批");
477
+ }
478
+ return sub.answerApproval(eventId, outcome);
479
+ },
480
+
481
+ /** 只读状态(面板/诊断用;不包含任何事件内容)。 */
482
+ status() {
483
+ return { ...stats, queued: queue.length };
484
+ },
485
+
486
+ /** 仅供单测:直接塞一条(绕过订阅器)。 */
487
+ _enqueue(payload) { return enqueue(payload); },
488
+ /** 仅供单测:立即跑一次发送循环。 */
489
+ _flush: flush,
490
+ /** 仅供单测:把映射函数暴露出去,避免测试重复实现。 */
491
+ _toNotifyPayload: toNotifyPayload
492
+ };
493
+ }
494
+
495
+ async function postOnce(token, payload, apiBase, fetchImpl) {
496
+ try {
497
+ const r = await fetchWithTimeout(`${apiBase}/api/notify/event`, {
498
+ method: "POST",
499
+ headers: {
500
+ authorization: `Bearer ${token}`,
501
+ "content-type": "application/json"
502
+ },
503
+ body: JSON.stringify(payload)
504
+ }, fetchImpl, REQUEST_TIMEOUT_MS);
505
+ if (!r) return { ok: false, error: "no_response" };
506
+ if (r.status === 200) {
507
+ const body = await safeJson(r);
508
+ if (body && body.ok === true) return { ok: true };
509
+ return { ok: false, clientError: true, status: r.status };
510
+ }
511
+ if (r.status === 401 || r.status === 403) return { ok: false, authFailed: true, status: r.status };
512
+ if (r.status >= 400 && r.status < 500) return { ok: false, clientError: true, status: r.status };
513
+ return { ok: false, status: r.status };
514
+ } catch (e) {
515
+ return { ok: false, error: String(e && e.message ? e.message : e) };
516
+ }
517
+ }
518
+
519
+ async function fetchWithTimeout(url, init, fetchImpl, timeoutMs) {
520
+ if (typeof fetchImpl !== "function") return null;
521
+ const ctl = typeof AbortController === "function" ? new AbortController() : null;
522
+ const timer = ctl ? setTimeout(() => { try { ctl.abort(); } catch { /* 忽略 */ } }, timeoutMs) : null;
523
+ if (timer && typeof timer.unref === "function") timer.unref();
524
+ try {
525
+ return await fetchImpl(url, ctl ? { ...init, signal: ctl.signal } : init);
526
+ } finally {
527
+ if (timer) clearTimeout(timer);
528
+ }
529
+ }
530
+
531
+ async function safeJson(r) {
532
+ try { return await r.json(); } catch { return null; }
533
+ }