dsh-remote-control 1.0.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.
- package/LICENSE +21 -0
- package/cordis.patch.yml +21 -0
- package/dist/bundle/index.js +33956 -0
- package/dist/src/core/clock.d.ts +45 -0
- package/dist/src/core/clock.js +83 -0
- package/dist/src/core/clock.js.map +1 -0
- package/dist/src/core/keys.d.ts +98 -0
- package/dist/src/core/keys.js +176 -0
- package/dist/src/core/keys.js.map +1 -0
- package/dist/src/core/pairing-window.d.ts +95 -0
- package/dist/src/core/pairing-window.js +92 -0
- package/dist/src/core/pairing-window.js.map +1 -0
- package/dist/src/core/runtime.d.ts +104 -0
- package/dist/src/core/runtime.js +491 -0
- package/dist/src/core/runtime.js.map +1 -0
- package/dist/src/core/sleep-policy.d.ts +81 -0
- package/dist/src/core/sleep-policy.js +134 -0
- package/dist/src/core/sleep-policy.js.map +1 -0
- package/dist/src/core/window.d.ts +88 -0
- package/dist/src/core/window.js +185 -0
- package/dist/src/core/window.js.map +1 -0
- package/dist/src/index.d.ts +70 -0
- package/dist/src/index.js +882 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/platform/carrier-services.d.ts +96 -0
- package/dist/src/platform/carrier-services.js +982 -0
- package/dist/src/platform/carrier-services.js.map +1 -0
- package/dist/src/platform/guard.d.ts +60 -0
- package/dist/src/platform/guard.js +124 -0
- package/dist/src/platform/guard.js.map +1 -0
- package/dist/src/platform/mock-kernel.d.ts +95 -0
- package/dist/src/platform/mock-kernel.js +180 -0
- package/dist/src/platform/mock-kernel.js.map +1 -0
- package/dist/src/platform/qr.d.ts +28 -0
- package/dist/src/platform/qr.js +80 -0
- package/dist/src/platform/qr.js.map +1 -0
- package/dist/src/platform/sleep-posix.d.ts +17 -0
- package/dist/src/platform/sleep-posix.js +91 -0
- package/dist/src/platform/sleep-posix.js.map +1 -0
- package/dist/src/ports/index.d.ts +246 -0
- package/dist/src/ports/index.js +2 -0
- package/dist/src/ports/index.js.map +1 -0
- package/dist/src/shell/config.d.ts +56 -0
- package/dist/src/shell/config.js +212 -0
- package/dist/src/shell/config.js.map +1 -0
- package/dist/src/shell/pairing-text.d.ts +38 -0
- package/dist/src/shell/pairing-text.js +22 -0
- package/dist/src/shell/pairing-text.js.map +1 -0
- package/dist/src/shell/status.d.ts +39 -0
- package/dist/src/shell/status.js +119 -0
- package/dist/src/shell/status.js.map +1 -0
- package/dist/src/transport/relay.d.ts +107 -0
- package/dist/src/transport/relay.js +359 -0
- package/dist/src/transport/relay.js.map +1 -0
- package/dist/tests/carrier-events.test.d.ts +1 -0
- package/dist/tests/carrier-events.test.js +88 -0
- package/dist/tests/carrier-events.test.js.map +1 -0
- package/dist/tests/carrier-services.test.d.ts +1 -0
- package/dist/tests/carrier-services.test.js +562 -0
- package/dist/tests/carrier-services.test.js.map +1 -0
- package/dist/tests/carrier-vocabulary.test.d.ts +1 -0
- package/dist/tests/carrier-vocabulary.test.js +149 -0
- package/dist/tests/carrier-vocabulary.test.js.map +1 -0
- package/dist/tests/clock-probe-timers.test.d.ts +1 -0
- package/dist/tests/clock-probe-timers.test.js +73 -0
- package/dist/tests/clock-probe-timers.test.js.map +1 -0
- package/dist/tests/command-result.test.d.ts +1 -0
- package/dist/tests/command-result.test.js +185 -0
- package/dist/tests/command-result.test.js.map +1 -0
- package/dist/tests/config.test.d.ts +1 -0
- package/dist/tests/config.test.js +223 -0
- package/dist/tests/config.test.js.map +1 -0
- package/dist/tests/guard.test.d.ts +1 -0
- package/dist/tests/guard.test.js +175 -0
- package/dist/tests/guard.test.js.map +1 -0
- package/dist/tests/ids-and-keys.test.d.ts +1 -0
- package/dist/tests/ids-and-keys.test.js +211 -0
- package/dist/tests/ids-and-keys.test.js.map +1 -0
- package/dist/tests/pairing-text.test.d.ts +1 -0
- package/dist/tests/pairing-text.test.js +62 -0
- package/dist/tests/pairing-text.test.js.map +1 -0
- package/dist/tests/pairing-window.test.d.ts +1 -0
- package/dist/tests/pairing-window.test.js +165 -0
- package/dist/tests/pairing-window.test.js.map +1 -0
- package/dist/tests/relay-client.test.d.ts +1 -0
- package/dist/tests/relay-client.test.js +557 -0
- package/dist/tests/relay-client.test.js.map +1 -0
- package/dist/tests/runtime.test.d.ts +1 -0
- package/dist/tests/runtime.test.js +963 -0
- package/dist/tests/runtime.test.js.map +1 -0
- package/dist/tests/sleep.test.d.ts +1 -0
- package/dist/tests/sleep.test.js +264 -0
- package/dist/tests/sleep.test.js.map +1 -0
- package/dist/tests/status.test.d.ts +1 -0
- package/dist/tests/status.test.js +238 -0
- package/dist/tests/status.test.js.map +1 -0
- package/dist/tests/window.test.d.ts +1 -0
- package/dist/tests/window.test.js +210 -0
- package/dist/tests/window.test.js.map +1 -0
- package/package.json +70 -0
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* index — cordis bundle 入口(host 侧)。
|
|
3
|
+
*
|
|
4
|
+
* 职责边界(取证 docs/legacy-spec/host-plugin-cordis.md §1):这个文件只做四件事
|
|
5
|
+
* —— 读配置并校验、探测内核载体、把内核端口/中继客户端/运行时/防休眠接起来、
|
|
6
|
+
* 注册 `/drc` 命令并持续写状态快照。业务逻辑都在 `core/`,平台知识都在 `platform/`。
|
|
7
|
+
*
|
|
8
|
+
* 三条不可违反的纪律:
|
|
9
|
+
*
|
|
10
|
+
* 1. **`apply()` 绝不向外抛异常**。宿主加载 bundle 时若这里抛出,整个 profile 起不来。
|
|
11
|
+
* 所有失败路径都收敛成"写一次 status.json + 记一条日志"。
|
|
12
|
+
* 2. **不写 `inject:` 闸门**(见同目录 cordis.patch.yml 注释):本文件对每个内核面
|
|
13
|
+
* 都软探测,写闸门只会让某一代宿主缺服务时整行不激活、插件静默不加载。
|
|
14
|
+
* 3. **`provide()` 无条件执行**:外部(测试、其它插件)拿到的永远是同一个句柄,
|
|
15
|
+
* runtime 还没起来时 `createPairing()` 返回 null,而不是"这个插件不存在"。
|
|
16
|
+
*/
|
|
17
|
+
import { execFile } from 'node:child_process';
|
|
18
|
+
import { randomBytes } from 'node:crypto';
|
|
19
|
+
import { buildPairingUri } from 'dsh-remote-wire';
|
|
20
|
+
import { HostRuntime } from './core/runtime.js';
|
|
21
|
+
import { KeepAwake } from './core/sleep-policy.js';
|
|
22
|
+
import { PairingSlots } from './core/keys.js';
|
|
23
|
+
import { PairingWindow } from './core/pairing-window.js';
|
|
24
|
+
import { RelayClient } from './transport/relay.js';
|
|
25
|
+
import { SystemSleepBackend } from './platform/sleep-posix.js';
|
|
26
|
+
import { createServicesKernel } from './platform/carrier-services.js';
|
|
27
|
+
import { createOneShotTimers, DEFAULT_SYSTEM_CLOCK } from './core/clock.js';
|
|
28
|
+
import { pairingImagePath, StatusFile, writePrivateFile } from './shell/status.js';
|
|
29
|
+
import { pairingPairText, PAIR_UNAVAILABLE_TEXT } from './shell/pairing-text.js';
|
|
30
|
+
import { DEFAULT_CONFIG, readConfig, redact, validateConfig } from './shell/config.js';
|
|
31
|
+
import { renderTerminalQr, qrPng } from './platform/qr.js';
|
|
32
|
+
const SERVICE_NAMES = [
|
|
33
|
+
'sessions',
|
|
34
|
+
'sessionQuery',
|
|
35
|
+
'agents',
|
|
36
|
+
'agentDefaultModel',
|
|
37
|
+
'workspaceRegistry',
|
|
38
|
+
'userQuestions',
|
|
39
|
+
'sessionController',
|
|
40
|
+
];
|
|
41
|
+
/** 同时保持的配对通道硬上界:超出就从最不活跃的开始剪(空闲 TTL 之外的第二道闸)。 */
|
|
42
|
+
const MAX_CONVERSATIONS = 64;
|
|
43
|
+
/** 取证:同一个进程里宿主调用了几次 `apply`(bundle 被重复挂载时能看到)。 */
|
|
44
|
+
const APPLY_COUNT = { n: 0 };
|
|
45
|
+
/** 默认时钟:真实定时器。测试注入假时钟(见 core/clock.ts)。 */
|
|
46
|
+
export const systemClock = DEFAULT_SYSTEM_CLOCK;
|
|
47
|
+
export function apply(ctx, injected = {}) {
|
|
48
|
+
let handle;
|
|
49
|
+
try {
|
|
50
|
+
handle = applyInner(ctx, injected);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
// 兜底:任何一步抛出都不许穿出去。
|
|
54
|
+
try {
|
|
55
|
+
process.stderr.write(`[dsh-remote-control] apply failed: ${String(error?.stack ?? error)}\n`);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* stderr 也可能不可用 */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// applyInner 之后碰 ctx 的每一句都必须各自包 try。
|
|
62
|
+
// 真机取证(status.json 的 probe.fiberStates):原来这里写的是
|
|
63
|
+
// ctx.onDispose?.(() => handle?.stop())
|
|
64
|
+
// 而 cordis 的 ctx 是 Proxy——读一个**它没有的**属性名不是返回 undefined,是抛错
|
|
65
|
+
// (reflect.ts 的 get trap:`cannot get property "onDispose" without inject`),
|
|
66
|
+
// 可选链 `?.` 只挡 null/undefined、挡不住抛错。这一句抛出被 Fiber._reload 捕获,
|
|
67
|
+
// 我们的 fiber 进 FAILED → _unload 把 applyInner 里登记的六个 ctx.inject 子 fiber
|
|
68
|
+
// 一起 dispose(probe.injectChildren 全在 @DISPOSED/uid=null)→ 回调永远不触发 →
|
|
69
|
+
// carrier=none,哪怕 `ctx.get('sessions', true)` 两秒后一直拿得到服务。
|
|
70
|
+
// 卸载钩子只能用 cordis 真存在的 `ctx.effect`(reflect.ts:220 mixin 了它)。
|
|
71
|
+
try {
|
|
72
|
+
ctx.provide?.('dshRemoteControl', {
|
|
73
|
+
get state() {
|
|
74
|
+
return handle?.state() ?? { relay: 'offline', carrier: 'none', reason: 'not-started' };
|
|
75
|
+
},
|
|
76
|
+
createPairing: () => handle?.createPairing() ?? null,
|
|
77
|
+
// presentation 那一半要按会话解析落点(右栏那张图放哪),而"会话 → 工作区"这点
|
|
78
|
+
// 平台知识只在本插件里有。**在这里开一个口,而不是让那一半自己去探内核服务**:
|
|
79
|
+
// 两份探测迟早分叉,而分叉的表现是"卡片说 A 地、右栏读 B 地",很难查。
|
|
80
|
+
sessionWorkspace: (sessionId) => handle?.sessionWorkspace(sessionId),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* 这一代宿主可能不支持 provide */
|
|
85
|
+
}
|
|
86
|
+
let disposeHooked = false;
|
|
87
|
+
try {
|
|
88
|
+
if (typeof ctx.effect === 'function') {
|
|
89
|
+
ctx.effect(() => () => void handle?.stop());
|
|
90
|
+
disposeHooked = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
/* 没有 effect 口的代际 */
|
|
95
|
+
}
|
|
96
|
+
if (!disposeHooked && 'onDispose' in ctx) {
|
|
97
|
+
// `'x' in ctx` 走的是 Proxy 的 has trap,不抛错——这是在这个宿主上"探测一个属性
|
|
98
|
+
// 到底有没有"的唯一安全姿势;直接点属性就等着抛。测试替身/别的代际才提供 onDispose。
|
|
99
|
+
try {
|
|
100
|
+
;
|
|
101
|
+
ctx.onDispose?.(() => void handle?.stop());
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* 同上 */
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function applyInner(ctx, injected) {
|
|
109
|
+
const config = readConfig(injected);
|
|
110
|
+
const clock = systemClock;
|
|
111
|
+
// 载具探测的复查(8s/25s/60s)与载具宽限都是"宿主还活着就顺手看一眼"的一次性动作。
|
|
112
|
+
// 它们必须 unref 且在停机时撤掉,否则 60s 那一发会把事件循环钉住——`node e2e/run.mjs`
|
|
113
|
+
// 1.2s 跑完却 60s 才退出、任何 headless/CLI 跑法白等一分钟,都出自这里(见 core/clock.ts)。
|
|
114
|
+
const oneShot = createOneShotTimers(clock);
|
|
115
|
+
const status = new StatusFile(config.statusFile, clock);
|
|
116
|
+
const problems = validateConfig(config);
|
|
117
|
+
const log = (message, fields = {}) => {
|
|
118
|
+
try {
|
|
119
|
+
process.stdout.write(`${JSON.stringify({ ts: new Date().toISOString(), level: 'info', msg: `dsh-remote-control ${message}`, ...fields })}\n`);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* GUI 宿主里 stdout 可能不可用 */
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
for (const problem of problems) {
|
|
126
|
+
log(`config ${problem.level}`, { field: problem.field, message: problem.message });
|
|
127
|
+
}
|
|
128
|
+
if (!config.enabled) {
|
|
129
|
+
status.write({ carrier: 'none', relay: 'idle', reason: 'disabled' });
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
if (problems.some((problem) => problem.level === 'error')) {
|
|
133
|
+
// 有 error 级问题就不启动,但一定留下面包屑:否则"为什么什么都没发生"没有答案。
|
|
134
|
+
status.write({
|
|
135
|
+
carrier: 'none',
|
|
136
|
+
relay: 'idle',
|
|
137
|
+
reason: 'invalid-config',
|
|
138
|
+
serverUrl: config.serverUrl,
|
|
139
|
+
hostToken: undefined,
|
|
140
|
+
problems: problems.map((problem) => `${problem.level}:${problem.field}:${problem.message}`),
|
|
141
|
+
});
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
const slots = new PairingSlots(() => clock.now());
|
|
145
|
+
const sleep = new KeepAwake(new SystemSleepBackend(), clock, {
|
|
146
|
+
idleReleaseMs: config.keepAwake.idleReleaseSec * 1000,
|
|
147
|
+
keepDisplay: config.keepAwake.keepDisplay,
|
|
148
|
+
});
|
|
149
|
+
if (config.keepAwake.enabled)
|
|
150
|
+
sleep.setEnabled(true);
|
|
151
|
+
let kernel;
|
|
152
|
+
let runtime;
|
|
153
|
+
let relay;
|
|
154
|
+
let startedCarrier = '';
|
|
155
|
+
// RelayClient 是唯一知道连接此刻怎么样的一方;状态快照读这两个值。
|
|
156
|
+
let lastRelayState = 'connecting';
|
|
157
|
+
let lastRelayProblem;
|
|
158
|
+
let lastRelayGeneration = 0;
|
|
159
|
+
const transport = {
|
|
160
|
+
reply(conversationId, payload) {
|
|
161
|
+
return relay ? relay.send(conversationId, payload) : false;
|
|
162
|
+
},
|
|
163
|
+
broadcast(payload) {
|
|
164
|
+
return relay ? relay.broadcast(payload) : 0;
|
|
165
|
+
},
|
|
166
|
+
hasPeer(conversationId) {
|
|
167
|
+
return relay?.hasPeer(conversationId) ?? false;
|
|
168
|
+
},
|
|
169
|
+
conversationIds() {
|
|
170
|
+
return relay?.conversationIds() ?? [];
|
|
171
|
+
},
|
|
172
|
+
voidConversation(conversationId) {
|
|
173
|
+
relay?.voidConversation(conversationId);
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
const start = (port) => {
|
|
177
|
+
if (runtime)
|
|
178
|
+
return;
|
|
179
|
+
kernel = port;
|
|
180
|
+
startedCarrier = port.carrier;
|
|
181
|
+
relay = new RelayClient({
|
|
182
|
+
url: config.serverUrl,
|
|
183
|
+
hostId: config.hostId || `h_${randomBytes(3).toString('hex')}`,
|
|
184
|
+
label: config.hostLabel,
|
|
185
|
+
token: config.hostToken,
|
|
186
|
+
clock,
|
|
187
|
+
log,
|
|
188
|
+
lookupPairingSlot: (token) => slots.resolveFor(token),
|
|
189
|
+
onPairReady: (token, ttlMs) => {
|
|
190
|
+
// 服务端权威 TTL:不改写就会提前清掉 PSK,造成"配对成功但全解不开"。
|
|
191
|
+
if (!slots.applyServerTtl(token, ttlMs))
|
|
192
|
+
log('pair-ready for an unknown token', { token });
|
|
193
|
+
pairingWindow.applyServerTtl(token, ttlMs);
|
|
194
|
+
},
|
|
195
|
+
onPeerJoined: (conversationId, pairingToken) => {
|
|
196
|
+
runtime?.start();
|
|
197
|
+
void runtime?.pushSessions('peer-joined');
|
|
198
|
+
// 一次性码用掉了就当场换一张新的(而不是等半程刷新):第二部手机要的是一张新码,
|
|
199
|
+
// 多轮真链路取证也靠这条才不用重启宿主。
|
|
200
|
+
if (pairingToken) {
|
|
201
|
+
pairingWindow.markConsumed(pairingToken);
|
|
202
|
+
// status.json 里也不能再挂着这张已经用掉的码:外部脚本只看年龄,不看是否被消费。
|
|
203
|
+
if (active.pairing?.token === pairingToken)
|
|
204
|
+
active.pairing = null;
|
|
205
|
+
}
|
|
206
|
+
log('client paired', { conversationId });
|
|
207
|
+
},
|
|
208
|
+
onClientLeft: () => {
|
|
209
|
+
void runtime?.pushSessions('client-left');
|
|
210
|
+
},
|
|
211
|
+
onConversationGone: (conversationId) => log('conversation gone', { conversationId }),
|
|
212
|
+
onCommand: (conversationId, cmd) => void runtime?.handleCommand(cmd, conversationId),
|
|
213
|
+
onState: (info) => {
|
|
214
|
+
lastRelayState = info.relay;
|
|
215
|
+
lastRelayProblem = info.problem;
|
|
216
|
+
lastRelayGeneration = info.generation;
|
|
217
|
+
},
|
|
218
|
+
// 配对通道长存(D3)之后必须自己剪枝:中继那边的空闲 TTL 是 7 天,
|
|
219
|
+
// 不剪就是无界的 PSK 簿 + 每次广播对废弃通道逐个密封。
|
|
220
|
+
prunePolicy: { idleTtlMs: config.conversationIdleTtlSec * 1000, maxConversations: MAX_CONVERSATIONS },
|
|
221
|
+
});
|
|
222
|
+
runtime = new HostRuntime(port, transport, sleep, clock, {
|
|
223
|
+
listingRefreshMs: config.listingRefreshSec * 1000,
|
|
224
|
+
unarchiveOnPrompt: config.unarchiveOnPrompt,
|
|
225
|
+
approvalTimeoutMs: config.approvalTimeoutSec * 1000,
|
|
226
|
+
log: (message, fields) => log(message, fields),
|
|
227
|
+
});
|
|
228
|
+
relay.connect();
|
|
229
|
+
// 自动发码走 pairingWindow,而不是启动时发一张就完事:
|
|
230
|
+
// 一张码是一次性的,用完/中继重启/过半程都得换一张(判据见 core/pairing-window.ts)。
|
|
231
|
+
// tick 挂在 status 的 3 秒节拍上(旧实现就是挂在这里),并且**先 tick 一次**,
|
|
232
|
+
// 让 `pairOnStartSec>0` 的实例一起来就能看到 status.json 里有码。
|
|
233
|
+
pairingWindow.tick();
|
|
234
|
+
status.start(() => {
|
|
235
|
+
pairingWindow.tick();
|
|
236
|
+
relay?.pruneConversations();
|
|
237
|
+
return state();
|
|
238
|
+
});
|
|
239
|
+
};
|
|
240
|
+
const publishPairing = (ttlMs = config.pairTtlMs) => {
|
|
241
|
+
if (!relay)
|
|
242
|
+
return null;
|
|
243
|
+
const slot = slots.create(ttlMs);
|
|
244
|
+
// 发不出去就不算一张码:`raw()` 对非 OPEN 的 socket 是静默丢弃,
|
|
245
|
+
// 只记本地会留下"status.json 显示有效、中继那边根本没收到"的死码(取证 §6.2)。
|
|
246
|
+
if (!relay.publishPairing(slot)) {
|
|
247
|
+
log('pairing published to a socket that is not open', { token: slot.token });
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
const qr = buildPairingUri({
|
|
251
|
+
server: config.serverUrl,
|
|
252
|
+
psk: slot.psk,
|
|
253
|
+
hostLabel: config.hostLabel,
|
|
254
|
+
token: slot.token,
|
|
255
|
+
});
|
|
256
|
+
return { qr, token: slot.token, psk: slot.psk, expiresAt: slot.expiresAt };
|
|
257
|
+
};
|
|
258
|
+
const state = () => {
|
|
259
|
+
const snapshot = sleep.snapshot();
|
|
260
|
+
return {
|
|
261
|
+
carrier: startedCarrier || (config.mockBridge ? 'mock' : 'probing'),
|
|
262
|
+
serverUrl: config.serverUrl,
|
|
263
|
+
hostLabel: config.hostLabel,
|
|
264
|
+
relay: relay ? relayState() : 'idle',
|
|
265
|
+
relayProblem: relayProblem(),
|
|
266
|
+
conversations: relay?.conversationCount ?? 0,
|
|
267
|
+
pendingPairs: slots.size,
|
|
268
|
+
pairing: describeActivePairing(),
|
|
269
|
+
keepAwake: snapshot,
|
|
270
|
+
kernel: kernel?.describe() ?? null,
|
|
271
|
+
// 出站计数:手机上"没收到 X"的第一现场对比点(插件没发 vs 发了但路上丢了)。
|
|
272
|
+
outbound: runtime?.stats ?? null,
|
|
273
|
+
probe,
|
|
274
|
+
// token 永远不出现真值;这条有测试锁住。
|
|
275
|
+
hostToken: undefined,
|
|
276
|
+
hostTokenShape: config.hostToken ? redact(config.hostToken) : undefined,
|
|
277
|
+
mockBridgeForced: config.mockBridge,
|
|
278
|
+
problems: problems.map((problem) => `${problem.level}:${problem.field}`),
|
|
279
|
+
...(config.pairOnStartSec > 0 ? { pairOnStartWarn: 'status.json 里带着仍然有效的配对码与 PSK' } : {}),
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
const active = { pairing: null };
|
|
283
|
+
/**
|
|
284
|
+
* status.json 里的 `pairing` 必须是**窗口正在维护的那一张**(它的 QR 才在 active.pairing 里),
|
|
285
|
+
* 不是 `slots.latest()`:手工 /drc pair 之后自动刷新会发新的一张,两者一旦分叉,
|
|
286
|
+
* 外部脚本读到的是"一张没有二维码的码"。用 `resolveFor` 取密钥顺带把已过期的剔掉。
|
|
287
|
+
*/
|
|
288
|
+
function describeActivePairing() {
|
|
289
|
+
const shown = active.pairing;
|
|
290
|
+
if (!shown)
|
|
291
|
+
return null;
|
|
292
|
+
const slot = slots.resolveFor(shown.token);
|
|
293
|
+
if (!slot)
|
|
294
|
+
return null;
|
|
295
|
+
return {
|
|
296
|
+
token: slot.token,
|
|
297
|
+
psk: slot.psk,
|
|
298
|
+
expiresAt: new Date(slot.expiresAt).toISOString(),
|
|
299
|
+
qr: shown.qr,
|
|
300
|
+
ageSec: Math.max(0, Math.round((clock.now() - slot.createdAt) / 1000)),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
function relayState() {
|
|
304
|
+
return lastRelayState;
|
|
305
|
+
}
|
|
306
|
+
function relayProblem() {
|
|
307
|
+
return lastRelayProblem;
|
|
308
|
+
}
|
|
309
|
+
const wrappedPublish = (ttlMs = config.pairTtlMs) => {
|
|
310
|
+
const created = publishPairing(ttlMs);
|
|
311
|
+
if (created) {
|
|
312
|
+
active.pairing = { qr: created.qr, token: created.token, expiresAt: created.expiresAt };
|
|
313
|
+
// 手工发的那张也要让窗口认领,否则窗口以为"没有活动码"又发一张。
|
|
314
|
+
pairingWindow.adopt({ token: created.token, createdAt: clock.now(), expiresAt: created.expiresAt });
|
|
315
|
+
}
|
|
316
|
+
return created;
|
|
317
|
+
};
|
|
318
|
+
const createPairing = () => wrappedPublish();
|
|
319
|
+
/**
|
|
320
|
+
* "让屏幕上永远有一张能用的码"这条策略(四条 stale 判据 + online 闸门,
|
|
321
|
+
* 逐条动机见 `core/pairing-window.ts` 文件头)。tick 挂在 status 的 3 秒节拍上。
|
|
322
|
+
*/
|
|
323
|
+
const pairingWindow = new PairingWindow({
|
|
324
|
+
clock,
|
|
325
|
+
relayOnline: () => lastRelayState === 'online' && relay !== undefined,
|
|
326
|
+
generation: () => lastRelayGeneration,
|
|
327
|
+
publish: (ttlMs) => {
|
|
328
|
+
const created = wrappedPublish(ttlMs);
|
|
329
|
+
if (!created)
|
|
330
|
+
return null;
|
|
331
|
+
return { token: created.token, createdAt: clock.now(), expiresAt: created.expiresAt };
|
|
332
|
+
},
|
|
333
|
+
log,
|
|
334
|
+
}, config.pairOnStartSec);
|
|
335
|
+
// ── 载体探测:services > apiproxy > (mock) ─────────────────────────
|
|
336
|
+
const collected = {};
|
|
337
|
+
const hasServices = () => typeof collected.sessions === 'object' && collected.sessions !== null;
|
|
338
|
+
const apiProxyOf = () => collected.apiProxy;
|
|
339
|
+
const probe = {};
|
|
340
|
+
const injectFired = [];
|
|
341
|
+
let hasGet = true;
|
|
342
|
+
let hasInject = true;
|
|
343
|
+
let decided = false;
|
|
344
|
+
/** 取证:`ctx.inject` 返回的子 fiber,用来区分"依赖没满足"和"子 fiber 已被拆掉"。 */
|
|
345
|
+
const injectChildren = [];
|
|
346
|
+
/** 取证:我们这条 fiber 的生命周期轨迹(含进入 UNLOADING/DISPOSED 时的调用栈)。 */
|
|
347
|
+
const states = [];
|
|
348
|
+
const tryStart = () => {
|
|
349
|
+
if (runtime)
|
|
350
|
+
return;
|
|
351
|
+
if (hasServices()) {
|
|
352
|
+
if (decided) {
|
|
353
|
+
// 服务热补丁进同一个对象:runtime 已经持有它的引用,不需要重启。
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
decided = true;
|
|
357
|
+
start(createServicesKernel(collected, { log, takeOverQuestions: config.takeOverQuestions, clock }));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
// 没有 services 载体就不启动,也不再退回 apiProxy/typert:
|
|
361
|
+
// 桌面态从未注册 apiProxy 服务,而它的调用信封在旧实现里整个是错的、无法验证;
|
|
362
|
+
// typert 只有 invoke(),既不能列会话也不能发指令,接上去只是一台"永远空列表"的机器。
|
|
363
|
+
// (取证 docs/legacy-spec/host-plugin-cordis.md §2.4 表与 §3 表第 14-19 行)
|
|
364
|
+
if (typeof ctx.get?.('apiProxy', true) === 'object') {
|
|
365
|
+
log('apiProxy present but unsupported by this build', { hint: '只做诊断记录,不接这条载体' });
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
/** 关键成员先验:收下不合法的对象比不收更糟(旧实现踩过的坑)。 */
|
|
369
|
+
const requiredMember = { sessions: 'list', sessionQuery: 'listSessions', agents: 'get' };
|
|
370
|
+
/** 把服务对象的前几个成员拼成可读指纹;这是取证,绝不许抛。 */
|
|
371
|
+
const keysOf = (value) => {
|
|
372
|
+
try {
|
|
373
|
+
return Object.keys(value)
|
|
374
|
+
.slice(0, 6)
|
|
375
|
+
.join('|');
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
return `keys threw: ${String(error?.message ?? error).slice(0, 60)}`;
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
/** 一个候选来源里取出 `name`:先按"作用域上下文"读,读不到再按"直接就是服务"读。 */
|
|
382
|
+
const pickFrom = (source, name) => {
|
|
383
|
+
if (!source || typeof source !== 'object')
|
|
384
|
+
return undefined;
|
|
385
|
+
const asContext = source;
|
|
386
|
+
if (typeof asContext.get === 'function') {
|
|
387
|
+
try {
|
|
388
|
+
const got = asContext.get(name, true);
|
|
389
|
+
if (got)
|
|
390
|
+
return got;
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
/* 这个候选没有该服务 */
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
// 直接点属性在 cordis 上下文上只在"这个名字已注入本 fiber"时成立,否则 get trap
|
|
398
|
+
// 抛错(不是返回 undefined)——所以这里必须包 try。
|
|
399
|
+
return source[name];
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
/**
|
|
406
|
+
* 收下探到的服务并尝试启动;`via` 只用于把"从哪条路拿到的"记进 probe。
|
|
407
|
+
* 晚到的服务热补丁进同一个对象:runtime 已经持有它的引用,不需要重启。
|
|
408
|
+
*/
|
|
409
|
+
const accept = (name, value, source, via) => {
|
|
410
|
+
if (!value || typeof value !== 'object')
|
|
411
|
+
return false;
|
|
412
|
+
const member = requiredMember[name];
|
|
413
|
+
if (member && typeof value[member] !== 'function') {
|
|
414
|
+
probe[name] = `object without ${member}()`;
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
collected[name] = value;
|
|
418
|
+
const asContext = source;
|
|
419
|
+
if (name === 'sessions' && typeof collected.on !== 'function' && typeof asContext?.on === 'function') {
|
|
420
|
+
collected.on = asContext.on.bind(asContext);
|
|
421
|
+
if (typeof asContext.off === 'function')
|
|
422
|
+
collected.off = asContext.off.bind(asContext);
|
|
423
|
+
}
|
|
424
|
+
probe[name] = `${via}(${keysOf(value)})`;
|
|
425
|
+
tryStart();
|
|
426
|
+
return true;
|
|
427
|
+
};
|
|
428
|
+
for (const name of SERVICE_NAMES) {
|
|
429
|
+
try {
|
|
430
|
+
if (typeof ctx.get !== 'function')
|
|
431
|
+
hasGet = false;
|
|
432
|
+
const found = ctx.get?.(name, true);
|
|
433
|
+
probe[name] = found
|
|
434
|
+
? typeof found === 'object'
|
|
435
|
+
? `object(${Object.keys(found)
|
|
436
|
+
.slice(0, 6)
|
|
437
|
+
.join('|')})`
|
|
438
|
+
: typeof found
|
|
439
|
+
: 'none';
|
|
440
|
+
if (found)
|
|
441
|
+
collected[name] = found;
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
// 这一代宿主没这个服务
|
|
445
|
+
probe[name] = `get threw: ${String(error?.message ?? error).slice(0, 60)}`;
|
|
446
|
+
}
|
|
447
|
+
try {
|
|
448
|
+
// 关键:`ctx.inject(names, cb)` 的回调参数**不是服务实例,而是已作用域化的上下文**,
|
|
449
|
+
// 要在回调里 `c.get(name, true)` 才拿得到东西(第一版按"回调参数=服务"写,
|
|
450
|
+
// 结果六个服务全 none、carrier=none —— 这个形状错了比抛错更难查)。
|
|
451
|
+
const child = ctx.inject?.([name], ((...cbArgs) => {
|
|
452
|
+
injectFired.push(`${name}(args=${cbArgs.length})`);
|
|
453
|
+
probe.injectFiredLive = injectFired.join('|');
|
|
454
|
+
// 回调整段包 try:这条子 fiber 的 apply 抛出会让 cordis 把它连同我们其它
|
|
455
|
+
// 五条探测一起 dispose 掉,代价是"服务明明在、carrier 却起不来"。
|
|
456
|
+
try {
|
|
457
|
+
// 取证:不同代际这里拿到的可能是"作用域上下文",也可能直接就是服务。逐个试。
|
|
458
|
+
let value;
|
|
459
|
+
let from;
|
|
460
|
+
for (const candidate of [cbArgs[0], ...cbArgs, ctx]) {
|
|
461
|
+
value = pickFrom(candidate, name);
|
|
462
|
+
if (value) {
|
|
463
|
+
from = candidate;
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (accept(name, value, from, 'via inject'))
|
|
468
|
+
return;
|
|
469
|
+
if (!value)
|
|
470
|
+
probe[name] = `${probe[name] ?? 'none'} (inject fired, empty)`;
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
probe[`injectThrew:${name}`] = String(error?.message ?? error).slice(0, 160);
|
|
474
|
+
}
|
|
475
|
+
}));
|
|
476
|
+
if (child !== undefined)
|
|
477
|
+
injectChildren.push([name, child]);
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
hasInject = false;
|
|
481
|
+
probe[name] = `${probe[name] ?? 'none'}; inject threw: ${String(error?.message ?? error).slice(0, 60)}`;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
probe.hasGet = String(hasGet);
|
|
485
|
+
probe.hasInject = String(hasInject);
|
|
486
|
+
// 一次性取证:这一代宿主到底给了 ctx 什么。猜"名字对不对"之前先看形状。
|
|
487
|
+
probe.ctxKeys = Object.keys(ctx).slice(0, 40).join('|');
|
|
488
|
+
probe.ctxProtoKeys = Object.getOwnPropertyNames(Object.getPrototypeOf(ctx) ?? {})
|
|
489
|
+
.slice(0, 40)
|
|
490
|
+
.join('|');
|
|
491
|
+
for (const candidate of ['sessions', 'agents']) {
|
|
492
|
+
try {
|
|
493
|
+
const strict = ctx.get?.(candidate);
|
|
494
|
+
probe[`strictGet:${candidate}`] = strict ? 'object' : 'undefined';
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
probe[`strictGet:${candidate}`] = `threw: ${String(error?.message ?? error).slice(0, 70)}`;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
// 有些代际要靠 effect/fiber 才看得到服务;有就登记一条"等一会再看"的复查。
|
|
501
|
+
// 注意 `injectFiredLive`(而不是任何在 apply 同步段里取的快照)才是"回调到底有没有
|
|
502
|
+
// 触发"的证据:inject 回调最快也要一个微任务之后才跑,同步取的那一笔永远是 never。
|
|
503
|
+
probe.hasEffect = String(typeof ctx.effect === 'function');
|
|
504
|
+
probe.hasFiber = String(typeof ctx.fiber === 'function' || ctx.fiber !== undefined);
|
|
505
|
+
// ── 取证(只记录现场,不改变行为)────────────────────────────────
|
|
506
|
+
// 每一次猜错都要付一次"重启 Harness"的代价,所以把定根因需要的东西一次拿全:
|
|
507
|
+
// 这个 cordis root 到底注册了哪些服务、我们的 fiber 挂在哪棵树上、谁调用了 apply。
|
|
508
|
+
const asRecord = (value) => value && typeof value === 'object' ? value : {};
|
|
509
|
+
const liveFiberChain = () => {
|
|
510
|
+
const chain = [];
|
|
511
|
+
const seen = new Set();
|
|
512
|
+
let fiber = asRecord(ctx).fiber;
|
|
513
|
+
while (fiber && !seen.has(fiber) && chain.length < 12) {
|
|
514
|
+
seen.add(fiber);
|
|
515
|
+
const record = asRecord(fiber);
|
|
516
|
+
const entryId = asRecord(record.entry).id;
|
|
517
|
+
// state 数字即 cordis FiberState:0 PENDING / 1 LOADING / 2 ACTIVE / 3 DISPOSED / 4 UNLOADING
|
|
518
|
+
chain.push(`${String(record.name ?? '?')}#${String(record.uid ?? 'null')}@${String(record.state ?? '?')}${entryId ? `[${String(entryId)}]` : ''}`);
|
|
519
|
+
fiber = asRecord(asRecord(record.parent).fiber === record.fiber ? null : asRecord(record.parent).fiber);
|
|
520
|
+
}
|
|
521
|
+
return chain;
|
|
522
|
+
};
|
|
523
|
+
const liveStore = () => {
|
|
524
|
+
const store = asRecord(asRecord(ctx).reflect).store;
|
|
525
|
+
const names = [];
|
|
526
|
+
for (const key of Object.getOwnPropertySymbols(asRecord(store))) {
|
|
527
|
+
const impl = asRecord(asRecord(store)[key]);
|
|
528
|
+
names.push(`${String(impl.name)}@${String(asRecord(impl.fiber).state ?? '?')}`);
|
|
529
|
+
}
|
|
530
|
+
return names;
|
|
531
|
+
};
|
|
532
|
+
const liveIsolate = () => {
|
|
533
|
+
const map = asRecord(ctx)[Symbol.for('cordis.isolate')];
|
|
534
|
+
// isolate map 是 Object.create(null) 的**原型链**,每条 loader entry 自己那一层
|
|
535
|
+
// 往往是空的——只数 own key 会永远得到空串,必须用 for...in 连继承的一起看。
|
|
536
|
+
const names = [];
|
|
537
|
+
for (const key in map)
|
|
538
|
+
names.push(`${key}=${String(asRecord(map)[key])}`);
|
|
539
|
+
return names;
|
|
540
|
+
};
|
|
541
|
+
const collectForensics = (prefix) => {
|
|
542
|
+
try {
|
|
543
|
+
probe[`${prefix}store`] = liveStore().join('|').slice(0, 900);
|
|
544
|
+
probe[`${prefix}isolate`] = liveIsolate().join(',').slice(0, 900);
|
|
545
|
+
probe[`${prefix}props`] = Object.getOwnPropertyNames(asRecord(asRecord(asRecord(ctx).reflect).props))
|
|
546
|
+
.slice(0, 160)
|
|
547
|
+
.join('|');
|
|
548
|
+
probe[`${prefix}fibers`] = liveFiberChain().join(' <- ').slice(0, 600);
|
|
549
|
+
for (const name of SERVICE_NAMES) {
|
|
550
|
+
const got = ctx.get?.(name, true);
|
|
551
|
+
probe[`${prefix}get:${name}`] = got
|
|
552
|
+
? `object(${Object.keys(got)
|
|
553
|
+
.slice(0, 5)
|
|
554
|
+
.join('|')})`
|
|
555
|
+
: 'undefined';
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
catch (error) {
|
|
559
|
+
probe[`${prefix}forensicsThrew`] = String(error?.message ?? error).slice(0, 120);
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
collectForensics('t0:');
|
|
563
|
+
probe.injectFiredLive = injectFired.join('|') || 'never';
|
|
564
|
+
// 生命周期取证:`internal/status` 是 emit-mode(cordis events.d.ts:219),订阅安全。
|
|
565
|
+
// 只记自己这条 fiber;进入 UNLOADING/DISPOSED 时抓一次栈,看清是谁把我们拆掉的。
|
|
566
|
+
APPLY_COUNT.n += 1;
|
|
567
|
+
probe.applyCount = `${String(APPLY_COUNT.n)}@${clock.now()}`;
|
|
568
|
+
try {
|
|
569
|
+
ctx.on?.('internal/status', ((fiber, oldState) => {
|
|
570
|
+
if (asRecord(fiber) !== asRecord(ctx).fiber)
|
|
571
|
+
return;
|
|
572
|
+
const record = asRecord(fiber);
|
|
573
|
+
const state = Number(record.state ?? -1);
|
|
574
|
+
const entry = asRecord(record.entry);
|
|
575
|
+
const error = record._error;
|
|
576
|
+
const tail = `${error
|
|
577
|
+
? ` err=${String(error?.name ?? '')}:${String(error?.message ?? error).slice(0, 200)} @${String(error?.stack ?? '')
|
|
578
|
+
.split('\n')
|
|
579
|
+
.slice(1, 4)
|
|
580
|
+
.map((line) => line.trim().replace(/^at\s+/, ''))
|
|
581
|
+
.join(' <- ')}`
|
|
582
|
+
: ''}${state >= 4
|
|
583
|
+
? ` stack=${String(new Error('drc-dispose').stack ?? '')
|
|
584
|
+
.split('\n')
|
|
585
|
+
.slice(1, 7)
|
|
586
|
+
.map((line) => line.trim().replace(/^at\s+/, ''))
|
|
587
|
+
.join(' <- ')
|
|
588
|
+
.slice(0, 400)}`
|
|
589
|
+
: ''}`;
|
|
590
|
+
states.push(`${clock.now()}:${String(oldState)}->${String(state)} entry=${String(entry.id ?? '?')}/disabled=${String(entry.disabled ?? '?')}${tail}`);
|
|
591
|
+
probe.fiberStates = states.join(' | ').slice(0, 1800);
|
|
592
|
+
}));
|
|
593
|
+
}
|
|
594
|
+
catch (error) {
|
|
595
|
+
probe.statusWatch = `threw: ${String(error?.message ?? error).slice(0, 80)}`;
|
|
596
|
+
}
|
|
597
|
+
probe.processShape = [
|
|
598
|
+
String(process.type ?? ''),
|
|
599
|
+
String(process.versions.electron ?? 'no-electron'),
|
|
600
|
+
String(process.argv[2] ?? '')
|
|
601
|
+
.split('/')
|
|
602
|
+
.pop() ?? '',
|
|
603
|
+
String(process.argv[3] ?? '')
|
|
604
|
+
.split('/')
|
|
605
|
+
.pop() ?? '',
|
|
606
|
+
].join('|');
|
|
607
|
+
// 谁调用 apply:栈里能看出是 cordis-plugin-loader 还是别的包装层。
|
|
608
|
+
probe.applyStack = String(new Error('drc-probe').stack ?? '')
|
|
609
|
+
.split('\n')
|
|
610
|
+
.slice(1, 9)
|
|
611
|
+
.map((line) => line.trim().replace(/^at\s+/, ''))
|
|
612
|
+
.join(' <- ')
|
|
613
|
+
.slice(0, 900);
|
|
614
|
+
try {
|
|
615
|
+
const loader = ctx.get?.('loader', true);
|
|
616
|
+
if (typeof loader?.entries === 'function') {
|
|
617
|
+
const rows = [];
|
|
618
|
+
for (const entry of loader.entries()) {
|
|
619
|
+
const options = asRecord(asRecord(entry).options);
|
|
620
|
+
rows.push(`${String(options.id ?? '?')}=${String(options.name ?? '?')}`);
|
|
621
|
+
if (rows.length >= 300)
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
probe.loaderRows = rows.join(',').slice(0, 2600);
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
probe.loaderRows = 'no-loader-service';
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
catch (error) {
|
|
631
|
+
probe.loaderRows = `threw: ${String(error?.message ?? error).slice(0, 80)}`;
|
|
632
|
+
}
|
|
633
|
+
// 服务可能晚到:8s / 25s / 60s 各复查一次,把"根本没提供"和"提供得晚"分开。
|
|
634
|
+
for (const delayMs of [8_000, 25_000, 60_000]) {
|
|
635
|
+
try {
|
|
636
|
+
oneShot.schedule(() => {
|
|
637
|
+
probe.injectFiredLive = injectFired.join('|') || 'never';
|
|
638
|
+
probe.injectFired = probe.injectFiredLive;
|
|
639
|
+
collectForensics(`t${delayMs}:`);
|
|
640
|
+
probe.injectChildren = injectChildren
|
|
641
|
+
.map(([childName, child]) => {
|
|
642
|
+
const record = asRecord(child);
|
|
643
|
+
const store = asRecord(record.store);
|
|
644
|
+
return `${childName}@${String(record.state ?? '?')}/uid${String(record.uid ?? 'null')}${store ? `[${Object.keys(store).join('|')}]` : ''}`;
|
|
645
|
+
})
|
|
646
|
+
.join(' ');
|
|
647
|
+
probe.fiberStates = states.join(' | ').slice(0, 1800);
|
|
648
|
+
// 兜底:inject 子 fiber 若因任何原因没被唤醒,直接查 store——`ctx.get` 不要求
|
|
649
|
+
// inject,真机取证显示它在同一个 ctx 上一直是拿得到的。
|
|
650
|
+
for (const name of SERVICE_NAMES) {
|
|
651
|
+
if (collected[name]) {
|
|
652
|
+
// 已经收下了,但"从哪条路收的"那一笔可能没写进去(accept 里的取证写入是尽力而为)。
|
|
653
|
+
if (!String(probe[name] ?? '').startsWith('via'))
|
|
654
|
+
probe[name] = `via held(${keysOf(collected[name])})`;
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
try {
|
|
658
|
+
accept(name, ctx.get?.(name, true), ctx, 'via get@poll');
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
/* 还是没有这个服务 */
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
tryStart();
|
|
665
|
+
status.write({ ...state(), carrier: startedCarrier || 'none', probe });
|
|
666
|
+
}, delayMs);
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
/* 时钟不可用就少一次复查,不影响主流程 */
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
try {
|
|
673
|
+
const proxy = ctx.get?.('apiProxy', true);
|
|
674
|
+
if (proxy)
|
|
675
|
+
collected.apiProxy = proxy;
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
/* desktop 态通常没注册 apiProxy */
|
|
679
|
+
}
|
|
680
|
+
try {
|
|
681
|
+
ctx.inject?.(['apiProxy'], ((scoped) => {
|
|
682
|
+
const value = scoped?.get?.('apiProxy', true);
|
|
683
|
+
if (!value)
|
|
684
|
+
return;
|
|
685
|
+
collected.apiProxy = value;
|
|
686
|
+
if (!decided) {
|
|
687
|
+
// 先不抢跑:等宽限期,让 services 有机会先到。这发也 unref——它只是"再看一眼",
|
|
688
|
+
// 进程若已无别的事可做就不该为它多活一个宽限期。
|
|
689
|
+
oneShot.schedule(tryStart, config.carrierGraceMs);
|
|
690
|
+
}
|
|
691
|
+
tryStart();
|
|
692
|
+
}));
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
/* 同上 */
|
|
696
|
+
}
|
|
697
|
+
tryStart();
|
|
698
|
+
if (!runtime && config.mockBridge) {
|
|
699
|
+
decided = true;
|
|
700
|
+
// mock 载体只为开发与冒烟存在:它的会话 id 一律 `ses_mock` 前缀,
|
|
701
|
+
// 真链路测试里有专门一条断言"看到 ses_mock 就判失败"。
|
|
702
|
+
import('./platform/mock-kernel.js')
|
|
703
|
+
.then(({ createMockKernel }) => start(createMockKernel({ log, clock })))
|
|
704
|
+
.catch(() => { });
|
|
705
|
+
}
|
|
706
|
+
if (!runtime) {
|
|
707
|
+
status.write({ ...state(), carrier: 'none', reason: 'no-carrier', probe });
|
|
708
|
+
log('no kernel carrier available', { hint: '需要 sessions/apiProxy 服务,或把 mockBridge 打开做开发' });
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* 这次 `/drc pair` 是在哪条会话里敲的 → 那条会话的工作区目录。
|
|
712
|
+
*
|
|
713
|
+
* `agent` 是宿主交给 handler 的**接收 agent**(`@deepseek-ai/dsh-commands` 的
|
|
714
|
+
* `handler: ({ agent, rawInput })`),会话 id 在 `agent.session.id`——与
|
|
715
|
+
* `platform/carrier-services.ts` 读审批/提问事件里那个 agent 是同一个形状。
|
|
716
|
+
* 拿不到 agent、会话、工作区**都不算失败**:那时二维码退回 `~/.dsh/`,卡片上印的就是
|
|
717
|
+
* 退回去的那个路径。这里绝不能"猜一个应该在的工作区"——卡片路径与真落盘路径必须同一个。
|
|
718
|
+
*/
|
|
719
|
+
const workspaceOfInvocation = (invocation) => {
|
|
720
|
+
const agent = invocation?.agent;
|
|
721
|
+
const sessionId = typeof agent?.session?.id === 'string' ? agent.session.id : '';
|
|
722
|
+
if (sessionId === '')
|
|
723
|
+
return undefined;
|
|
724
|
+
try {
|
|
725
|
+
// runtime 还没起来(carrier=none)时 kernel 是 undefined:退回家目录那条路。
|
|
726
|
+
return kernel?.sessionWorkspace?.(sessionId);
|
|
727
|
+
}
|
|
728
|
+
catch {
|
|
729
|
+
return undefined;
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
// ── /drc 命令 ──────────────────────────────────────────────────────
|
|
733
|
+
// 拿 commands 的方式和拿内核服务完全一样:`ctx.commands` 这种"直接点属性"的读法在
|
|
734
|
+
// 真 cordis 上下文上是**抛错**的(属性名不在本 fiber 的 inject 集合里,见 reflect.ts
|
|
735
|
+
// 的 get trap),所以先试 `ctx.get('commands', true)`,再用 `ctx.inject` 等晚到。
|
|
736
|
+
//
|
|
737
|
+
// **返回值只能是 `{kind:'success', text?}` 或 `{kind:'error', text}`。**
|
|
738
|
+
// 宿主在注册边界上做校验(`@deepseek-ai/dsh-commands` 的 `normalizeResult`),
|
|
739
|
+
// 别的 `kind` 一律 `throw TypeError("...unknown result kind...")`——用户看到的是
|
|
740
|
+
// 一条命令整个失败,而不是"格式不标准"。旧实现把这条契约写在注释里,重写时漏掉了,
|
|
741
|
+
// 于是 `/drc pair` 报 `unknown result kind "text"`。
|
|
742
|
+
// 现在**两道锁**:类型上 `CommandResult` 让写错 kind 编译就红(`pnpm -r typecheck`),
|
|
743
|
+
// 运行时有 `tests/command-result.test.ts` 走一遍 apply() 注册出来的真 handler。
|
|
744
|
+
const commandDefinition = {
|
|
745
|
+
name: 'drc',
|
|
746
|
+
description: 'DSH 远程控制:发布配对二维码、查看中继与内核接合状态',
|
|
747
|
+
input: { hint: 'pair | status | unpair' },
|
|
748
|
+
handler: async (invocation) => {
|
|
749
|
+
const argument = String(invocation.rawInput ?? '')
|
|
750
|
+
.trim()
|
|
751
|
+
.toLowerCase();
|
|
752
|
+
if (argument.startsWith('pair')) {
|
|
753
|
+
const pairing = createPairing();
|
|
754
|
+
if (!pairing)
|
|
755
|
+
return { kind: 'success', text: PAIR_UNAVAILABLE_TEXT };
|
|
756
|
+
// **默认走图片**:DSH 命令卡按 `line-height:1.6` 渲染等宽输出,行间留白会把半块
|
|
757
|
+
// 二维码横切成条——实测 zxing 在 ≥1.15 行距就解不出来,而宿主固定 1.6,也就是
|
|
758
|
+
// 文本码在这个(唯一)宿主上扫不出来。图片是自包含位图矩阵,跟行高/字体/配色无关。
|
|
759
|
+
// 只有显式关掉 qrImage 时才渲染文本码当退路(另有可粘贴的 URI 兜底)。
|
|
760
|
+
const workspaceDir = workspaceOfInvocation(invocation);
|
|
761
|
+
const imageFile = config.qrImage ? pairingImagePath(config.statusFile, workspaceDir) : '';
|
|
762
|
+
if (imageFile)
|
|
763
|
+
writePairingImage(imageFile, pairing.qr, config, log);
|
|
764
|
+
const terminal = config.qrImage
|
|
765
|
+
? undefined
|
|
766
|
+
: await renderTerminalQr(pairing.qr, { style: config.qrStyle, ansi: config.qrAnsi });
|
|
767
|
+
return {
|
|
768
|
+
kind: 'success',
|
|
769
|
+
text: pairingPairText({
|
|
770
|
+
token: pairing.token,
|
|
771
|
+
qr: pairing.qr,
|
|
772
|
+
expiresAt: pairing.expiresAt,
|
|
773
|
+
now: clock.now(),
|
|
774
|
+
qrImage: config.qrImage,
|
|
775
|
+
imageFile,
|
|
776
|
+
terminalQr: terminal,
|
|
777
|
+
}),
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
if (argument.startsWith('unpair')) {
|
|
781
|
+
for (const id of relay?.conversationIds() ?? [])
|
|
782
|
+
relay?.voidConversation(id);
|
|
783
|
+
return { kind: 'success', text: '已解除所有配对;手机端会显示"主机已断开,请重新配对"。' };
|
|
784
|
+
}
|
|
785
|
+
return { kind: 'success', text: JSON.stringify(state(), null, 2) };
|
|
786
|
+
},
|
|
787
|
+
};
|
|
788
|
+
let commandsTaken = false;
|
|
789
|
+
const takeCommands = (source) => {
|
|
790
|
+
if (commandsTaken || !source || typeof source !== 'object')
|
|
791
|
+
return;
|
|
792
|
+
const holder = source;
|
|
793
|
+
if (typeof holder.register !== 'function')
|
|
794
|
+
return;
|
|
795
|
+
holder.register(commandDefinition);
|
|
796
|
+
commandsTaken = true;
|
|
797
|
+
probe.commands = 'registered';
|
|
798
|
+
};
|
|
799
|
+
try {
|
|
800
|
+
// 假的/非 cordis 上下文(单元测试)直接把 commands 挂在 ctx 上;真 cordis 上这一读
|
|
801
|
+
// 会抛,所以整段包 try——它不是主路径,只是兼容测试与更老一代的宿主形状。
|
|
802
|
+
takeCommands(ctx.commands);
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
/* cordis 上下文上没有注入过的属性名读不得:走下面两条路 */
|
|
806
|
+
}
|
|
807
|
+
try {
|
|
808
|
+
takeCommands(ctx.get?.('commands', true));
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
/* 这一代宿主没这个服务 */
|
|
812
|
+
}
|
|
813
|
+
try {
|
|
814
|
+
const child = ctx.inject?.(['commands'], ((...cbArgs) => {
|
|
815
|
+
for (const candidate of [cbArgs[0], ...cbArgs, ctx]) {
|
|
816
|
+
takeCommands(pickFrom(candidate, 'commands'));
|
|
817
|
+
if (commandsTaken)
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
}));
|
|
821
|
+
if (child !== undefined)
|
|
822
|
+
injectChildren.push(['commands', child]);
|
|
823
|
+
}
|
|
824
|
+
catch {
|
|
825
|
+
/* 没有 commands 服务的代际:静默跳过,配对仍可由 pairOnStartSec 自动发布 */
|
|
826
|
+
}
|
|
827
|
+
return {
|
|
828
|
+
config,
|
|
829
|
+
state,
|
|
830
|
+
createPairing,
|
|
831
|
+
sessionWorkspace(sessionId) {
|
|
832
|
+
try {
|
|
833
|
+
return kernel?.sessionWorkspace?.(sessionId);
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
return undefined;
|
|
837
|
+
}
|
|
838
|
+
},
|
|
839
|
+
stop() {
|
|
840
|
+
// 复查定时器要先撤:停机之后它们再去写 status.json,写的是已经过期的取证。
|
|
841
|
+
oneShot.cancelAll();
|
|
842
|
+
status.stop();
|
|
843
|
+
runtime?.stop();
|
|
844
|
+
relay?.stop();
|
|
845
|
+
sleep.stop();
|
|
846
|
+
},
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* 图片版是**默认路径**(`qrImage` 默认 true)。
|
|
851
|
+
*
|
|
852
|
+
* 为什么翻转:DSH 命令卡把等宽输出按 `line-height:1.6` 渲染,行间留白把半块二维码
|
|
853
|
+
* 横切成条;用真机输出做的受控实验里,行距 1.0 时 zxing 解得出来、≥1.15 就扫不出来。
|
|
854
|
+
* 文本码在这个宿主上不可用,所以主产物只能是图片。
|
|
855
|
+
*
|
|
856
|
+
* 落到磁盘的仍然是**旁路**:命令 API 只收纯文本,我们写 PNG + 把**路径**写进卡片,
|
|
857
|
+
* 但绝不替用户打开查看器——`qrOpen` 默认关(原话「不要用打开一个图片的方式」)。
|
|
858
|
+
*
|
|
859
|
+
* `file` 由调用方按会话工作区解析好(`pairingImagePath`)后传进来,本函数只管写。
|
|
860
|
+
*/
|
|
861
|
+
function writePairingImage(file, qrText, config, log) {
|
|
862
|
+
if (!config.qrImage || !file)
|
|
863
|
+
return;
|
|
864
|
+
qrPng(qrText)
|
|
865
|
+
.then((buffer) => {
|
|
866
|
+
if (!writePrivateFile(file, buffer)) {
|
|
867
|
+
log('qr png write failed', { file });
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
if (!config.qrOpen)
|
|
871
|
+
return;
|
|
872
|
+
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
873
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', file] : [file];
|
|
874
|
+
execFile(opener, args, () => {
|
|
875
|
+
/* 打不开就算了:卡片里仍有图片路径与可粘贴的 URI */
|
|
876
|
+
});
|
|
877
|
+
})
|
|
878
|
+
.catch((error) => log('qr png render failed', { message: String(error?.message ?? error) }));
|
|
879
|
+
}
|
|
880
|
+
export { DEFAULT_CONFIG };
|
|
881
|
+
export { readConfig, validateConfig } from './shell/config.js';
|
|
882
|
+
//# sourceMappingURL=index.js.map
|