@marktrue/dsh-mobile-remote 3.2.1-rc.1 → 3.2.2-alpha.1

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/lib/index.js CHANGED
@@ -277,6 +277,30 @@ export const Config = z.object({
277
277
  approvalMode: z
278
278
  .union([z.const("both"), z.const("mobile"), z.const("desktop")])
279
279
  .default("both"),
280
+ /**
281
+ * 手机离线时仍由手机侧接管审批/问询的等待上限(毫秒;0 = 关闭,恢复 v3.2.0 的
282
+ * "离线一律交桌面 GUI"行为)。默认 5 分钟。
283
+ *
284
+ * 为什么需要(v3.2.x 跑在 DSH 0.2.x 上的实测缺陷):$events 通道不可用时 approvalMode
285
+ * 降级为 mobile 语义,而手机离线时 answerer 直接 next() 把审批让给桌面;人不在电脑前
286
+ * 时桌面无人应答,内核 approval promise 永久悬挂——会话一直 running、手机端既无卡片
287
+ * 也无待办、连"停止"都因网关签名变化而 400。开启本项后离线也建条目并写回放帧,打开
288
+ * App 时卡片会被补发;超过上限则 fail-close 成 unavailable,让内核继续走。
289
+ */
290
+ offlineHoldMs: z.number().min(0).max(3_600_000).default(300_000),
291
+ /**
292
+ * 手机离线时是否**同时**让桌面 GUI 弹卡(默认 true)。
293
+ * 关掉后回到"离线即由手机独占、桌面不弹";需要在家用桌面审批时保持默认。
294
+ */
295
+ offlineAlsoDesktop: z.boolean().default(true),
296
+ /**
297
+ * 审批/问询推送的宽限期(毫秒,0 = 立即推送)。默认 5000。
298
+ *
299
+ * 审批/问询到达时先挂起这么久再决定要不要推送:期间条目若被结算(桌面先答、另一端
300
+ * 已处理、自动批准),整条通知都不再发出。否则"人就坐在电脑前秒点一下",手机照样
301
+ * 会响一次 —— 纯噪音。与 zen-remote 的同名策略一致(它的默认值也是 5 秒)。
302
+ */
303
+ pushApprovalGraceMs: z.number().min(0).max(60_000).default(5_000),
280
304
  });
281
305
 
282
306
  /**
@@ -1300,6 +1324,36 @@ export const capabilityState = (service, predicate) => {
1300
1324
  }
1301
1325
  };
1302
1326
 
1327
+ /**
1328
+ * 二维码地址排序(issue #49)。
1329
+ *
1330
+ * 默认保持「局域网私有地址优先」:在家扫码必须给手机可达的局域网地址,组网地址
1331
+ * (Tailscale CGNAT 100.64/10)排在其后,避免「桌面有 Tailscale、手机没登录 → 扫到黑洞」。
1332
+ *
1333
+ * 例外:**当前没有任何局域网私有地址**时,把 Tailscale 提到最前。这类机器(校园网 / 热点 /
1334
+ * 只剩虚拟网卡)若不这样排,唯一可用的组网地址会被「其他」段压到后面,二维码首选变成不可达地址。
1335
+ * 两种情形都按「手机能否直接到达」取最优,而不是按网卡枚举顺序。
1336
+ *
1337
+ * 排序:0 = 10./192.168.x;1 = 172.16-31;2 = 100.64/10;3 = 其他。
1338
+ */
1339
+ export const orderQrAddresses = (ips) => {
1340
+ const rankOf = (ip) => {
1341
+ const o = String(ip).split(".").map(Number);
1342
+ if (o.length !== 4 || o.some((n) => !Number.isFinite(n))) return 3;
1343
+ if (o[0] === 10 || (o[0] === 192 && o[1] === 168)) return 0;
1344
+ if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return 1;
1345
+ if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return 2;
1346
+ return 3;
1347
+ };
1348
+ const hasLanPrivate = ips.some((ip) => {
1349
+ const rank = rankOf(ip);
1350
+ return rank === 0 || rank === 1;
1351
+ });
1352
+ // 没有局域网私有地址时,把 CGNAT 从 2 提到 0(其余保持原相对次序)
1353
+ const adjusted = (ip) => (rankOf(ip) === 2 && !hasLanPrivate ? 0 : rankOf(ip));
1354
+ return [...ips].sort((a, b) => adjusted(a) - adjusted(b));
1355
+ };
1356
+
1303
1357
  /** 从 ContentBlock[] 提取纯文本(默认过滤 tool-call;user 消息的 image 保留占位)。 */
1304
1358
  export function blocksToText(blocks, { includeToolCalls = false, imagePlaceholder = true } = {}) {
1305
1359
  let out = "";
@@ -3329,6 +3383,23 @@ export function apply(ctx, config, runtime = {}) {
3329
3383
  });
3330
3384
  return true;
3331
3385
  };
3386
+ /**
3387
+ * 带宽限的待答通知:宽限期内条目已被结算就整条跳过。
3388
+ * @param isPending 该条目是否仍挂着(按 phoneId 查 pending 表)
3389
+ * @param fire 真正发通知的动作(notifyAsk / notifyQuestion)
3390
+ */
3391
+ const notifyAskDeferred = (sessionId, isPending, fire) => {
3392
+ if (APPROVAL_PUSH_GRACE_MS <= 0) return fire();
3393
+ const timer = setTimeout(() => {
3394
+ if (typeof isPending === "function" && !isPending()) {
3395
+ ctx.logger.info?.("mobile-remote: 待答已在 " + APPROVAL_PUSH_GRACE_MS + "ms 内结算,跳过通知(" + sessionId + ")");
3396
+ return;
3397
+ }
3398
+ fire();
3399
+ }, APPROVAL_PUSH_GRACE_MS);
3400
+ timer.unref?.();
3401
+ return true;
3402
+ };
3332
3403
  const pendingEpochs = new Map(); // sessionId -> 自增序号(review M1:取消/重 arm 后旧回调失效)
