@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/CHANGELOG.md +405 -17
- package/docs/00-/345/274/200/345/217/221/346/200/273/347/272/262.md +4 -1
- package/docs/01-PRD.md +1 -1
- package/docs/02-architecture.md +2 -2
- package/docs/03-api.md +146 -32
- package/docs/04-security.md +9 -5
- package/docs/05-test-cases.md +58 -18
- package/docs/06-install-run.md +64 -0
- package/docs/07-user-manual.md +2 -1
- package/docs/09-compatibility.md +60 -32
- package/docs/10-release.md +3 -1
- package/docs/spec-app-auto-update.md +143 -0
- package/lib/index.js +271 -142
- package/package.json +2 -2
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
|
-
*
|
|
3477
|
-
*
|
|
3478
|
-
|
|
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
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
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
|
-
|
|
4977
|
-
|
|
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 (
|
|
4985
|
-
|
|
4986
|
-
|
|
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 =
|
|
4992
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
7905
|
-
// ①
|
|
7906
|
-
//
|
|
7907
|
-
//
|
|
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
|
-
//
|
|
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
|
|
7979
|
-
//
|
|
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
|
|
8265
|
-
|
|
8266
|
-
|
|
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
|
|
8296
|
-
|
|
8297
|
-
|
|
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
|
-
|
|
8378
|
-
|
|
8379
|
-
|
|
8380
|
-
|
|
8381
|
-
|
|
8382
|
-
|
|
8383
|
-
|
|
8384
|
-
|
|
8385
|
-
|
|
8386
|
-
|
|
8387
|
-
|
|
8388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8403
|
-
|
|
8404
|
-
|
|
8405
|
-
|
|
8406
|
-
|
|
8407
|
-
|
|
8408
|
-
|
|
8409
|
-
|
|
8410
|
-
}
|
|
8411
|
-
|
|
8412
|
-
|
|
8413
|
-
|
|
8414
|
-
|
|
8415
|
-
});
|
|
8416
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
8594
|
-
//
|
|
8595
|
-
//
|
|
8596
|
-
|
|
8597
|
-
|
|
8598
|
-
|
|
8599
|
-
|
|
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
|
|
8656
|
-
|
|
8657
|
-
|
|
8658
|
-
|
|
8659
|
-
|
|
8660
|
-
|
|
8661
|
-
|
|
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
|
|
8665
|
-
|
|
8666
|
-
|
|
8667
|
-
|
|
8668
|
-
|
|
8669
|
-
|
|
8670
|
-
|
|
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();
|