3333
3404
  const bumpEpoch = (sessionId) => {
3334
3405
  pendingEpochs.set(sessionId, (pendingEpochs.get(sessionId) ?? 0) + 1);
@@ -3472,25 +3543,10 @@ export function apply(ctx, config, runtime = {}) {
3472
3543
  return true;
3473
3544
  })
3474
3545
  .map(({ iface }) => iface.address);
3475
- /** 地址排序(二维码首选/自动收集顺序):
3476
- * 0 = 家庭局域网常见段(192.168.x / 10.x)——二维码首选,保证在家扫码即连
3477
- * 1 = 组网常见段(172.16-31,蒲公英/ZeroTier 等虚拟网)
3478
- * 2 = Tailscale CGNAT(100.64/10)
3479
- * 3 = 其他。
3480
- * 原理:在家扫码时必须给手机可达的局域网地址;组网地址靠连接后自动收集,
3481
- * 避免"扫到组网 IP 而手机组网未开 → 黑洞"的连环故障。 */
3482
- const privateFirst = (ips) =>
3483
- [...ips].sort((a, b) => {
3484
- const rank = (ip) => {
3485
- const o = ip.split(".").map(Number);
3486
- if (o.length !== 4) return 3;
3487
- if (o[0] === 10 || (o[0] === 192 && o[1] === 168)) return 0;
3488
- if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return 1;
3489
- if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return 2;
3490
- return 3;
3491
- };
3492
- return rank(a) - rank(b);
3493
- });
3546
+ /** 地址排序(二维码首选/自动收集顺序)见 `orderQrAddresses`(issue #49):
3547
+ * 默认局域网私有地址优先;只有当机器上没有任何局域网私有地址时,才把
3548
+ * Tailscale CGNAT 提到最前,避免二维码首选到不可达地址。 */
3549
+ const privateFirst = (ips) => orderQrAddresses(ips);
3494
3550
  /** Host 校验白名单(与 dsh /api 信任围栏同思路,阻断 DNS 重绑定)。
3495
3551
  * = 回环(含 IPv6 ::1)+ 本机全部 internal IPv4(含 Tailscale/ZeroTier/WireGuard 虚拟网段)+ 显式配置的 `trustedHosts`(内网穿透中转)。 */
3496
3552
  const trustedHosts = () => new Set([
@@ -4951,45 +5007,46 @@ export function apply(ctx, config, runtime = {}) {
4951
5007
  /** v3.1.2:session/control(baseline)中取某会话的投影值(modelSelection/imageLimits 等)。 */
4952
5008
  const projectionOf = (control, sessionId) =>
4953
5009
  control?.type === "baseline" ? control.value?.projections?.[sessionId]?.values : undefined;
4954
- // ── 进程内 Peer(issue #34 / DSH 0.2.1-rc.2)──
4955
- // 网关不再为缺失的 peer 兜底,凡是走 `typertGateway` 的进程内调用都必须显式带上。
4956
- // 必须是**插件级单例**:`$events` 开流与其 `$events/result` 结算由宿主按 Peer 身份匹配
4957
- // (`dispatchRpc` 校验 `client.peer !== peer`),每次新建会在结算处被拒。
4958
- // 0.2.0 及更早没有 `connection.createPeer` → 保持 undefined(网关自带 operator Peer 兜底)。
4959
- // 惰性创建:`connection` 可能晚于插件挂载(实测宿主 0.2.1 首次取到 undefined),
4960
- // 首次取不到就留给调用方的既有重试/重连,下一次再建。
4961
- let inProcessPeer;
4962
- let peerFailureLogged = false;
4963
- const hostPeer = () => {
4964
- if (inProcessPeer) {
4965
- // 已缓存的 Peer 若 signal 已 abort,说明持有它的作用域已销毁(插件卸载,或宿主
4966
- // 未来把 createPeer 纳入 connection 作用域后 connection 重启)——宿主随后会以
4967
- // gateway/peer-disposed 拒绝一切调用。发现即重建,避免 `$events` 重连与 RPC 在
4968
- // **永久失败**里循环且无从恢复。(0.2.1 实测:Peer 随**读取 `ctx.connection` 的
4969
- // Context** 释放,而非 connection 提供者;本守卫是纵深防御。)
4970
- if (inProcessPeer.signal?.aborted !== true) return inProcessPeer;
4971
- inProcessPeer = undefined;
4972
- }
5010
+ /**
5011
+ * 0.2.x(实测 0.2.1-rc.2)把 typertGateway 入口从"对象选项"改成位置参数:
5012
+ * invokeRpc(endpoint, payload, signal, peer)
5013
+ * dispatchRpc(endpoint, payload, signal, peer)
5014
+ * openWireStream(endpoint, payload, uplink, peer, signal, control, uplinkWindow)
5015
+ * 并且 0.2.1 起每次调用都要带一个 **活着的** Peer 身份(内部读 `peer.signal` / `peer.principal`):
5016
+ * 网关注册的进程内入口是 `connection.createPeer({ principal, interactions })`。
5017
+ * 0.2.0 仍由网关 `operatorPeer` 兜底,0.1.x 是两参形态,两代都不消费本插件持有的 Peer。
5018
+ */
5019
+ let cachedOwnerPeer = null;
5020
+ /**
5021
+ * 取本次调用使用的 Peer(**插件级单例**)。0.2.1-alpha.3 起网关不再有隐式 operator 兜底,
5022
+ * 而 $events/result 必须由**打开 $events 的同一个 Peer** 应答(宿主按对象身份匹配),
5023
+ * 所以这里按"存活"缓存,不能每次新建。没有 createPeer 的宿主(0.2.0 及更早)返回
5024
+ * undefined,交给网关自己的兜底——**不伪造 Peer**:身份不匹配会让结算被静默拒绝。
5025
+ */
5026
+ const gatewayPeer = () => {
4973
5027
  let connection;
4974
5028
  try {
4975
5029
  connection = ctx.get("connection");
4976
- inProcessPeer = createInProcessPeer(connection) ?? undefined;
4977
- } catch (err) {
4978
- inProcessPeer = undefined;
4979
- if (!peerFailureLogged) {
4980
- peerFailureLogged = true;
4981
- ctx.logger.warn?.(`mobile-remote: 进程内 Peer 创建失败(${err?.message ?? err})——0.2.1+ 的网关调用会失败;诊断 gateway.eventsPeer 可归因`);
4982
- }
5030
+ } catch {
5031
+ connection = void 0;
4983
5032
  }
4984
- if (inProcessPeer === undefined && typeof connection?.createPeer === "function" && !peerFailureLogged) {
4985
- peerFailureLogged = true;
4986
- ctx.logger.warn?.("mobile-remote: connection.createPeer 未返回 Peer——RPC 与 $events 双端审批会失败;诊断 gateway.eventsPeer 可归因");
5033
+ if (typeof connection?.createPeer !== "function") return undefined;
5034
+ const live = cachedOwnerPeer !== null
5035
+ && !(cachedOwnerPeer.signal instanceof AbortSignal && cachedOwnerPeer.signal.aborted);
5036
+ if (live) return cachedOwnerPeer;
5037
+ try {
5038
+ cachedOwnerPeer = connection.createPeer({ principal: { kind: "owner" }, interactions: "answer" });
5039
+ return cachedOwnerPeer;
5040
+ } catch (err) {
5041
+ cachedOwnerPeer = null;
5042
+ ctx.logger.warn?.(`mobile-remote: connection.createPeer 失败(${err?.message ?? err})——0.2.1+ 的网关调用会失败;诊断 gateway.eventsPeer 可归因`);
5043
+ return undefined;
4987
5044
  }
4988
- return inProcessPeer;
4989
5045
  };
5046
+ // 卸载时释放 Peer scope,避免随重连/重载累积
4990
5047
  ctx.effect(() => () => {
4991
- const peer = inProcessPeer;
4992
- inProcessPeer = undefined;
5048
+ const peer = cachedOwnerPeer;
5049
+ cachedOwnerPeer = null;
4993
5050
  try {
4994
5051
  Promise.resolve(peer?.dispose?.()).catch(() => {});
4995
5052
  } catch { /* 卸载期忽略 */ }
@@ -5006,19 +5063,25 @@ export function apply(ctx, config, runtime = {}) {
5006
5063
  gateway = void 0;
5007
5064
  }
5008
5065
  if (gateway && typeof gateway.invokeRpc === "function") {
5066
+ // 0.2.x 位置参数形态形参 ≥3;0.1.x 是 (endpoint, options) 两参
5067
+ const modernGateway = gateway.invokeRpc.length >= 3;
5068
+ const peer = modernGateway ? gatewayPeer() : void 0;
5009
5069
  const endpoint = RPC_ENDPOINT_RENAMES[method] ?? method.replace(".", "/");
5010
5070
  const adapted = RPC_PAYLOAD_ADAPTER[method]?.(payload) ?? { request: payload };
5011
5071
  if (RPC_STREAM_METHODS.has(method)) {
5012
5072
  // 基线流(session/control 等):首帧即基线快照,取完即断
5013
5073
  const [namespace, name] = endpoint.split("/");
5014
5074
  const abort = new AbortController();
5015
- const stream = await gateway.stream({ namespace, method: name, args: adapted, signal: abort.signal, peer: hostPeer() });
5075
+ const request = { namespace, method: name, args: adapted, signal: abort.signal };
5076
+ const stream = await gateway.stream(modernGateway ? { ...request, peer } : request);
5016
5077
  const iter = stream[Symbol.asyncIterator]();
5017
5078
  const first = await iter.next();
5018
5079
  abort.abort();
5019
5080
  return first?.value;
5020
5081
  }
5021
- const full = await gateway.invokeRpc(endpoint, { args: adapted }, undefined, hostPeer());
5082
+ const full = modernGateway
5083
+ ? await gateway.invokeRpc(endpoint, { args: adapted }, peer?.signal, peer)
5084
+ : await gateway.invokeRpc(endpoint, { args: adapted });
5022
5085
  if (full?.ok === true) return full.value;
5023
5086
  const e = new Error(full?.error?.message ?? `${method} failed`);
5024
5087
  e.status = 400;
@@ -7541,7 +7604,14 @@ export function apply(ctx, config, runtime = {}) {
7541
7604
  if (!gatewaySvc) return { state: "missing" };
7542
7605
  if (!wireStreamTakesControl(gatewaySvc)) return { state: "ok" };
7543
7606
  if (peerSource === "operatorPeer") return { state: "ok" };
7544
- return hostPeer() ? { state: "ok" } : { state: "drift", reason: "gateway-peer-source-missing" };
7607
+ if (peerSource === "createPeer") {
7608
+ // 必须真的造出来才算 ok:createPeer 抛错时网关调用同样必失败
7609
+ const peer = gatewayPeer();
7610
+ return peer && peer === cachedOwnerPeer
7611
+ ? { state: "ok" }
7612
+ : { state: "drift", reason: "gateway-peer-source-missing" };
7613
+ }
7614
+ return { state: "drift", reason: "gateway-peer-source-missing" };
7545
7615
  })();
7546
7616
  // issue #30 评审 WARNING 3:`settings.read` **不能只看方法是否存在**。严格读取器已经能识别
7547
7617
  // "get()/describe() 抛错、describe 返回非数组、已认识命名空间的描述符缺 value",真实读取会
@@ -7897,14 +7967,16 @@ export function apply(ctx, config, runtime = {}) {
7897
7967
  const sessions = ctx.get("sessions");
7898
7968
  const subagentsSvc = ctx.get("subagents");
7899
7969
  const agent = agents?.get(parentSessionId);
7900
- // issue #20:与 /sessions 同一条全量枚举路径,持久化回退也接入请求取消。
7970
+ // issue #20:与 /sessions 同一条全量枚举路径,宿主目录与持久化回退都接入请求取消。
7901
7971
  const signal = requestAbort(res);
7902
7972
  const parent = sessions?.get?.(parentSessionId);
7903
7973
  const hasCatalog = typeof subagentsSvc?.listChildren === "function";
7904
- // 目录来源按优先级取,必须同时保留 host catalog 的已释放条目与休眠父会话回退:
7905
- // ① host subagents.listChildren:两代都有,包含已释放子代理和 mode/label 元数据;
7906
- // ② listPersistedSubagents:宿主目录不可用时,支持父会话休眠/归档及信号取消;
7907
- // ③ live sessions registry:仅作不完整兜底,并显式标记 catalogDegraded。
7974
+ // 目录来源按优先级取——**两边能力都要保留**(issue #14/#17/#21):
7975
+ // ① 宿主持久目录 `subagents.listChildren`:包含已释放子代理和 mode/label 元数据,
7976
+ // 不要求父会话有活跃 Agent;
7977
+ // ② 持久化枚举 `listPersistedSubagents`:目录不可用时保留休眠/归档父会话能力;
7978
+ // ③ 会话注册表派生:不完整兜底,必须标注 catalogDegraded。
7979
+ // 状态另取自活 agent(AgentStatus = idle | running);目录条目本身不带状态。
7908
7980
  let catalogRows = null;
7909
7981
  let catalogDegraded = false;
7910
7982
  if (hasCatalog) {
@@ -7918,17 +7990,19 @@ export function apply(ctx, config, runtime = {}) {
7918
7990
  }
7919
7991
  }
7920
7992
  if (catalogRows === null) {
7993
+ // 取消在途请求时不再启动持久化枚举,也不向已关闭的 socket 写响应。
7921
7994
  if (signal.aborted) return;
7922
7995
  const persisted = await listPersistedSubagents(parentSessionId, signal);
7923
7996
  if (signal.aborted) return;
7997
+ // issue #20 第 2 步:枚举预算耗尽时返回 { timedOut: true };必须与 null 分开,
7998
+ // 否则超时会被误报为 404 session-not-found。
7924
7999
  if (persisted?.timedOut) return error(res, 504, "subagents-timeout");
7925
8000
  if (persisted === null) {
7926
8001
  // 持久层不可读(宿主无 sessionQuery)→ 留给 ③ 兜底
7927
8002
  } else if (!persisted.parentExists) {
7928
- return error(res, 404, "session-not-found"); // 会话确实不存在
8003
+ return error(res, 404, "session-not-found"); // 会话确实不存在(与 develop 语义一致)
7929
8004
  } else {
7930
- // 刻意**不取**其 `status`:那条路径写死 "inactive",会盖掉活注册表的真实状态;
7931
- // 标题由 ② 带出(title),在下方映射里优先于继承来的父标题。
8005
+ // 刻意**不取**其 `status`:那条路径写死 "inactive",会盖掉注册表的真实状态
7932
8006
  catalogRows = persisted.entries.map((entry) => ({
7933
8007
  id: entry.id,
7934
8008
  title: entry.title,
@@ -7945,6 +8019,7 @@ export function apply(ctx, config, runtime = {}) {
7945
8019
  catalogDegraded = true;
7946
8020
  }
7947
8021
  if (catalogRows === null) return error(res, 404, "session-not-found");
8022
+
7948
8023
  // 排序与 issue #17 对齐:createdAt 降序(最新派生在最上),等值按 id 升序。
7949
8024
  const rows = catalogRows.map((row) => {
7950
8025
  const id = typeof row?.id === "string" && row.id !== "" ? row.id : "";
@@ -7957,6 +8032,7 @@ export function apply(ctx, config, runtime = {}) {
7957
8032
  // 已完成轮次的事件前缀(含父的 `session/title`),而 sessionTitleOf 从整个快照反查
7958
8033
  // 标题、不区分继承事件与子代理自身事件——直接用它会让多个不同委派标签的 fork
7959
8034
  // 全部显示父标题,丢掉任务辨识信息。
8035
+ // 标题优先级:catalog 的 label → ② 带来的 title → 子会话自身标题 → 短码
7960
8036
  const label = typeof row?.label === "string" && row.label !== "" ? row.label : undefined;
7961
8037
  const rowTitle = typeof row?.title === "string" && row.title !== "" ? row.title : undefined;
7962
8038
  const liveTitle = sessionTitleOf(session);
@@ -7964,8 +8040,8 @@ export function apply(ctx, config, runtime = {}) {
7964
8040
  }).filter((row) => row.id !== "");
7965
8041
  // issue #30 评审 WARNING 1:目录条目的 `label` 可省略,而**已释放**的子代理不在 live
7966
8042
  // 注册表里(拿不到会话快照标题)。三个来源都拿不到时必须按需**补读持久标题**,
7967
- // 否则会退化成短 ID——而基线在父会话不活跃时一直走持久枚举、是能拿到标题的,属新回归。
7968
- // 只对需要的条目批量读一次;复用 /sessions 的 revision cache、取消与标题预算。
8043
+ // 否则会退化成短 ID——基线在父会话不活跃时也能取得标题,不能丢掉该能力。
8044
+ // 只补读缺少 label/目录标题/live 标题的条目;复用 /sessions 的 revision cache、取消和预算。
7969
8045
  const needPersistedTitle = rows.filter((row) => !row.label && !row.rowTitle && !row.liveTitle).map((row) => row.id);
7970
8046
  const titleBudgetStartedAt = performance.now();
7971
8047
  const persistedTitleMap = needPersistedTitle.length > 0
@@ -7975,8 +8051,8 @@ export function apply(ctx, config, runtime = {}) {
7975
8051
  const entries = rows
7976
8052
  .map((row) => ({
7977
8053
  id: row.id,
7978
- // issue #30 评审 WARNING 2:目录的 `mode: "unknown"` 是宿主明确判定的**不受支持
7979
- // 的历史条目**(宿主 `listDescendants` 映射为 diagnostic/unsupported),不能伪装成
8054
+ // issue #30 评审 WARNING 2:目录 `mode: "unknown"` 是宿主明确判定的**不受支持
8055
+ // 条目**(宿主 `listDescendants` 映射为 diagnostic/unsupported),不能伪装成
7980
8056
  // 普通闲置子代理——否则消费者无法区分"不受支持"与"正常闲置",等于删掉 RPC 时
7981
8057
  // 顺带静默删掉既有诊断语义(docs/03-api 承诺 `status` 可为 diagnostic reason)。
7982
8058
  kind: row.mode === "unknown" ? "diagnostic" : "child",
@@ -7995,6 +8071,7 @@ export function apply(ctx, config, runtime = {}) {
7995
8071
  if (ta !== tb) return tb - ta;
7996
8072
  return String(a.id).localeCompare(String(b.id));
7997
8073
  });
8074
+ // issue #20:客户端在扫描期间已经走了,不再写响应。
7998
8075
  if (signal.aborted) return;
7999
8076
  sendJson(res, 200, {
8000
8077
  ok: true,
@@ -8219,6 +8296,12 @@ export function apply(ctx, config, runtime = {}) {
8219
8296
  const approvalMode = config.approvalMode ?? "both";
8220
8297
  /** 手机待办超时 fail-close(沿用 v3.1.2 的 120s;超时前另一端先答则本端直接收卡)。 */
8221
8298
  const PENDING_TIMEOUT_MS = 120_000;
8299
+ /** 手机离线时仍接管审批/问询的等待上限(修复:离线交桌面导致内核悬挂)。 */
8300
+ const OFFLINE_HOLD_MS = Math.max(0, Number(config.offlineHoldMs ?? 300_000) || 0);
8301
+ /** 审批/问询推送的宽限期(0 = 立即推)。 */
8302
+ const APPROVAL_PUSH_GRACE_MS = Math.max(0, Number(config.pushApprovalGraceMs ?? 5_000) || 0);
8303
+ /** 手机离线时是否同时放行桌面 GUI(双端呈现,先答者生效)。 */
8304
+ const offlineAlsoDesktop = config.offlineAlsoDesktop !== false;
8222
8305
  const pendingApprovals = new Map(); // phoneApprovalId -> { phoneId, sessionId, rpcId, timer, settle(outcome) }
8223
8306
  const pendingQuestions = new Map(); // phoneQuestionId -> { phoneId, sessionId, rpcId, timer, settle(value) }
8224
8307
  /**
@@ -8239,7 +8322,7 @@ export function apply(ctx, config, runtime = {}) {
8239
8322
  for (const key of askReplayKeys(prefix, entry)) pendingFrames.delete(key);
8240
8323
  };
8241
8324
  /** $events 进程内客户端就绪态(both 双端呈现的传输前提;事件按发生时刻实时判定)。 */
8242
- const eventsState = { ready: false, clientId: "" };
8325
+ const eventsState = { ready: false, clientId: "", peer: null };
8243
8326
  let eventsAbort = null; // AbortController:插件卸载时断开 $events 流
8244
8327
  const eventsCapable = () => eventsState.ready && eventsAbort !== null && !eventsAbort.signal.aborted;
8245
8328
  /**
@@ -8261,9 +8344,10 @@ export function apply(ctx, config, runtime = {}) {
8261
8344
  try {
8262
8345
  const gateway = ctx.get("typertGateway");
8263
8346
  if (!gateway || typeof gateway.dispatchRpc !== "function" || !eventsState.clientId) return false;
8264
- const full = await gateway.dispatchRpc("$events/result", {
8265
- args: { clientId: eventsState.clientId, eventId, outcome: { kind: "result", value } },
8266
- }, undefined, hostPeer());
8347
+ const args = { clientId: eventsState.clientId, eventId, outcome: { kind: "result", value } };
8348
+ const full = gateway.dispatchRpc.length >= 3
8349
+ ? await gateway.dispatchRpc("$events/result", { args }, void 0, eventsState.peer)
8350
+ : await gateway.dispatchRpc("$events/result", { args });
8267
8351
  if (full?.ok !== true) {
8268
8352
  ctx.logger.warn?.(`mobile-remote: $events/result 结算失败(${eventId} → ${full?.error?.code ?? "unknown"})`);
8269
8353
  return false;
@@ -8292,9 +8376,10 @@ export function apply(ctx, config, runtime = {}) {
8292
8376
  try {
8293
8377
  const gateway = ctx.get("typertGateway");
8294
8378
  if (!gateway || typeof gateway.dispatchRpc !== "function" || !eventsState.clientId) return false;
8295
- const full = await gateway.dispatchRpc("$events/result", {
8296
- args: { clientId: eventsState.clientId, eventId, outcome: { kind: "rejected", error: { name, message, code } } },
8297
- }, undefined, hostPeer());
8379
+ const args = { clientId: eventsState.clientId, eventId, outcome: { kind: "rejected", error: { name, message, code } } };
8380
+ const full = gateway.dispatchRpc.length >= 3
8381
+ ? await gateway.dispatchRpc("$events/result", { args }, void 0, eventsState.peer)
8382
+ : await gateway.dispatchRpc("$events/result", { args });
8298
8383
  if (full?.ok !== true) {
8299
8384
  ctx.logger.warn?.(`mobile-remote: $events/result 拒绝回执失败(${eventId} → ${full?.error?.code ?? "unknown"})`);
8300
8385
  return false;
@@ -8340,7 +8425,7 @@ export function apply(ctx, config, runtime = {}) {
8340
8425
  return true;
8341
8426
  };
8342
8427
  const armTimeout = (entry, onFire) => {
8343
- const timer = setTimeout(onFire, PENDING_TIMEOUT_MS);
8428
+ const timer = setTimeout(onFire, entry.holdMs ?? PENDING_TIMEOUT_MS);
8344
8429
  timer.unref?.();
8345
8430
  entry.timer = timer;
8346
8431
  };
@@ -8374,56 +8459,64 @@ export function apply(ctx, config, runtime = {}) {
8374
8459
  const phoneKey = `${sessionId}:${typeof req?.callId === "string" && req.callId !== ""
8375
8460
  ? req.callId
8376
8461
  : Date.now().toString(36)}`;
8377
- ctx.logger.info?.(`mobile-remote: approval/request 手机接管(${sessionId} tool=${req?.toolName})`);
8378
- return new Promise((resolve) => {
8379
- const entry = { phoneId: phoneKey, sessionId, rpcId: phoneKey, timer: null, settle: resolve };
8380
- pendingApprovals.set(phoneKey, entry);
8381
- armTimeout(entry, () => {
8382
- // 手机无响应:fail-close(与内核 unavailable 语义一致),不悬挂工具调用
8383
- ctx.logger.warn?.(`mobile-remote: approval/request 手机超时未应答 → unavailable(${phoneKey})`);
8384
- finishApproval(entry, "unavailable");
8385
- });
8386
- const requested = {
8387
- type: "approval/requested", rpcId: entry.rpcId, sessionId, approvalId: phoneKey,
8388
- toolName: req?.toolName, callId: req?.callId, reason: req?.reason,
8389
- };
8390
- // v3.1.4(issue #14):接管期间手机断线重连也能拿回卡片(与 $events 路径同一契约)
8391
- putAskReplay("a", entry, requested);
8392
- broadcast({ type: "mobile/frame", frame: requested });
8393
- notifyAsk(sessionId, req);
8462
+ const phoneOnline = connections.size > 0;
8463
+ const holdMs = phoneOnline ? PENDING_TIMEOUT_MS : OFFLINE_HOLD_MS;
8464
+ ctx.logger.info?.(`mobile-remote: approval/request 手机接管(${sessionId} tool=${req?.toolName}${phoneOnline ? "" : `,手机离线,最多等待 ${Math.round(holdMs / 1000)}s`})`);
8465
+ // v3.3.0:改为先建 Promise 再建条目,便于双端竞速时由调用方拿到 entry 收卡
8466
+ let resolveHeld;
8467
+ const held = new Promise((resolve) => { resolveHeld = resolve; });
8468
+ const entry = { phoneId: phoneKey, sessionId, rpcId: phoneKey, timer: null, holdMs, settle: resolveHeld };
8469
+ pendingApprovals.set(phoneKey, entry);
8470
+ armTimeout(entry, () => {
8471
+ // 手机无响应:fail-close(与内核 unavailable 语义一致),不悬挂工具调用
8472
+ ctx.logger.warn?.(`mobile-remote: approval/request 手机超时未应答 → unavailable(${phoneKey})`);
8473
+ finishApproval(entry, "unavailable");
8394
8474
  });
8475
+ const requested = {
8476
+ type: "approval/requested", rpcId: entry.rpcId, sessionId, approvalId: phoneKey,
8477
+ toolName: req?.toolName, callId: req?.callId, reason: req?.reason,
8478
+ };
8479
+ // v3.1.4(issue #14):接管期间手机断线重连也能拿回卡片(与 $events 路径同一契约)
8480
+ putAskReplay("a", entry, requested);
8481
+ broadcast({ type: "mobile/frame", frame: requested });
8482
+ notifyAskDeferred(sessionId, () => pendingApprovals.has(phoneKey), () => notifyAsk(sessionId, req));
8483
+ return { held, entry };
8395
8484
  };
8396
8485
  const holdQuestion = (req, sessionId) => {
8397
8486
  const phoneKey = `${sessionId}:${Date.now().toString(36)}`;
8398
- ctx.logger.info?.(`mobile-remote: user-questions/request 手机接管(${sessionId})`);
8487
+ const phoneOnline = connections.size > 0;
8488
+ const holdMs = phoneOnline ? PENDING_TIMEOUT_MS : OFFLINE_HOLD_MS;
8489
+ ctx.logger.info?.(`mobile-remote: user-questions/request 手机接管(${sessionId}${phoneOnline ? "" : `,手机离线,最多等待 ${Math.round(holdMs / 1000)}s`})`);
8399
8490
  // v3.1.5(PR #11 P0):rpcId = phoneKey(见 holdApproval 同名说明);
8400
8491
  // 同时补 reject —— 取消/超时走 UserQuestionError 拒绝,不再 resolve(null)
8401
8492
  // (null 会让内核 dsh-tool-ask-user 读 .answers 抛 TypeError)。
8402
- return new Promise((resolve, reject) => {
8403
- const entry = {
8404
- phoneId: phoneKey, sessionId, rpcId: phoneKey, timer: null,
8405
- // questions 必须随条目保存:/respond 的答案校验(validateQuestionAnswers)
8406
- // 要拿原始提问核对选项/单选多选,缺失会让所有应答被判非法 400
8407
- questions: Array.isArray(req?.questions) ? req.questions : [],
8408
- settle: (value) => { resolve(value); return true; },
8409
- reject: (reason) => { reject(reason); return true; },
8410
- };
8411
- pendingQuestions.set(phoneKey, entry);
8412
- armTimeout(entry, () => {
8413
- ctx.logger.warn?.(`mobile-remote: user-questions/request 手机超时未应答 → 取消(${phoneKey})`);
8414
- finishQuestion(entry, questionCancelledError("ask_user_question timed out before the user answered"), { reject: true });
8415
- });
8416
- const requested = {
8417
- type: "question/requested", rpcId: entry.rpcId, sessionId, questionId: phoneKey,
8418
- // 内核瀑布值为顶层 questions(userQuestions.ask 传 {questions, agent, signal},
8419
- // GUI 端 $on 同样读 request.questions);v3.1.2 误用 req.request.questions
8420
- // → 手机端广播空问题列表(手机问询不可用的遗留根因之一,真机验证发现)
8421
- questions: req?.questions ?? [],
8422
- };
8423
- putAskReplay("q", entry, requested); // v3.1.4:同上,断线重连可回放
8424
- broadcast({ type: "mobile/frame", frame: requested });
8425
- notifyQuestion(sessionId);
8493
+ let resolveHeld;
8494
+ let rejectHeld;
8495
+ const held = new Promise((resolve, reject) => { resolveHeld = resolve; rejectHeld = reject; });
8496
+ const entry = {
8497
+ phoneId: phoneKey, sessionId, rpcId: phoneKey, timer: null, holdMs,
8498
+ // questions 必须随条目保存:/respond 的答案校验(validateQuestionAnswers)
8499
+ // 要拿原始提问核对选项/单选多选,缺失会让所有应答被判非法 400
8500
+ questions: Array.isArray(req?.questions) ? req.questions : [],
8501
+ settle: (value) => { resolveHeld(value); return true; },
8502
+ reject: (reason) => { rejectHeld(reason); return true; },
8503
+ };
8504
+ pendingQuestions.set(phoneKey, entry);
8505
+ armTimeout(entry, () => {
8506
+ ctx.logger.warn?.(`mobile-remote: user-questions/request 手机超时未应答 → 取消(${phoneKey})`);
8507
+ finishQuestion(entry, questionCancelledError("ask_user_question timed out before the user answered"), { reject: true });
8426
8508
  });
8509
+ const requested = {
8510
+ type: "question/requested", rpcId: entry.rpcId, sessionId, questionId: phoneKey,
8511
+ // 内核瀑布值为顶层 questions(userQuestions.ask 传 {questions, agent, signal},
8512
+ // GUI 端 $on 同样读 request.questions);v3.1.2 误用 req.request.questions
8513
+ // → 手机端广播空问题列表(手机问询不可用的遗留根因之一,真机验证发现)
8514
+ questions: req?.questions ?? [],
8515
+ };
8516
+ putAskReplay("q", entry, requested); // v3.1.4:同上,断线重连可回放
8517
+ broadcast({ type: "mobile/frame", frame: requested });
8518
+ notifyAskDeferred(sessionId, () => pendingQuestions.has(phoneKey), () => notifyQuestion(sessionId));
8519
+ return { held, entry };
8427
8520
  };
8428
8521
 
8429
8522
  // $events 事件处理:瀑布经内核转发到达网关后才投给本客户端。
@@ -8472,8 +8565,9 @@ export function apply(ctx, config, runtime = {}) {
8472
8565
  armOfflineReap(entry, "a");
8473
8566
  }
8474
8567
  broadcast({ type: "mobile/frame", frame: requested });
8475
- // 推送与手机是否在线无关:App 关闭时这是**唯一**的提醒通道(issue #14 Bug1)
8476
- notifyAsk(sessionId, request);
8568
+ // 推送与手机是否在线无关:App 关闭时这是**唯一**的提醒通道(issue #14 Bug1)。
8569
+ // 宽限期:桌面先答/自动批准时不该再响一次。
8570
+ notifyAskDeferred(sessionId, () => pendingApprovals.has(phoneKey), () => notifyAsk(sessionId, request));
8477
8571
  return;
8478
8572
  }
8479
8573
  if (frame.event === "user-questions/request") {
@@ -8509,7 +8603,7 @@ export function apply(ctx, config, runtime = {}) {
8509
8603
  armOfflineReap(entry, "q");
8510
8604
  }
8511
8605
  broadcast({ type: "mobile/frame", frame: requested });
8512
- notifyQuestion(sessionId);
8606
+ notifyAskDeferred(sessionId, () => pendingQuestions.has(phoneKey), () => notifyQuestion(sessionId));
8513
8607
  return;
8514
8608
  }
8515
8609
  };
@@ -8590,16 +8684,15 @@ export function apply(ctx, config, runtime = {}) {
8590
8684
  let everReady = false;
8591
8685
  while (!controller.signal.aborted) {
8592
8686
  try {
8593
- // v3.1.6(issue #19):宿主 0.2.0 在途中插入了 uplink/peer,取消信号从第 3 位移到第 5 位。
8594
- // 传错位不会报参数错误:信号落进 uplink、真正的 signal 为 undefined,宿主在
8595
- // AbortSignal.any([undefined, …]) 处抛错并被本循环吞掉 —— 现象是
8596
- // approvalMode=both 的 $events 通道**永远不就绪**(静默降级为 mobile 语义)。
8597
- // issue #34:0.2.1 起第 4 位 peer 也不再可省(网关删除 operatorPeer 兜底)——
8598
- // 传插件级单例 Peer,与 $events/result 结算同源;首次取不到(connection 尚未
8599
- // 挂载)时传 undefined,由下面的有界重试等宿主就绪。
8600
- const stream = wireStreamTakesControl(gateway)
8601
- ? await gateway.openWireStream("$events", { args: {} }, undefined, hostPeer(), controller.signal)
8687
+ // 0.2.x:openWireStream(endpoint, payload, uplink, peer, signal, control, uplinkWindow)
8688
+ // 旧宿主只有 (endpoint, payload, signal)。uplink 必须是异步迭代器
8689
+ // (宿主会对它调用 return()),$events 分支里只被释放、不被消费。
8690
+ const peer = gatewayPeer();
8691
+ const emptyUplink = (async function* emptyUplink() {})();
8692
+ const stream = gateway.openWireStream.length >= 6
8693
+ ? await gateway.openWireStream("$events", { args: {} }, emptyUplink, peer, controller.signal, new AbortController())
8602
8694
  : await gateway.openWireStream("$events", { args: {} }, controller.signal);
8695
+ eventsState.peer = peer;
8603
8696
  for await (const frame of stream) {
8604
8697
  try {
8605
8698
  if (frame?.type === "ready" && typeof frame.clientId === "string" && frame.clientId !== "") {
@@ -8651,23 +8744,58 @@ export function apply(ctx, config, runtime = {}) {
8651
8744
  // (这正是"两端都没弹卡、请求悬挂"的根因)。prepend:true 排在内核转发监听之前——
8652
8745
  // 是否抢先消费即 issue #9 的开关点:mobile(含 both 降级)手机在线即接管;
8653
8746
  // both 就绪 / desktop 一律 next() 放行内核转发(桌面弹卡),手机卡由 $events 补发。
8747
+ // 双端策略:approvalMode=both → 始终两端同显、先答生效($events 不可用时用 hold+next 竞速复现);
8748
+ // mobile → 手机在线即独占;离线时按 offlineAlsoDesktop 决定是否带桌面兜底。
8749
+ const dualRoute = (hold, next, onDownstream) => {
8750
+ const downstream = Promise.resolve().then(() => next());
8751
+ return Promise.race([hold, downstream]).then(
8752
+ (winner) => {
8753
+ // 下游抢答但结果是 `unavailable`(没有任何桌面 $events 客户端 / 转发通道超时):
8754
+ // **不能**当成先答——手机侧卡片已经建好,用户可能正要作答。保留条目,
8755
+ // 等手机 /respond 或 hold 自己的 fail-close 超时给出最终结果
8756
+ // (真机验收 issue #46 时发现)。
8757
+ if (winner === "unavailable") return hold;
8758
+ // 真答复(桌面先答)/ 手机先答 / 取消:按先答生效,另一端收卡
8759
+ onDownstream();
8760
+ return winner;
8761
+ },
8762
+ (err) => {
8763
+ onDownstream();
8764
+ throw err;
8765
+ },
8766
+ );
8767
+ };
8654
8768
  const offApproval = ctx.on("approval/request", (req, next) => {
8655
- const mobileHold = approvalMode !== "desktop" && !eventsCapable() && connections.size > 0;
8656
- if (mobileHold) {
8657
- const sessionId = req?.agent?.session?.id ?? "";
8658
- if (sessionId) return holdApproval(req, sessionId);
8659
- }
8660
- ctx.logger.info?.("mobile-remote: approval/request 放行内核转发(桌面 GUI 弹卡)");
8661
- return next();
8769
+ const phoneOnline = connections.size > 0;
8770
+ const mobileHold = approvalMode !== "desktop" && !eventsCapable() && (phoneOnline || OFFLINE_HOLD_MS > 0);
8771
+ const sessionId = mobileHold ? (req?.agent?.session?.id ?? "") : "";
8772
+ if (!mobileHold || !sessionId) {
8773
+ ctx.logger.info?.("mobile-remote: approval/request 放行内核转发(桌面 GUI 弹卡)");
8774
+ return next();
8775
+ }
8776
+ const { held, entry } = holdApproval(req, sessionId);
8777
+ // both 的语义就是「两端同显、先答生效」。$events 不可用时用 hold + next 竞速在本地复现;
8778
+ // mobile 保持手机独占;离线场景由 offlineAlsoDesktop 决定是否带上桌面。
8779
+ const wantDual = approvalMode === "both" || (!phoneOnline && offlineAlsoDesktop);
8780
+ if (!wantDual) return held;
8781
+ ctx.logger.info?.(`mobile-remote: approval/request 双端呈现(${phoneOnline ? "手机在线" : "手机离线,桌面兜底"}),先答生效`);
8782
+ return dualRoute(held, next, () => finishApproval(entry, "cancelled"));
8662
8783
  }, { prepend: true, global: true });
8663
8784
  const offQuestion = ctx.on("user-questions/request", (req, next) => {
8664
- const mobileHold = approvalMode !== "desktop" && !eventsCapable() && connections.size > 0;
8665
- if (mobileHold) {
8666
- const sessionId = req?.agent?.session?.id ?? "";
8667
- if (sessionId) return holdQuestion(req, sessionId);
8668
- }
8669
- ctx.logger.info?.("mobile-remote: user-questions/request 放行内核转发(桌面 GUI 弹卡)");
8670
- return next();
8785
+ const phoneOnline = connections.size > 0;
8786
+ const mobileHold = approvalMode !== "desktop" && !eventsCapable() && (phoneOnline || OFFLINE_HOLD_MS > 0);
8787
+ const sessionId = mobileHold ? (req?.agent?.session?.id ?? "") : "";
8788
+ if (!mobileHold || !sessionId) {
8789
+ ctx.logger.info?.("mobile-remote: user-questions/request 放行内核转发(桌面 GUI 弹卡)");
8790
+ return next();
8791
+ }
8792
+ const { held, entry } = holdQuestion(req, sessionId);
8793
+ const wantDual = approvalMode === "both" || (!phoneOnline && offlineAlsoDesktop);
8794
+ if (!wantDual) return held;
8795
+ ctx.logger.info?.(`mobile-remote: user-questions/request 双端呈现(${phoneOnline ? "手机在线" : "手机离线,桌面兜底"}),先答生效`);
8796
+ // 桌面先答时 held 会被 reject:挂一个空 catch,避免"未处理的 rejection"
8797
+ held.catch(() => void 0);
8798
+ return dualRoute(held, next, () => finishQuestion(entry, questionCancelledError("another client answered the question"), { reject: true }));
8671
8799
  }, { prepend: true, global: true });
8672
8800
  return () => {
8673
8801
  offApproval?.();
@@ -8676,6 +8804,7 @@ export function apply(ctx, config, runtime = {}) {
8676
8804
  eventsAbort = null;
8677
8805
  eventsState.ready = false;
8678
8806
  eventsState.clientId = "";
8807
+ eventsState.peer = null;
8679
8808
  for (const entry of pendingApprovals.values()) if (entry.timer) clearTimeout(entry.timer);
8680
8809
  for (const entry of pendingQuestions.values()) if (entry.timer) clearTimeout(entry.timer);
8681
8810
  pendingApprovals.clear();