@dmh2002/dsh-connect-qoder 0.0.0-stage → 0.4.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.
@@ -0,0 +1,1985 @@
1
+ import { createCipheriv, createHash, publicEncrypt, randomBytes, randomUUID } from "node:crypto";
2
+ import { execFile, execFileSync } from "node:child_process";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { promisify } from "node:util";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { readFile, rm, writeFile } from "node:fs/promises";
8
+ import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
9
+ import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
10
+ //#region src/cosy.ts
11
+ /**
12
+ * Qoder cosy 协议:加密与签名原语(TypeScript 移植)。
13
+ *
14
+ * 协议来源(社区逆向 + 官方产物核对):
15
+ * - fengyinxia/qoder2api(MIT):qoder_auth.py —— PAT→jobToken 交换、cosy
16
+ * 签名、自定义 base64、RSA/AES 会话构建,实测可用(CN 网关)。
17
+ * - x_Zhengyuuuui/qoder2api(Go 版):internal/cosy/* —— 同一协议的 Go
18
+ * 实现,含设备指纹派生(fingerprint.go),并有 CN / Global 双区域端点。
19
+ * - @qoder-ai/qoder-agent-sdk 官方 .d.ts:模型字段、用量字段的官方命名。
20
+ *
21
+ * 本模块只做「无状态纯函数 + 加密原语」,不触网;网络与流程在 upstream.ts。
22
+ *
23
+ * @module dsh-connect-qoder/cosy
24
+ */
25
+ /** cosy 自定义 base64 字母表(与 qoder2api 逐字节一致)。 */
26
+ const CUSTOM_ALPHABET = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!";
27
+ const STD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
28
+ const s2c = /* @__PURE__ */ new Map();
29
+ const c2s = /* @__PURE__ */ new Map();
30
+ for (let i = 0; i < 64; i += 1) {
31
+ s2c.set(STD_ALPHABET[i], CUSTOM_ALPHABET[i]);
32
+ c2s.set(CUSTOM_ALPHABET[i], STD_ALPHABET[i]);
33
+ }
34
+ s2c.set("=", "$");
35
+ c2s.set("$", "=");
36
+ /**
37
+ * Qoder 自定义 base64 编码:先标准 base64,再按 `n/3` 三块重排,
38
+ * 最后把每个字符映射到自定义字母表(含自定义 pad `$`)。
39
+ */
40
+ function cosyEncode(plaintext) {
41
+ const std = Buffer.from(plaintext).toString("base64");
42
+ const n = std.length;
43
+ const a = Math.floor(n / 3);
44
+ const rearranged = std.slice(n - a) + std.slice(a, n - a) + std.slice(0, a);
45
+ let out = "";
46
+ for (const ch of rearranged) {
47
+ const mapped = s2c.get(ch);
48
+ if (mapped === void 0) throw new Error(`cosy encode: char out of alphabet: ${ch}`);
49
+ out += mapped;
50
+ }
51
+ return out;
52
+ }
53
+ /** RFC1123 格式的当前 UTC 时间,用于旧式签名头。 */
54
+ function currentRfc1123Date() {
55
+ return (/* @__PURE__ */ new Date()).toUTCString();
56
+ }
57
+ function md5Hex(input) {
58
+ return createHash("md5").update(input, "utf8").digest("hex");
59
+ }
60
+ function sha512Base64Url(input) {
61
+ return createHash("sha512").update(input, "utf8").digest("base64url");
62
+ }
63
+ /** cosy 签名常量。 */
64
+ const COSY_APPCODE = "cosy";
65
+ const COSY_SECRET_B64 = "d2FyLCB3YXIgbmV2ZXIgY2hhbmdlcw==";
66
+ /** 旧式请求签名:md5("cosy&<secret>&<rfc1123date>")。 */
67
+ function signLegacy(date) {
68
+ return md5Hex(`${COSY_APPCODE}&${COSY_SECRET_B64}&${date}`);
69
+ }
70
+ /**
71
+ * Qoder 网关 RSA 公钥(客户端用它加密会话临时密钥)。
72
+ * 与 qoder2api / Go 克隆一致。
73
+ */
74
+ const SERVER_PUBKEY_PEM = `-----BEGIN PUBLIC KEY-----
75
+ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
76
+ 4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
77
+ 6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
78
+ XcW+ML9FoCI6AOvOzwIDAQAB
79
+ -----END PUBLIC KEY-----`;
80
+ /** cosy 版本号(客户端身份的一部分,与 qoder2api 实测一致)。 */
81
+ const COSY_VERSION = "0.1.43";
82
+ /** AES-CBC + PKCS7 填充。 */
83
+ function aesCbcEncrypt(plain, key) {
84
+ const iv = key;
85
+ const blockSize = 16;
86
+ const padLen = blockSize - plain.length % blockSize;
87
+ const padded = new Uint8Array(plain.length + padLen);
88
+ padded.set(plain);
89
+ padded.fill(padLen, plain.length);
90
+ const cipher = createCipheriv("aes-128-cbc", key, iv);
91
+ const out = Buffer.concat([cipher.update(padded), cipher.final()]);
92
+ return new Uint8Array(out);
93
+ }
94
+ /** RSA PKCS1v1.5 加密(用网关公钥加密会话临时密钥)。 */
95
+ function rsaEncrypt(plain) {
96
+ const { publicKey } = requirePublicKey();
97
+ const out = publicEncrypt({
98
+ key: publicKey,
99
+ padding: 1
100
+ }, Buffer.from(plain));
101
+ return new Uint8Array(out);
102
+ }
103
+ let cachedPublicKey;
104
+ function requirePublicKey() {
105
+ if (cachedPublicKey === void 0) cachedPublicKey = { publicKey: SERVER_PUBKEY_PEM };
106
+ return cachedPublicKey;
107
+ }
108
+ /**
109
+ * 新会话:生成临时密钥 → RSA 加密得 cosyKey → AES 加密身份载荷得 info。
110
+ * `tempKey` 用 16 字节随机数的 hex 前 16 字符(与 qoder2api 一致)。
111
+ */
112
+ function newCosySession(identity, machineId, machineToken, machineType) {
113
+ const tempKey = new Uint8Array(Buffer.from(randomBytes(16).toString("hex").slice(0, 16), "utf8"));
114
+ const cosyKeyBytes = rsaEncrypt(tempKey);
115
+ const cosyKey = Buffer.from(cosyKeyBytes).toString("base64");
116
+ const payload = JSON.stringify(identityPayload(identity));
117
+ return {
118
+ tempKey,
119
+ cosyKey,
120
+ info: Buffer.from(aesCbcEncrypt(new Uint8Array(Buffer.from(payload, "utf8")), tempKey)).toString("base64"),
121
+ identity,
122
+ machineId,
123
+ machineToken,
124
+ machineType
125
+ };
126
+ }
127
+ function identityPayload(identity) {
128
+ return {
129
+ name: identity.name,
130
+ aid: identity.aid,
131
+ uid: identity.uid,
132
+ yx_uid: identity.yx_uid,
133
+ organization_id: identity.organization_id,
134
+ organization_name: identity.organization_name,
135
+ user_type: identity.user_type,
136
+ security_oauth_token: identity.security_oauth_token,
137
+ refresh_token: identity.refresh_token
138
+ };
139
+ }
140
+ /**
141
+ * Bearer 载荷:{cosyVersion, ideVersion, info, requestId, version} 的
142
+ * 标准 base64(键序按字典序,与 qoder2api 一致)。
143
+ */
144
+ function buildPayloadB64(info) {
145
+ const payload = {
146
+ cosyVersion: COSY_VERSION,
147
+ ideVersion: "",
148
+ info,
149
+ requestId: randomUUID(),
150
+ version: "v1"
151
+ };
152
+ const sorted = Object.fromEntries(Object.entries(payload).sort(([a], [b]) => a < b ? -1 : 1));
153
+ return Buffer.from(JSON.stringify(sorted)).toString("base64");
154
+ }
155
+ /**
156
+ * 请求签名:md5(payloadB64 \n cosyKey \n date \n body \n pathWithoutAlgo)。
157
+ */
158
+ function signRequest(payloadB64, cosyKey, cosyDate, body, pathSig) {
159
+ return md5Hex(`${payloadB64}\n${cosyKey}\n${cosyDate}\n${body}\n${pathSig}`);
160
+ }
161
+ /** 组装 Authorization 头:`Bearer COSY.<payloadB64>.<sig>`。 */
162
+ function composeBearer(payloadB64, sig) {
163
+ return `Bearer COSY.${payloadB64}.${sig}`;
164
+ }
165
+ /** 从完整 URL 派生签名路径:去掉 `/algo` 前缀(不含查询串)。 */
166
+ function pathSigFrom(fullUrl) {
167
+ let path;
168
+ try {
169
+ path = new URL(fullUrl).pathname;
170
+ } catch {
171
+ path = fullUrl;
172
+ }
173
+ return path.startsWith("/algo") ? path.slice(5) : path;
174
+ }
175
+ function saltedSeed(seed, installSalt) {
176
+ return installSalt ? `${seed}|salt:${installSalt}` : seed;
177
+ }
178
+ function deriveId(seed, salt, installSalt) {
179
+ return md5Hex(`${salt}:${saltedSeed(seed, installSalt)}`);
180
+ }
181
+ /**
182
+ * 派生设备指纹。seed 优先取账号 UID;未知时回退 `cred:<credential>`,
183
+ * 保证同一凭证跨次调用派生结果一致。
184
+ */
185
+ function deriveFingerprint(uid, credential, installSalt) {
186
+ const seed = uid !== "" ? uid : `cred:${credential}`;
187
+ return {
188
+ machineId: deriveId(seed, "machine", installSalt),
189
+ machineType: deriveId(seed, "machinetype", installSalt).slice(0, 18),
190
+ machineToken: sha512Base64Url(`machinetoken:${saltedSeed(seed, installSalt)}`).slice(0, 43)
191
+ };
192
+ }
193
+ //#endregion
194
+ //#region src/messages.ts
195
+ /** 构建一份全新的聊天请求体(每个请求必须换 ID)。 */
196
+ function createChatBodyTemplate() {
197
+ return {
198
+ request_id: "",
199
+ request_set_id: "",
200
+ chat_record_id: "",
201
+ stream: true,
202
+ chat_task: "FREE_INPUT",
203
+ chat_context: {
204
+ extra: {
205
+ modelConfig: {
206
+ is_reasoning: false,
207
+ key: "lite"
208
+ },
209
+ originalContent: {
210
+ type: "text",
211
+ text: ""
212
+ }
213
+ },
214
+ text: {
215
+ type: "text",
216
+ text: ""
217
+ }
218
+ },
219
+ session_id: "",
220
+ source: 1,
221
+ version: "3",
222
+ aliyun_user_type: "personal_standard",
223
+ session_type: "qodercli",
224
+ agent_id: "agent_common",
225
+ task_id: "common",
226
+ model_config: {
227
+ key: "lite",
228
+ is_vl: false,
229
+ is_reasoning: false,
230
+ source: "system"
231
+ },
232
+ messages: [],
233
+ business: {
234
+ id: "",
235
+ name: "hi",
236
+ begin_at: 0
237
+ }
238
+ };
239
+ }
240
+ function blankResponseMeta() {
241
+ return {
242
+ id: "",
243
+ usage: {
244
+ prompt_tokens: 0,
245
+ completion_tokens: 0,
246
+ total_tokens: 0,
247
+ completion_tokens_details: { reasoning_tokens: 0 },
248
+ prompt_tokens_details: { cached_tokens: 0 }
249
+ }
250
+ };
251
+ }
252
+ function structuredMessage(role, text) {
253
+ return {
254
+ role,
255
+ content: text,
256
+ response_meta: blankResponseMeta(),
257
+ reasoning_content_signature: ""
258
+ };
259
+ }
260
+ function userMessage(text, images = []) {
261
+ const parts = [];
262
+ for (const url of images) parts.push({
263
+ type: "image_url",
264
+ image_url: { url }
265
+ });
266
+ if (text.trim()) parts.push({
267
+ type: "text",
268
+ text
269
+ });
270
+ if (parts.length === 0) parts.push({
271
+ type: "text",
272
+ text: ""
273
+ });
274
+ return {
275
+ role: "user",
276
+ content: "",
277
+ contents: parts,
278
+ response_meta: blankResponseMeta(),
279
+ reasoning_content_signature: ""
280
+ };
281
+ }
282
+ function toolMessage(message, text) {
283
+ const out = structuredMessage("tool", text);
284
+ if (typeof message.name === "string") out.name = message.name;
285
+ if (typeof message.tool_call_id === "string") out.tool_call_id = message.tool_call_id;
286
+ return out;
287
+ }
288
+ function assistantToolCallMessage(text, toolCalls) {
289
+ const out = structuredMessage("assistant", text);
290
+ out.tool_calls = toolCalls;
291
+ return out;
292
+ }
293
+ /** 从 OpenAI 消息提取纯文本(content 字符串、contents parts、named 等)。 */
294
+ function normalizeMessageText(message) {
295
+ const content = message.content;
296
+ if (typeof content === "string") return content;
297
+ if (Array.isArray(content)) {
298
+ const parts = [];
299
+ for (const part of content) if (typeof part === "string") parts.push(part);
300
+ else if (part && typeof part === "object") {
301
+ const t = part.text;
302
+ if (typeof t === "string") parts.push(t);
303
+ else if (typeof part.value === "string") parts.push(part.value);
304
+ }
305
+ return parts.join("");
306
+ }
307
+ return "";
308
+ }
309
+ /** 提取消息里的图片 URL(user 消息的 image_url parts)。 */
310
+ function extractMessageImages(message) {
311
+ if (message.role !== "user") return [];
312
+ const content = message.content;
313
+ if (!Array.isArray(content)) return [];
314
+ const urls = [];
315
+ for (const part of content) {
316
+ const iu = part.image_url;
317
+ if (iu && typeof iu.url === "string") urls.push(iu.url);
318
+ }
319
+ return urls;
320
+ }
321
+ const TOOL_CALLS_TEXT_RE = /Tool calls:\s*(\[[\s\S]*?\])/;
322
+ /** 尝试从文本解析 "Tool calls: [...]" 形状的 tool_calls(兜底)。 */
323
+ function parseToolCallsText(text) {
324
+ if (!text) return void 0;
325
+ const m = TOOL_CALLS_TEXT_RE.exec(text);
326
+ if (!m?.[1]) return void 0;
327
+ try {
328
+ const parsed = JSON.parse(m[1]);
329
+ if (Array.isArray(parsed) && parsed.every((item) => item && typeof item === "object")) return normalizeToolCalls(parsed);
330
+ } catch {
331
+ return;
332
+ }
333
+ }
334
+ function normalizeToolCalls(raw) {
335
+ return raw.map((tc) => {
336
+ if (tc && typeof tc === "object" && !("function" in tc)) {
337
+ const r = tc;
338
+ if (r.id && r.name && r.input) return {
339
+ id: r.id,
340
+ type: "function",
341
+ function: {
342
+ name: r.name,
343
+ arguments: JSON.stringify(r.input)
344
+ }
345
+ };
346
+ }
347
+ return tc;
348
+ });
349
+ }
350
+ /** 检测 assistant 消息之后是否有已解析的 tool 响应(决定结构化 tool_calls 传输)。 */
351
+ function hasResolvedToolResponse(messages, assistantIndex) {
352
+ const message = messages[assistantIndex];
353
+ if (message === void 0 || message.role !== "assistant") return false;
354
+ const tc = message.tool_calls;
355
+ if (!(Array.isArray(tc) && tc.length > 0) && !parseToolCallsText(normalizeMessageText(message))) return false;
356
+ for (let i = assistantIndex + 1; i < messages.length; i += 1) {
357
+ const next = messages[i];
358
+ if (next?.role === "tool") return true;
359
+ if (next !== void 0 && (next.role === "assistant" || next.role === "user" || next.role === "system")) return false;
360
+ }
361
+ return false;
362
+ }
363
+ function renderToolCalls(toolCalls) {
364
+ return `Tool calls:\n${JSON.stringify(toolCalls)}`;
365
+ }
366
+ function renderToolResult(message, text) {
367
+ const name = typeof message.name === "string" ? message.name : "";
368
+ const toolCallId = typeof message.tool_call_id === "string" ? message.tool_call_id : "";
369
+ let out = "Tool result";
370
+ if (name) out += ` (${name})`;
371
+ if (toolCallId) out += ` [${toolCallId}]`;
372
+ if (text.trim()) out += `:\n${text}`;
373
+ return out;
374
+ }
375
+ /**
376
+ * 把一条 OpenAI 消息转换为 Qoder 消息形状;返回 null 表示该条无法表示(跳过)。
377
+ */
378
+ function convertIncomingMessage(message, toolsEnabled, allowStructuredToolCalls) {
379
+ const role = typeof message.role === "string" ? message.role : "user";
380
+ const text = normalizeMessageText(message);
381
+ let structuredToolCalls;
382
+ if (toolsEnabled && allowStructuredToolCalls) {
383
+ const tc = Array.isArray(message.tool_calls) && message.tool_calls.length > 0 ? message.tool_calls : parseToolCallsText(text);
384
+ if (tc) structuredToolCalls = tc;
385
+ }
386
+ if (role === "assistant" && structuredToolCalls) return assistantToolCallMessage(text, structuredToolCalls);
387
+ if (role === "tool") {
388
+ if (toolsEnabled) return toolMessage(message, text);
389
+ return structuredMessage("user", renderToolResult(message, text));
390
+ }
391
+ let finalText = text;
392
+ if (role === "assistant" && !toolsEnabled && Array.isArray(message.tool_calls) && message.tool_calls.length > 0) finalText = [text, renderToolCalls(message.tool_calls)].filter(Boolean).join("\n\n");
393
+ const images = role === "user" ? extractMessageImages(message) : [];
394
+ if (!finalText.trim() && images.length === 0) return null;
395
+ if (role === "user") return userMessage(images.length > 0 ? finalText.split("\n\n").filter((s) => !s.trim().startsWith("[image] ")).join("\n\n") : finalText, images);
396
+ return structuredMessage(role, finalText);
397
+ }
398
+ /**
399
+ * 构建发往 Qoder 网关的 messages 数组。OpenAI 形状入参 → Qoder 形状。
400
+ */
401
+ function buildQoderMessages(incoming, prompt, toolsEnabled) {
402
+ const rebuilt = [];
403
+ if (incoming.length > 0) for (let i = 0; i < incoming.length; i += 1) {
404
+ const allowStructured = toolsEnabled && hasResolvedToolResponse(incoming, i);
405
+ const converted = convertIncomingMessage(incoming[i], toolsEnabled, allowStructured);
406
+ if (converted !== null) rebuilt.push(converted);
407
+ }
408
+ if (rebuilt.length === 0 && prompt.trim()) rebuilt.push(userMessage(prompt));
409
+ return rebuilt;
410
+ }
411
+ /** 取最后一条 user 消息的纯文本(供 chat_context / business.name 使用)。 */
412
+ function extractLatestUserPrompt(messages) {
413
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
414
+ const m = messages[i];
415
+ if (m?.role === "user") {
416
+ const text = normalizeMessageText(m);
417
+ if (text.trim()) return text;
418
+ }
419
+ }
420
+ return "";
421
+ }
422
+ /** 任意 JSON 深拷贝(模板必须逐请求独立)。 */
423
+ function deepClone(value) {
424
+ return structuredClone(value);
425
+ }
426
+ /** 模型名 → 是否需要图像输入(is_vl)。两个入口都改:model_config 与 chat_context.extra.modelConfig。 */
427
+ function applyModelKey(body, key, userType, isVl) {
428
+ body.model_config.key = key;
429
+ body.model_config.is_vl = isVl;
430
+ body.chat_context.extra.modelConfig.key = key;
431
+ body.aliyun_user_type = userType;
432
+ }
433
+ //#endregion
434
+ //#region src/device-risk.ts
435
+ /**
436
+ * 设备风控身份(国际版签到依赖)。
437
+ *
438
+ * 背景:国际版的每日签到活动**只下发给带设备风控头的请求**
439
+ * (`Cosy-MachineToken` / `Cosy-MachineCode` / `Cosy-MachineType`)。国内版不需要。
440
+ * 这三件套由 Qoder 客户端自带的 UMID 组件(`runtime-info`)在本机生成 —— 它不是
441
+ * 随机数,而是与这台机器绑定的标识,所以**不能**由插件伪造或缓存到别的机器。
442
+ *
443
+ * 来源:社区参考实现 L0NE-6/Qoder-Daily(MIT)的做法是先在 Qoder 客户端资源目录里
444
+ * 找 `umid/runtime-info`,找不到才去官方 qodercli 包里提取。本插件只做前者:本机装了
445
+ * Qoder 就有,没装就如实报「取不到」,不去下载任何东西。
446
+ *
447
+ * 为什么不在插件里缓存:组件本身每次调用都会给出当前有效值(参考实现按 50 分钟
448
+ * 刷新)。这里按 {@link RISK_TTL_MS} 做进程内缓存,避免每次签到都 fork 一个进程。
449
+ *
450
+ * @module dsh-connect-qoder/device-risk
451
+ */
452
+ const run = promisify(execFile);
453
+ /** 组件输出的大小上限:超过即视为异常输出,不解析。 */
454
+ const MAX_OUTPUT_BYTES = 1048576;
455
+ /** 组件调用超时。参考实现用 25s,这里保持一致。 */
456
+ const COMPONENT_TIMEOUT_MS = 25e3;
457
+ /**
458
+ * 每个区域的 UMID 组件搜索路径,按优先级排列。
459
+ *
460
+ * 区域与 App 的对应关系沿用参考实现:国际版优先 `Qoder`,国内版优先 `Qoder CN`。
461
+ * 两个 App 都装时,各自优先自己那个 —— 设备身份与区域是绑定的,用错一侧的组件
462
+ * 会被风控拒绝。
463
+ */
464
+ const UMID_CANDIDATES = {
465
+ cn: ["/Applications/Qoder CN.app/Contents/Resources/umid/runtime-info", "/Applications/Qoder.app/Contents/Resources/umid/runtime-info"],
466
+ global: ["/Applications/Qoder.app/Contents/Resources/umid/runtime-info", "/Applications/Qoder CN.app/Contents/Resources/umid/runtime-info"]
467
+ };
468
+ /** 允许用户显式指定组件路径(参考实现的 `QODER_UMID_PATH` 同规)。 */
469
+ const UMID_PATH_ENV = "QODER_UMID_PATH";
470
+ const cache = {};
471
+ /**
472
+ * 找本机可用的 UMID 组件。
473
+ *
474
+ * @returns 组件绝对路径;本机没有任何一个时 undefined(调用方据此如实降级)。
475
+ */
476
+ function findDeviceRiskComponent(region) {
477
+ const custom = process.env[UMID_PATH_ENV];
478
+ if (typeof custom === "string" && custom !== "" && existsSync(custom)) return custom;
479
+ for (const candidate of UMID_CANDIDATES[region]) if (existsSync(candidate)) return candidate;
480
+ }
481
+ /**
482
+ * 读取设备风控身份。
483
+ *
484
+ * 组件把 JSON 打在第一行 stdout。任何一步不符合预期(找不到组件、超时、输出不是
485
+ * JSON、字段缺失或过长)都**抛错**而不是返回半个对象 —— 半个身份发上去只会被风控
486
+ * 当成伪造,反而更难诊断。
487
+ *
488
+ * 结果按 {@link RISK_TTL_MS} 在进程内缓存。
489
+ */
490
+ async function readDeviceRisk(region, nowMs = Date.now()) {
491
+ const hit = cache[region];
492
+ if (hit !== void 0 && nowMs - hit.atMs < 3e6) return hit.risk;
493
+ const component = findDeviceRiskComponent(region);
494
+ if (component === void 0) throw new Error(`未找到 Qoder 设备风控组件(umid/runtime-info)。国际版签到需要它:请安装 Qoder 客户端,或用 ${UMID_PATH_ENV} 指定组件路径`);
495
+ let stdout;
496
+ try {
497
+ stdout = (await run(component, [], {
498
+ timeout: COMPONENT_TIMEOUT_MS,
499
+ maxBuffer: MAX_OUTPUT_BYTES
500
+ })).stdout;
501
+ } catch (error) {
502
+ throw new Error(`设备风控组件执行失败: ${String(error?.message ?? error)}`);
503
+ }
504
+ const firstLine = stdout.split("\n").map((line) => line.trim()).find((line) => line !== "") ?? "";
505
+ let parsed;
506
+ try {
507
+ parsed = JSON.parse(firstLine);
508
+ } catch {
509
+ throw new Error(`设备风控组件输出不是 JSON: ${firstLine.slice(0, 120)}`);
510
+ }
511
+ if (parsed === null || typeof parsed !== "object") throw new Error("设备风控组件输出不是对象");
512
+ const record = parsed;
513
+ const pick = (key) => {
514
+ const value = record[key];
515
+ if (typeof value !== "string" || value.trim() === "" || value.length > 4096) throw new Error(`设备风控组件输出缺少 ${key}`);
516
+ return value.trim();
517
+ };
518
+ const risk = {
519
+ machineToken: pick("machineToken"),
520
+ machineCode: pick("machineCode"),
521
+ machineType: pick("machineType")
522
+ };
523
+ cache[region] = {
524
+ risk,
525
+ atMs: nowMs
526
+ };
527
+ return risk;
528
+ }
529
+ //#endregion
530
+ //#region src/upstream.ts
531
+ /**
532
+ * Qoder 上游 HTTP 客户端:cosy 签名请求、jobToken 交换、聊天 SSE、
533
+ * 模型目录与用量查询。
534
+ *
535
+ * 端点映射(CN 与 Global 双区域)。两区域**各自一个网关主机**,chat / models /
536
+ * jobToken 三个端点共用它(见 `QODER_REGIONS` 的 `gateway` 字段):
537
+ *
538
+ * ```text
539
+ * CN gateway: gateway.qoder.com.cn
540
+ * chat: /algo/api/v2/service/pro/sse/agent_chat_generation
541
+ * models: /algo/api/v2/model/list
542
+ * jobToken: /algo/api/v3/user/jobToken
543
+ * openapi: openapi.qoder.com.cn
544
+ * Global gateway: api1.qoder.sh
545
+ * chat: /algo/api/v2/service/pro/sse/agent_chat_generation
546
+ * models: /algo/api/v2/model/list
547
+ * jobToken: /algo/api/v3/user/jobToken
548
+ * openapi: openapi.qoder.sh
549
+ * ```
550
+ *
551
+ * 注:早期注释称国际版的 models 走 `api2.qoder.sh`、jobToken 走
552
+ * `center.qoder.sh`。实测(2026-10)三者对本插件用到的端点**完全等效**——
553
+ * 对同一个假 PAT 都回 `401 personal token is invalid`(凭据层被拒,而非
554
+ * `Signature invalid`),说明三者都接受同一套 cosy 签名、只是拒绝坏凭据。
555
+ * 所以国际版统一用 `api1.qoder.sh`,不按端点分主机;若将来真要分流,请改
556
+ * `QODER_REGIONS` 的配置表,而不是只改这段注释。
557
+ *
558
+ * @module dsh-connect-qoder/upstream
559
+ */
560
+ const CHAT_PATH = "/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1";
561
+ /**
562
+ * 每日签到走 `sash` 的**活动**接口。路径里没有「签到」二字(叫 campaigns),
563
+ * 所以照着 checkin / signin / reward 猜永远猜不到——实测猜了 13 个全 404。
564
+ * 注意是 **v1**(用量那套是 v2)。
565
+ */
566
+ const CAMPAIGNS_PATH = "/sash/api/v1/me/campaigns";
567
+ /**
568
+ * sash 面专用的令牌交换端点(CLI / 网页那一族)。
569
+ *
570
+ * 与 {@link JOB_TOKEN_PATH}(桌面客户端那一族)**并存**,不是替代 —— 实测国际版
571
+ * 的 chat 面只认前者换来的 `securityOauthToken`,而 sash / 额度面只认这里换来的
572
+ * `token`;国内版两族等价。见 `QoderSessionHolder.sashToken`。
573
+ */
574
+ const SASH_TOKEN_PATH = "/api/v1/jobToken/exchange";
575
+ const CAMPAIGNS_QUERY = "?forceRefresh=true";
576
+ /** sash 接口要求的客户端标识(参考实现如此,缺了会被拒)。 */
577
+ const SASH_CLIENT_TYPE = "10";
578
+ const SASH_USER_AGENT = "Qoder";
579
+ const QODER_REGIONS = {
580
+ cn: {
581
+ gateway: "https://gateway.qoder.com.cn",
582
+ openapi: "https://openapi.qoder.com.cn",
583
+ chatPath: CHAT_PATH,
584
+ modelsPath: "/algo/api/v2/model/list?Encode=1",
585
+ jobTokenPath: "/algo/api/v3/user/jobToken?Encode=1",
586
+ campaignsPath: CAMPAIGNS_PATH,
587
+ requiresDeviceRisk: false
588
+ },
589
+ global: {
590
+ gateway: "https://api1.qoder.sh",
591
+ openapi: "https://openapi.qoder.sh",
592
+ chatPath: CHAT_PATH,
593
+ modelsPath: "/algo/api/v2/model/list?Encode=1",
594
+ jobTokenPath: "/algo/api/v3/user/jobToken?Encode=1",
595
+ campaignsPath: CAMPAIGNS_PATH,
596
+ requiresDeviceRisk: true
597
+ }
598
+ };
599
+ var QoderUpstreamError = class extends Error {
600
+ kind;
601
+ status;
602
+ code;
603
+ constructor(kind, message, status = 0, code) {
604
+ super(message);
605
+ this.name = "QoderUpstreamError";
606
+ this.kind = kind;
607
+ this.status = status;
608
+ this.code = code;
609
+ }
610
+ };
611
+ function isQoderUpstreamError(error) {
612
+ return error instanceof QoderUpstreamError;
613
+ }
614
+ const DEFAULT_HEADERS = {
615
+ "cosy-data-policy": "agree",
616
+ "content-type": "application/json",
617
+ "cosy-clienttype": "5",
618
+ "login-version": "v2",
619
+ "user-agent": "Go-http-client/2.0",
620
+ "cosy-scene": "assistant",
621
+ "cosy-business-product": "ide",
622
+ "cosy-business-type": "agent"
623
+ };
624
+ /**
625
+ * Qoder 上游客户端。每个区域一个实例;会话按需惰性交换与续期。
626
+ */
627
+ var QoderUpstreamClient = class {
628
+ region;
629
+ /** 本实例所属区域(`region` 是它的配置表;这里保留区域名本身)。 */
630
+ regionKey;
631
+ logger;
632
+ installSalt;
633
+ fetchImpl;
634
+ /**
635
+ * 按凭证键控的会话缓存。
636
+ *
637
+ * 一个区域实例会同时服务多个账号(账号池批量探测、按账号查额度),
638
+ * 不同 PAT 的 cosy 会话互不相通,所以会话必须按凭证分开缓存——用单个
639
+ * 字段会互相覆盖(上一个账号的会话被下一个冲掉)。键是 PAT 本身:
640
+ * 会话不可再交换,必须绑定到它交换来的那份凭证。
641
+ */
642
+ holders = /* @__PURE__ */ new Map();
643
+ /** 最近使用的主会话(默认解析路径复用它)。 */
644
+ currentKey;
645
+ constructor(region, options = {}) {
646
+ this.region = QODER_REGIONS[region];
647
+ this.regionKey = region;
648
+ this.logger = options.logger ?? (() => {});
649
+ this.installSalt = options.installSalt;
650
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
651
+ }
652
+ log(message) {
653
+ this.logger(`dsh-connect-qoder: ${message}`);
654
+ }
655
+ /** 某一凭证的会话是否已过期(或即将过期)。 */
656
+ isSessionValid(credential, nowMs = Date.now()) {
657
+ const holder = this.holders.get(credential);
658
+ if (holder === void 0) return false;
659
+ return holder.expireAtMs - nowMs > 3e5;
660
+ }
661
+ /** 最近使用的凭证,沿用直觉意义上「当前账号」的引用。 */
662
+ currentCredential() {
663
+ return this.holders.has(this.currentKey ?? "") ? this.currentKey : void 0;
664
+ }
665
+ /**
666
+ * 用 PAT(`pt-...` 或任意凭证)或 refresh token(`drt-...`)交换 jobToken。
667
+ * 设备指纹以凭证为种子派生(无 uid 可用前),保证稳定。
668
+ */
669
+ async exchangeJobToken(credential) {
670
+ const fingerprint = deriveFingerprint("", credential, this.installSalt);
671
+ const body = jobTokenBody(credential);
672
+ const date = currentRfc1123Date();
673
+ const sig = signLegacy(date);
674
+ const headers = {
675
+ "cosy-machinetoken": fingerprint.machineToken,
676
+ "cosy-machinetype": fingerprint.machineType,
677
+ "login-version": "v2",
678
+ appcode: "cosy",
679
+ accept: "application/json",
680
+ "accept-encoding": "identity",
681
+ "cosy-version": "0.1.43",
682
+ "cosy-clienttype": "5",
683
+ date,
684
+ signature: sig,
685
+ "content-type": "application/json",
686
+ "cosy-machineid": fingerprint.machineId,
687
+ "user-agent": "Go-http-client/2.0"
688
+ };
689
+ const url = `${this.region.gateway}${this.region.jobTokenPath}`;
690
+ const resp = await this.fetchImpl(url, {
691
+ method: "POST",
692
+ headers,
693
+ body
694
+ });
695
+ const text = await resp.text();
696
+ if (resp.status !== 200) throw new QoderUpstreamError(classifyHttpStatus(resp.status, text), `jobToken 交换失败: HTTP ${resp.status} ${truncate(text, 200)}`, resp.status);
697
+ let data;
698
+ try {
699
+ data = JSON.parse(text);
700
+ } catch {
701
+ throw new QoderUpstreamError("unknown", `jobToken 响应不是 JSON: ${truncate(text, 200)}`, resp.status);
702
+ }
703
+ const jobToken = {
704
+ id: String(data.id ?? ""),
705
+ name: String(data.name ?? ""),
706
+ expireTime: Number(data.expireTime ?? 0),
707
+ securityOauthToken: String(data.securityOauthToken ?? ""),
708
+ refreshToken: String(data.refreshToken ?? ""),
709
+ userType: String(data.userType ?? "personal_standard")
710
+ };
711
+ if (!jobToken.id && !jobToken.securityOauthToken) throw new QoderUpstreamError("auth", `jobToken 交换响应缺少身份字段: ${truncate(text, 200)}`, resp.status);
712
+ const holder = {
713
+ session: newCosySession({
714
+ name: jobToken.name,
715
+ aid: jobToken.id,
716
+ uid: jobToken.id,
717
+ yx_uid: "",
718
+ organization_id: "",
719
+ organization_name: "",
720
+ user_type: jobToken.userType,
721
+ security_oauth_token: jobToken.securityOauthToken,
722
+ refresh_token: jobToken.refreshToken
723
+ }, fingerprint.machineId, fingerprint.machineToken, fingerprint.machineType),
724
+ fingerprint,
725
+ jobToken,
726
+ expireAtMs: jobToken.expireTime > 0 ? jobToken.expireTime : 0
727
+ };
728
+ await this.refreshSashToken(credential, holder);
729
+ this.holders.set(credential, holder);
730
+ this.currentKey = credential;
731
+ this.log(`jobToken 已交换: ${jobToken.name} (${jobToken.id})`);
732
+ return jobToken;
733
+ }
734
+ /**
735
+ * 换一枚 sash 面专用的令牌(`POST /api/v1/jobToken/exchange`)。
736
+ *
737
+ * **不抛**:这一族换不到时 sash 那一支会回落到旧令牌(国内版本来就够用),
738
+ * 而整条交换失败只该让「积分读不到」,不该让 chat / 对话跟着挂 —— 那是完全
739
+ * 不同的两条链路。
740
+ *
741
+ * body 键是 `personal_token`:实测 `personal_access_token`(eghrhegpe 那个 fork
742
+ * 用的拼写)在当前上游回 **400 BadRequest**,两个区域都是。
743
+ */
744
+ async refreshSashToken(credential, holder) {
745
+ const url = `${this.region.openapi}${SASH_TOKEN_PATH}`;
746
+ try {
747
+ const resp = await this.fetchImpl(url, {
748
+ method: "POST",
749
+ headers: {
750
+ "content-type": "application/json",
751
+ accept: "application/json"
752
+ },
753
+ body: JSON.stringify({ personal_token: credential })
754
+ });
755
+ const text = await resp.text();
756
+ if (resp.status !== 200) {
757
+ this.log(`sash 令牌交换失败(HTTP ${resp.status}),sash 面将回落到旧令牌: ${truncate(text, 120)}`);
758
+ return;
759
+ }
760
+ const data = parseJson(text);
761
+ const raw = data === void 0 ? {} : data;
762
+ const token = typeof raw["token"] === "string" && raw["token"] !== "" ? raw["token"] : typeof raw["job_token"] === "string" && raw["job_token"] !== "" ? raw["job_token"] : typeof raw["jobToken"] === "string" && raw["jobToken"] !== "" ? raw["jobToken"] : "";
763
+ if (token === "") {
764
+ this.log(`sash 令牌交换响应里没有 token 字段: ${truncate(text, 120)}`);
765
+ return;
766
+ }
767
+ holder.sashToken = token;
768
+ const expiry = typeof raw["expires_at"] === "string" ? Date.parse(raw["expires_at"]) : NaN;
769
+ holder.sashExpireAtMs = Number.isFinite(expiry) ? expiry : 0;
770
+ this.log(`sash 令牌已交换(有效期至 ${holder.sashExpireAtMs > 0 ? new Date(holder.sashExpireAtMs).toISOString() : "未知"})`);
771
+ } catch (error) {
772
+ this.log(`sash 令牌交换异常,sash 面将回落到旧令牌: ${String(error?.message ?? error).slice(0, 120)}`);
773
+ }
774
+ }
775
+ /**
776
+ * 取该会话用于 **sash 面** 的 Authorization 值。
777
+ *
778
+ * 新令牌优先(国际版必需);没有就回落到旧的 `securityOauthToken`(国内版
779
+ * 两个面都认它,而且它是 chat 在用的那一个 —— 回落不会破坏任何东西)。
780
+ *
781
+ * 返回 `''` 表示两族都没有:那一支必须报「读不到」,绝不能带上一个空 bearer
782
+ * 去换一个 401 回来(那样分不清是没令牌还是令牌被拒)。
783
+ */
784
+ sashBearerOf(holder) {
785
+ if (typeof holder.sashToken === "string" && holder.sashToken !== "") return holder.sashToken;
786
+ return holder.jobToken.securityOauthToken;
787
+ }
788
+ /**
789
+ * 确保存在未过期会话。credential 提供 PAT 或 refreshToken(`drt-` 前缀)。
790
+ * 已有有效会话时直接复用(不重复交换)。
791
+ */
792
+ async ensureSession(credential) {
793
+ if (this.isSessionValid(credential)) return this.holders.get(credential);
794
+ await this.exchangeJobToken(credential);
795
+ return this.holders.get(credential);
796
+ }
797
+ /**
798
+ * 拉取模型目录。响应形状(服务端 snake_case):
799
+ * `{"chat":[{key, display_name, enable, is_vl, is_default, ...}], ...}`
800
+ */
801
+ async fetchModels(credential, signal) {
802
+ const holder = await this.ensureSession(credential);
803
+ const url = `${this.region.gateway}${this.region.modelsPath}`;
804
+ const data = await this.bearerGet(holder, url, signal);
805
+ return (Array.isArray(data.chat) ? data.chat : []).map((entry) => {
806
+ if (!entry || typeof entry !== "object") return void 0;
807
+ const raw = entry;
808
+ const key = typeof raw.key === "string" ? raw.key : "";
809
+ const displayName = typeof raw.display_name === "string" ? raw.display_name : "";
810
+ if (!key || !displayName) return void 0;
811
+ return {
812
+ key,
813
+ display_name: displayName,
814
+ enable: raw.enable !== false,
815
+ is_default: raw.is_default === true,
816
+ is_vl: raw.is_vl === true,
817
+ is_reasoning: raw.is_reasoning === true,
818
+ is_free: raw.is_free === true,
819
+ price_factor: typeof raw.price_factor === "number" ? raw.price_factor : void 0,
820
+ max_input_tokens: typeof raw.max_input_tokens === "number" ? raw.max_input_tokens : void 0,
821
+ max_output_tokens: typeof raw.max_output_tokens === "number" ? raw.max_output_tokens : void 0,
822
+ thinking_config: typeof raw.thinking_config === "object" ? raw.thinking_config : void 0,
823
+ context_config: typeof raw.context_config === "object" ? raw.context_config : void 0,
824
+ raw
825
+ };
826
+ }).filter((m) => m !== void 0);
827
+ }
828
+ /**
829
+ * 聊天 SSE 流。onDelta 逐行回调;返回 null 表示正常结束。
830
+ * 令牌:`credential` 只在会话续期时使用。
831
+ */
832
+ async chatStream(credential, modelKey, messages, options) {
833
+ const holder = await this.ensureSession(credential);
834
+ const template = deepClone(createChatBodyTemplate());
835
+ applyModelKey(template, modelKey, options.userType ?? holder.jobToken.userType, options.isVl ?? false);
836
+ if (options.isReasoning) {
837
+ template.model_config.is_reasoning = true;
838
+ template.chat_context.extra.modelConfig.is_reasoning = true;
839
+ }
840
+ if (options.reasoningEffort !== void 0 && options.reasoningEffort !== "") {
841
+ template.reasoningEffort = options.reasoningEffort;
842
+ template.parameters = {
843
+ ...template.parameters ?? {},
844
+ reasoning_effort: options.reasoningEffort
845
+ };
846
+ }
847
+ const prompt = extractLatestUserPrompt(messages);
848
+ template.messages = buildQoderMessages(messages, prompt, Array.isArray(options.tools) && options.tools.length > 0);
849
+ if (options.tools !== void 0) template.tools = options.tools;
850
+ template.request_id = randomId();
851
+ template.chat_record_id = template.request_id;
852
+ template.request_set_id = randomId();
853
+ template.session_id = randomId();
854
+ template.business.id = randomId();
855
+ template.business.name = prompt.slice(0, 30) || "hi";
856
+ template.business.begin_at = Date.now();
857
+ const body = JSON.stringify(template);
858
+ const url = `${this.region.gateway}${this.region.chatPath}`;
859
+ const pathSig = pathSigFrom(url);
860
+ const encoded = cosyEncode(new TextEncoder().encode(body));
861
+ const date = String(Math.floor(Date.now() / 1e3));
862
+ const payloadB64 = buildPayloadB64(holder.session.info);
863
+ const bearer = composeBearer(payloadB64, signRequest(payloadB64, holder.session.cosyKey, date, encoded, pathSig));
864
+ const headers = {
865
+ ...DEFAULT_HEADERS,
866
+ "cosy-machinetype": holder.session.machineType,
867
+ "cosy-date": date,
868
+ "cosy-user": holder.session.identity.uid,
869
+ "cosy-key": holder.session.cosyKey,
870
+ "cache-control": "no-cache",
871
+ accept: "text/event-stream",
872
+ authorization: bearer,
873
+ "cosy-version": "0.1.43",
874
+ "cosy-machineid": holder.session.machineId,
875
+ "cosy-machinetoken": holder.session.machineToken,
876
+ "x-model-key": modelKey,
877
+ "x-model-source": "system"
878
+ };
879
+ const resp = await this.fetchImpl(url, {
880
+ method: "POST",
881
+ headers,
882
+ body: encoded,
883
+ ...options.signal === void 0 ? {} : { signal: options.signal }
884
+ });
885
+ if (resp.status !== 200) {
886
+ const errText = await resp.text();
887
+ throw new QoderUpstreamError(classifyHttpStatus(resp.status, errText), `聊天请求失败: HTTP ${resp.status} ${truncate(errText, 300)}`, resp.status);
888
+ }
889
+ if (!resp.body) throw new QoderUpstreamError("transient", "聊天响应没有 body");
890
+ const reader = resp.body.getReader();
891
+ const decoder = new TextDecoder();
892
+ let buffer = "";
893
+ try {
894
+ for (;;) {
895
+ const { done, value } = await reader.read();
896
+ if (done) break;
897
+ buffer += decoder.decode(value, { stream: true });
898
+ const lines = buffer.split("\n");
899
+ buffer = lines.pop() ?? "";
900
+ for (const line of lines) {
901
+ const trimmed = line.trim();
902
+ if (trimmed === "" || !trimmed.startsWith("data:")) continue;
903
+ const payload = trimmed.slice(5).trim();
904
+ if (payload === "[DONE]") continue;
905
+ const delta = parseStreamDelta(payload);
906
+ if (delta !== void 0) options.onDelta(delta);
907
+ }
908
+ }
909
+ } finally {
910
+ reader.releaseLock();
911
+ }
912
+ }
913
+ /**
914
+ * 查询用量(只读)。`GET {openapi}/sash/api/v2/me/usage`。
915
+ *
916
+ * **认证用的是 jobToken 里的 `securityOauthToken`(`jt-…`),不是 PAT,
917
+ * 也不是 COSY 签名 bearer。** 三者实测(2026-10,CN):
918
+ *
919
+ * | 凭据 | 结果 |
920
+ * | --- | --- |
921
+ * | 裸 PAT `pt-…` | 401 `TOKEN_EXPIRE / token is not active` |
922
+ * | COSY 签名 bearer(chat/models 用的那套) | 401 `TOKEN_INVALID / invalid token` |
923
+ * | `jt-…`(jobToken 交换产出) | **200,返回完整 qoderUsage** |
924
+ *
925
+ * 前两种之所以失败,是因为 `sash` 是**浏览器会话面**的接口:它既不收 PAT,
926
+ * 也不认 cosy 协议头(缺 Authorization 时它回
927
+ * `UNAUTHORIZED / missing cookie header`,可见它本来是给 cookie 会话用的)。
928
+ * 这解释了「令牌明明是好的、聊天也正常,积分却永远拿不到」——以前用错凭据。
929
+ */
930
+ async fetchUsage(credential, signal) {
931
+ const url = `${this.region.openapi}/sash/api/v2/me/usage`;
932
+ try {
933
+ const holder = await this.ensureSession(credential);
934
+ return parseUsage(await this.jobTokenGet(holder, url, signal));
935
+ } catch (error) {
936
+ if (error instanceof QoderUpstreamError && (error.status === 401 || error.status === 403)) {
937
+ const direct = await this.fetchUsageDirect(url, credential, signal);
938
+ if (direct !== void 0) return direct;
939
+ throw error;
940
+ }
941
+ return;
942
+ }
943
+ }
944
+ /**
945
+ * `sash` 系列接口的取数:带 `Authorization: Bearer <jobToken.securityOauthToken>`。
946
+ *
947
+ * 与 {@link bearerGet} 的区别只有一个——**认证凭据不同**:`sash` 要的是
948
+ * jobToken 交换产出的 `jt-…`,不是 COSY 签名 bearer(实测后者回
949
+ * `TOKEN_INVALID / invalid token`),也不是裸 PAT(回 `TOKEN_EXPIRE`)。
950
+ * 也正因为如此,这里**不**带那套 cosy-* 协议头:它们属于 cosy 协议面。
951
+ * 但**设备风控三件套属于 sash 面**,按 `requiresDeviceRisk` 附加 —— 见下。
952
+ */
953
+ async jobTokenGet(holder, url, signal) {
954
+ const oauthToken = this.sashBearerOf(holder);
955
+ if (oauthToken === "") throw new QoderUpstreamError("auth", "两次令牌交换都没有拿到可用令牌,无法查询用量", 401);
956
+ const resp = await this.fetchImpl(url, {
957
+ headers: {
958
+ accept: "application/json",
959
+ authorization: `Bearer ${oauthToken}`,
960
+ ...await this.deviceRiskHeaders()
961
+ },
962
+ ...signal === void 0 ? {} : { signal }
963
+ });
964
+ const text = await resp.text();
965
+ if (resp.status !== 200) throw new QoderUpstreamError(classifyHttpStatus(resp.status, text), `请求失败: HTTP ${resp.status} ${truncate(text, 200)}`, resp.status);
966
+ try {
967
+ return JSON.parse(text);
968
+ } catch {
969
+ throw new QoderUpstreamError("unknown", `响应不是 JSON: ${truncate(text, 200)}`, resp.status);
970
+ }
971
+ }
972
+ /**
973
+ * 拉当日的签到活动列表。
974
+ *
975
+ * 协议来自社区参考实现(L0NE-6/Qoder-Daily),已在本机 CN 账号实测通过:
976
+ * `GET {openapi}/sash/api/v1/me/campaigns?forceRefresh=true`,认证用与用量
977
+ * 查询同一套 `jt-` 令牌(`jobToken.securityOauthToken`)。
978
+ *
979
+ * 注意路径里**没有**「签到」二字——它叫 campaigns(活动/权益)。这也是我早期
980
+ * 把 `checkinSupported` 写死成 false 的原因:照着 checkin / signin / reward
981
+ * 猜路径一律 404(实测猜了 13 个),于是误判成「Qoder 没有签到」。
982
+ */
983
+ async fetchCampaigns(credential, signal) {
984
+ const holder = await this.ensureSession(credential);
985
+ const url = `${this.region.openapi}${this.region.campaignsPath}${CAMPAIGNS_QUERY}`;
986
+ const data = await this.sashRequest("GET", holder, url, void 0, signal);
987
+ return (Array.isArray(data.campaigns) ? data.campaigns : Array.isArray(data.data?.campaigns) ? data.data.campaigns : []).map((entry) => {
988
+ if (!entry || typeof entry !== "object") return void 0;
989
+ const raw = entry;
990
+ const campaignId = typeof raw.campaignId === "string" ? raw.campaignId : "";
991
+ if (campaignId === "") return void 0;
992
+ const benefit = typeof raw.benefit === "object" && raw.benefit !== null ? raw.benefit : {};
993
+ const validity = typeof benefit.validity === "object" && benefit.validity !== null ? benefit.validity : {};
994
+ return {
995
+ campaignId,
996
+ actionType: typeof raw.actionType === "string" ? raw.actionType : "",
997
+ claimStatus: typeof raw.claimStatus === "string" ? raw.claimStatus : "",
998
+ ...typeof benefit.amount === "number" ? { amount: benefit.amount } : {},
999
+ ...typeof validity.days === "number" ? { validityDays: validity.days } : {},
1000
+ ...typeof raw.startAt === "number" ? { startAt: raw.startAt } : {},
1001
+ ...typeof raw.endAt === "number" ? { endAt: raw.endAt } : {}
1002
+ };
1003
+ }).filter((c) => c !== void 0);
1004
+ }
1005
+ /**
1006
+ * 签到:领取今天那一份每日 Credits。
1007
+ *
1008
+ * 一次调用自足——先拉活动列表找出可领的那条,再领取。三种结果分得很清:
1009
+ * - `claimed`:领到了(带回数额与有效期);
1010
+ * - `already-claimed`:今天已经领过(`CLAIMED` / 409 / `ALREADY_CLAIMED` /
1011
+ * `replayed`),**不是错误**——重复调用是幂等的;
1012
+ * - `unavailable`:今天上游没下发活动(未开窗 / 已领到活动行消失 / 企业号
1013
+ * 不支持该活动),带上说明。
1014
+ */
1015
+ async claimDailyCredits(credential, signal) {
1016
+ const campaigns = await this.fetchCampaigns(credential, signal);
1017
+ const daily = campaigns.find((c) => c.actionType === "CLAIM_BENEFIT");
1018
+ const claimable = campaigns.find((c) => c.actionType === "CLAIM_BENEFIT" && c.claimStatus === "CLAIMABLE");
1019
+ const nextRefreshAtMs = daily?.endAt === void 0 ? void 0 : daily.endAt * 1e3;
1020
+ if (claimable === void 0) return daily?.claimStatus === "CLAIMED" ? {
1021
+ status: "already-claimed",
1022
+ ...nextRefreshAtMs === void 0 ? {} : { nextRefreshAtMs }
1023
+ } : {
1024
+ status: "unavailable",
1025
+ message: "上游今日没有下发可领取的签到活动"
1026
+ };
1027
+ const holder = await this.ensureSession(credential);
1028
+ const url = `${this.region.openapi}${this.region.campaignsPath}/${encodeURIComponent(claimable.campaignId)}/claim`;
1029
+ try {
1030
+ const data = await this.sashRequest("POST", holder, url, {}, signal);
1031
+ if (String(data.status ?? "") === "CLAIMED") return {
1032
+ status: "claimed",
1033
+ ...claimable.amount === void 0 ? {} : { amount: claimable.amount },
1034
+ ...claimable.validityDays === void 0 ? {} : { validityDays: claimable.validityDays },
1035
+ ...nextRefreshAtMs === void 0 ? {} : { nextRefreshAtMs }
1036
+ };
1037
+ return {
1038
+ status: "unavailable",
1039
+ message: `签到未完成(上游状态 ${String(data.status ?? "未知")})`
1040
+ };
1041
+ } catch (error) {
1042
+ if (error instanceof QoderUpstreamError && error.status === 409) return {
1043
+ status: "already-claimed",
1044
+ ...nextRefreshAtMs === void 0 ? {} : { nextRefreshAtMs }
1045
+ };
1046
+ throw error;
1047
+ }
1048
+ }
1049
+ /**
1050
+ * `sash` 接口的通用请求(活动列表 / 领取共用)。
1051
+ *
1052
+ * 认证与用量查询同一套:`Authorization: Bearer <jobToken.securityOauthToken>`。
1053
+ * 但那两个接口需要 `cosy-clienttype: 10` 与 `user-agent: Qoder`——参考实现
1054
+ * 如此,缺了会被拒;而夹带 cosy 协议头(chat/models 那套)反而不认。
1055
+ *
1056
+ * **国际版额外需要设备风控头**(`Cosy-MachineToken/Code/Type`,见 device-risk.ts):
1057
+ * 签到活动只下发给带这些头的请求,缺了就拿不到活动列表。国内版不需要,所以按
1058
+ * region 条件附加 —— 不要无条件发,多余的头在另一侧是噪音。
1059
+ */
1060
+ async sashRequest(method, holder, url, body, signal) {
1061
+ const oauthToken = this.sashBearerOf(holder);
1062
+ if (oauthToken === "") throw new QoderUpstreamError("auth", "两次令牌交换都没有拿到可用令牌,无法访问签到接口", 401);
1063
+ const resp = await this.fetchImpl(url, {
1064
+ method,
1065
+ headers: {
1066
+ authorization: `Bearer ${oauthToken}`,
1067
+ "cosy-clienttype": SASH_CLIENT_TYPE,
1068
+ accept: "application/json",
1069
+ "accept-language": "zh-CN",
1070
+ "user-agent": SASH_USER_AGENT,
1071
+ ...await this.deviceRiskHeaders(),
1072
+ ...method === "POST" ? {
1073
+ "content-type": "application/json",
1074
+ origin: this.region.openapi
1075
+ } : {}
1076
+ },
1077
+ ...method === "POST" ? { body: JSON.stringify(body ?? {}) } : {},
1078
+ ...signal === void 0 ? {} : { signal }
1079
+ });
1080
+ const text = await resp.text();
1081
+ if (resp.status !== 200 && resp.status !== 201) throw new QoderUpstreamError(classifyHttpStatus(resp.status, text), `请求失败: HTTP ${resp.status} ${truncate(text, 200)}`, resp.status);
1082
+ try {
1083
+ return JSON.parse(text);
1084
+ } catch {
1085
+ throw new QoderUpstreamError("unknown", `响应不是 JSON: ${truncate(text, 200)}`, resp.status);
1086
+ }
1087
+ }
1088
+ /**
1089
+ * 本区域签到需要的设备风控头。
1090
+ *
1091
+ * 国内版返回空对象(不需要,发了反而是噪音);国际版读取本机 UMID 组件的
1092
+ * 三件套。读取失败**不**静默降级成「无头请求」——那样只会拿到一个空的活动
1093
+ * 列表,用户看到「今天没有活动」而真实原因是取不到设备身份。所以这里让错误
1094
+ * 抛出去,由调用方带着原因回报。
1095
+ */
1096
+ async deviceRiskHeaders() {
1097
+ if (!this.region.requiresDeviceRisk) return {};
1098
+ const risk = await readDeviceRisk(this.regionKey);
1099
+ return {
1100
+ "cosy-machinetoken": risk.machineToken,
1101
+ "cosy-machinecode": risk.machineCode,
1102
+ "cosy-machinetype": risk.machineType
1103
+ };
1104
+ }
1105
+ /**
1106
+ * 直连回退:`Bearer <PAT>` 打同一个端点。返回 undefined 表示这条回退路径
1107
+ * 也没给出 200(端点不接受直连 PAT,或网络失败)——由调用方决定是否把原始
1108
+ * 的凭据被拒错误抛出去。
1109
+ */
1110
+ async fetchUsageDirect(url, credential, signal) {
1111
+ try {
1112
+ const resp = await this.fetchImpl(url, {
1113
+ headers: {
1114
+ authorization: `Bearer ${credential}`,
1115
+ accept: "application/json",
1116
+ "cosy-clienttype": "10",
1117
+ "user-agent": "Qoder",
1118
+ ...await this.deviceRiskHeaders()
1119
+ },
1120
+ ...signal === void 0 ? {} : { signal }
1121
+ });
1122
+ if (resp.status === 200) return parseUsage(await resp.json());
1123
+ if (resp.status === 401 || resp.status === 403) return;
1124
+ } catch {}
1125
+ }
1126
+ async bearerGet(holder, url, signal) {
1127
+ const pathSig = pathSigFrom(url);
1128
+ const payloadB64 = buildPayloadB64(holder.session.info);
1129
+ const date = String(Math.floor(Date.now() / 1e3));
1130
+ const bearer = composeBearer(payloadB64, signRequest(payloadB64, holder.session.cosyKey, date, "", pathSig));
1131
+ const headers = {
1132
+ ...DEFAULT_HEADERS,
1133
+ "cosy-machinetype": holder.session.machineType,
1134
+ "cosy-date": date,
1135
+ "cosy-user": holder.session.identity.uid,
1136
+ "cosy-key": holder.session.cosyKey,
1137
+ accept: "application/json",
1138
+ authorization: bearer,
1139
+ "cosy-version": "0.1.43",
1140
+ "cosy-machineid": holder.session.machineId,
1141
+ "cosy-machinetoken": holder.session.machineToken
1142
+ };
1143
+ const resp = await this.fetchImpl(url, {
1144
+ headers,
1145
+ ...signal === void 0 ? {} : { signal }
1146
+ });
1147
+ const text = await resp.text();
1148
+ if (resp.status !== 200) throw new QoderUpstreamError(classifyHttpStatus(resp.status, text), `请求失败: HTTP ${resp.status} ${truncate(text, 200)}`, resp.status);
1149
+ try {
1150
+ return JSON.parse(text);
1151
+ } catch {
1152
+ throw new QoderUpstreamError("unknown", `响应不是 JSON: ${truncate(text, 200)}`, resp.status);
1153
+ }
1154
+ }
1155
+ };
1156
+ /** jobToken 交换请求体。PAT 或 `drt-` refreshToken 二选一。 */
1157
+ function jobTokenBody(credential) {
1158
+ const isRefresh = credential.startsWith("drt-");
1159
+ const outer = {
1160
+ payload: JSON.stringify({
1161
+ personalToken: isRefresh ? "" : credential,
1162
+ securityOauthToken: "",
1163
+ refreshToken: isRefresh ? credential : "",
1164
+ needRefresh: isRefresh,
1165
+ authInfo: {}
1166
+ }),
1167
+ encodeVersion: "1"
1168
+ };
1169
+ const plain = JSON.stringify(outer);
1170
+ return cosyEncode(new TextEncoder().encode(plain));
1171
+ }
1172
+ function randomId() {
1173
+ return crypto.randomUUID();
1174
+ }
1175
+ /**
1176
+ * 解析一条 SSE 帧:
1177
+ * `data: {"headers":{...},"body":"<inner json>","statusCodeValue":200}`
1178
+ * 内层:`{"choices":[{"delta":{...}}], "usage":{...}}` 或业务错误 `{"code":"115","message":"..."}`。
1179
+ * 返回 undefined 表示本帧无可消费内容(或 [DONE] 之前的空帧)。
1180
+ */
1181
+ function parseStreamDelta(dataPayload) {
1182
+ let wrapper;
1183
+ try {
1184
+ wrapper = JSON.parse(dataPayload);
1185
+ } catch {
1186
+ return;
1187
+ }
1188
+ const scv = wrapper.statusCodeValue;
1189
+ if (scv !== void 0 && scv !== null && Number(scv) !== 200) {
1190
+ const detail = typeof wrapper.body === "string" ? wrapper.body : dataPayload.slice(0, 400);
1191
+ throw new QoderUpstreamError(classifyHttpStatus(Number(scv), detail), `上游信封错误: HTTP ${scv} ${truncate(detail, 300)}`, Number(scv));
1192
+ }
1193
+ const innerStr = wrapper.body;
1194
+ if (typeof innerStr !== "string" || innerStr === "") return void 0;
1195
+ let inner;
1196
+ try {
1197
+ inner = JSON.parse(innerStr);
1198
+ } catch {
1199
+ return;
1200
+ }
1201
+ if (typeof inner.code === "string" && inner.code !== "" && inner.code !== "0") throw new QoderUpstreamError(classifyBusinessCode(inner.code), `上游业务错误 code=${inner.code}: ${String(inner.message ?? "")}`, 0, inner.code);
1202
+ const usageIn = readTokens(inner.usage, "prompt_tokens");
1203
+ const usageOut = readTokens(inner.usage, "completion_tokens");
1204
+ const choices = Array.isArray(inner.choices) ? inner.choices : [];
1205
+ for (const ch of choices) {
1206
+ const delta = ch.delta;
1207
+ if (!delta) continue;
1208
+ const role = typeof delta.role === "string" ? delta.role : "";
1209
+ const content = typeof delta.content === "string" ? delta.content : "";
1210
+ const reasoning = typeof delta.reasoning_content === "string" ? delta.reasoning_content : "";
1211
+ const toolCalls = Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0 ? delta.tool_calls : void 0;
1212
+ if (role !== "" || content !== "" || reasoning !== "" || toolCalls !== void 0) return {
1213
+ role,
1214
+ content,
1215
+ reasoningContent: reasoning,
1216
+ toolCalls,
1217
+ inputTokens: usageIn,
1218
+ outputTokens: usageOut
1219
+ };
1220
+ }
1221
+ if (usageIn > 0 || usageOut > 0) return {
1222
+ role: "",
1223
+ content: "",
1224
+ reasoningContent: "",
1225
+ toolCalls: void 0,
1226
+ inputTokens: usageIn,
1227
+ outputTokens: usageOut
1228
+ };
1229
+ }
1230
+ function readTokens(usage, key) {
1231
+ if (!usage || typeof usage !== "object") return 0;
1232
+ const v = usage[key];
1233
+ return typeof v === "number" ? v : 0;
1234
+ }
1235
+ /** 解析用量响应(兼容 qoderUsage 内层与平铺两种形状)。 */
1236
+ function parseUsage(data) {
1237
+ const source = data.qoderUsage ?? data;
1238
+ const userQuota = readBucket(source.userQuota);
1239
+ const addOn = source.addOnQuota;
1240
+ const org = source.orgResourcePackage;
1241
+ const out = {
1242
+ userType: typeof source.userType === "string" ? source.userType : void 0,
1243
+ expiresAt: typeof source.expiresAt === "number" ? source.expiresAt : void 0,
1244
+ upgradeUrl: typeof source.upgradeUrl === "string" ? source.upgradeUrl : void 0,
1245
+ isQuotaExceeded: source.isQuotaExceeded === true
1246
+ };
1247
+ if (userQuota) out.userQuota = userQuota;
1248
+ if (addOn) out.addOnQuota = {
1249
+ ...readBucket(addOn),
1250
+ detailUrl: typeof addOn.detailUrl === "string" ? addOn.detailUrl : void 0
1251
+ };
1252
+ if (org) out.orgResourcePackage = {
1253
+ used: num(org.used),
1254
+ cap: num(org.cap),
1255
+ remaining: num(org.remaining),
1256
+ percentage: num(org.percentage),
1257
+ available: org.available === true
1258
+ };
1259
+ return out;
1260
+ }
1261
+ function readBucket(value) {
1262
+ if (!value || typeof value !== "object") return void 0;
1263
+ const v = value;
1264
+ return {
1265
+ total: num(v.total),
1266
+ used: num(v.used),
1267
+ remaining: num(v.remaining),
1268
+ percentage: num(v.percentage)
1269
+ };
1270
+ }
1271
+ function num(v) {
1272
+ return typeof v === "number" ? v : void 0;
1273
+ }
1274
+ /** HTTP 状态 → 错误分类(内容审核标记优先于状态码,与 Go 版一致)。 */
1275
+ function classifyHttpStatus(status, detail) {
1276
+ if (isContentPolicy(detail)) return "content-policy";
1277
+ if (status === 401 || status === 403) return "auth";
1278
+ if (status === 429) return "rate-limited";
1279
+ if (status === 400) return "invalid-request";
1280
+ if (status === 418 || status >= 500) return "transient";
1281
+ return "unknown";
1282
+ }
1283
+ /** 业务 code → 错误分类。 */
1284
+ function classifyBusinessCode(code) {
1285
+ if (code === "105") return "auth";
1286
+ if (code === "115" || code === "402" || code === "6004") return "quota-exhausted";
1287
+ return "unknown";
1288
+ }
1289
+ const CONTENT_POLICY_MARKERS = [
1290
+ "DataInspectionFailed",
1291
+ "inappropriate content",
1292
+ "input text data may contain",
1293
+ "ContentFilter",
1294
+ "SensitiveContent"
1295
+ ];
1296
+ function isContentPolicy(detail) {
1297
+ return CONTENT_POLICY_MARKERS.some((marker) => detail.includes(marker));
1298
+ }
1299
+ /** Parse JSON, or undefined when the body is not JSON at all. Never throws. */
1300
+ function parseJson(text) {
1301
+ try {
1302
+ return JSON.parse(text);
1303
+ } catch {
1304
+ return;
1305
+ }
1306
+ }
1307
+ function truncate(text, max) {
1308
+ return text.length <= max ? text : `${text.slice(0, max)}…`;
1309
+ }
1310
+ //#endregion
1311
+ //#region src/pi-ai-runtime.ts
1312
+ /**
1313
+ * Which `@earendil-works/pi-ai` copy THIS plugin actually resolves at runtime.
1314
+ *
1315
+ * 参考:dsh-connect-workbuddy(MIT,Copyright (c) 2026 LaoDing)的同名模块
1316
+ * 改动:纯改名移植(workbuddy → qoder,模块 doc 提及),检测机制与决策表原样沿用。
1317
+ *
1318
+ * WHY THIS EXISTS (issues #24 / #25 / #26). The plugin declares pi-ai as a
1319
+ * peer (`>=0.85.0 <0.88.0`), so the module it imports is whatever the host
1320
+ * environment provides — and the two generations consume DIFFERENT context
1321
+ * shapes:
1322
+ *
1323
+ * - 0.87 hands providers a normalized transcript: `{ messages: [ {role:
1324
+ * 'system', content, toolsAdded}, … ] }`. The system prompt and the tool
1325
+ * declarations live ONLY on that leading system message; the top-level
1326
+ * `systemPrompt` / `tools` fields are gone, and 0.87's api never reads them
1327
+ * back.
1328
+ * - 0.85 expects `{ systemPrompt, tools, messages }` and has no branch for a
1329
+ * `system` message inside `messages` at all: its token estimator crashes on
1330
+ * one (issue #24), and its wire converter silently drops it.
1331
+ *
1332
+ * Which copy resolves is NOT predictable from the host version alone: a
1333
+ * nested 0.85.1 on disk shadows the host's 0.87.1 (the plugin author's own
1334
+ * symlinked checkout does exactly that), while a clean market install has no
1335
+ * local copy at all and resolves the host's (issue #26's reporter). The
1336
+ * adapter therefore gates on what THIS plugin resolves.
1337
+ *
1338
+ * HOW it is detected — deliberately WITHOUT importing pi-ai:
1339
+ * `import.meta.resolve` locates the entry file (executing nothing), the
1340
+ * package manifest sitting beside it names the version, and the generation is
1341
+ * decided from that version (0.87+ → modern). A static import would drag
1342
+ * pi-ai into the standalone `doctor` CLI, which must also run from market
1343
+ * installs where NO local pi-ai exists and the module is only provided inside
1344
+ * the DSH host — a static import there is a load-time crash. Resolution
1345
+ * itself does not need the module: within the peer range, the manifest
1346
+ * version and the `normalizeContext` feature (present from 0.87 on, not
1347
+ * before) agree.
1348
+ *
1349
+ * When resolution FAILS — the normal state of a market install probed outside
1350
+ * the host — there is no local copy, so inside DSH the host supplies pi-ai.
1351
+ * Every DSH host that normalizes contexts (0.2.0+, via 0.87.1) also supplies
1352
+ * 0.87.1, so the reported generation defaults to `modern` (pass-through).
1353
+ * That default is safe on older hosts too: they never normalize, and a native
1354
+ * 0.85-shaped context is an identity under BOTH branches of the adapter gate.
1355
+ *
1356
+ * `doctor` prints the resolved version and path so mixed-generation setups —
1357
+ * the silent kind that produced #24 and #26 — become visible on demand.
1358
+ *
1359
+ * @module dsh-connect-qoder/pi-ai-runtime
1360
+ */
1361
+ let cached;
1362
+ /** Resolve (once) which pi-ai copy this plugin runs against. */
1363
+ function piAiRuntimeInfo() {
1364
+ cached ??= piAiRuntimeFor(resolveModuleFile());
1365
+ return cached;
1366
+ }
1367
+ /**
1368
+ * The decision table for one resolution result, exported so the branch that
1369
+ * only ever runs on OTHER machines (a market install with no local pi-ai) is
1370
+ * pinned by tests rather than by this comment.
1371
+ *
1372
+ * `undefined` means nothing local resolved — inside DSH the host supplies the
1373
+ * module, and every host that normalizes contexts (0.2.0+, via 0.87.1) ships
1374
+ * the modern generation, so that is the default. Older hosts never normalize,
1375
+ * and their native 0.85-shaped context is an identity under either gate
1376
+ * branch, so the default cannot hurt them.
1377
+ */
1378
+ function piAiRuntimeFor(resolvedFrom) {
1379
+ if (resolvedFrom === void 0) return {
1380
+ generation: "modern",
1381
+ resolvedFrom: void 0,
1382
+ version: void 0,
1383
+ resolvedLocally: false
1384
+ };
1385
+ const version = readVersion(resolvedFrom);
1386
+ return {
1387
+ generation: generationOf(version),
1388
+ resolvedFrom,
1389
+ version,
1390
+ resolvedLocally: true
1391
+ };
1392
+ }
1393
+ /**
1394
+ * The file the main entry resolved to; undefined when resolution itself fails.
1395
+ *
1396
+ * `import.meta.resolve` (not `require.resolve`): pi-ai's exports map exposes
1397
+ * "." under the `import` condition only, so the CJS resolver refuses the bare
1398
+ * specifier outright — the ESM resolver is the one this plugin actually
1399
+ * imports with, so it is also the one whose answer is meaningful here.
1400
+ */
1401
+ function resolveModuleFile() {
1402
+ try {
1403
+ return fileURLToPath(import.meta.resolve("@earendil-works/pi-ai"));
1404
+ } catch {
1405
+ return;
1406
+ }
1407
+ }
1408
+ /**
1409
+ * The version of the package owning `moduleFile`. pi-ai's exports map does
1410
+ * not expose `./package.json`, so the manifest is found by walking up from
1411
+ * the resolved entry — `dist/index.js` sits directly inside the package —
1412
+ * and claiming it only when its `name` matches, so a same-named ancestor
1413
+ * (a pnpm virtual-store parent, for instance) cannot be misread.
1414
+ */
1415
+ function readVersion(moduleFile) {
1416
+ if (moduleFile === void 0) return void 0;
1417
+ let directory = dirname(moduleFile);
1418
+ for (let depth = 0; depth < 4; depth++) {
1419
+ try {
1420
+ const parsed = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
1421
+ if (parsed.name === "@earendil-works/pi-ai" && typeof parsed.version === "string") return parsed.version;
1422
+ } catch {}
1423
+ const parent = dirname(directory);
1424
+ if (parent === directory) return void 0;
1425
+ directory = parent;
1426
+ }
1427
+ }
1428
+ /**
1429
+ * The generation a manifest version belongs to. Within the peer range
1430
+ * (<0.88.0) the split is exactly `0.87+` versus earlier; an unreadable
1431
+ * version conservatively reports `legacy` (and doctor shows it as
1432
+ * unreadable, so the guess is visible rather than silent).
1433
+ */
1434
+ function generationOf(version) {
1435
+ if (version === void 0) return "legacy";
1436
+ const match = /^0\.(\d+)\./u.exec(version);
1437
+ if (match === null) return "legacy";
1438
+ return Number(match[1]) >= 87 ? "modern" : "legacy";
1439
+ }
1440
+ //#endregion
1441
+ //#region src/credentials.ts
1442
+ /**
1443
+ * Qoder 凭据解析:PAT(Personal Access Token)来源与账号 id 派生。
1444
+ *
1445
+ * PAT 优先级(每个区域独立):
1446
+ * 1. 设置卡片里用户显式填写的 PAT(`config.regions.<region>.pat`);
1447
+ * 2. 环境变量(`QODER_PERSONAL_ACCESS_TOKEN` / `QODERCN_PERSONAL_ACCESS_TOKEN`);
1448
+ * 3. 插件自有保险库(`src/accounts.ts`)里登记的其余 PAT。
1449
+ *
1450
+ * 说明:Qoder CLI 的交互式登录凭据(`~/.qoder/qoder-cli-credentials.json`
1451
+ * 等)是 AES-256-GCM 加密存储的,密钥派生在 qodercli 二进制内,插件不做
1452
+ * 二进制逆向;本插件通过 PAT 接入。
1453
+ *
1454
+ * @module dsh-connect-qoder/credentials
1455
+ */
1456
+ /** 各区域对应的 PAT 环境变量名。 */
1457
+ const QODER_PAT_ENV_VARS = {
1458
+ global: "QODER_PERSONAL_ACCESS_TOKEN",
1459
+ cn: "QODERCN_PERSONAL_ACCESS_TOKEN"
1460
+ };
1461
+ /** 从环境变量取该区域的 PAT。 */
1462
+ function patFromEnv(region, env = process.env) {
1463
+ const value = env[QODER_PAT_ENV_VARS[region]]?.trim();
1464
+ return value ? value : void 0;
1465
+ }
1466
+ /**
1467
+ * 一个 PAT 的稳定账号 id:`pat-` + sha256 前 16 位十六进制。
1468
+ *
1469
+ * 为什么不用上游的 uid:jobToken 交换能给出 uid,但那要求添加/导入时
1470
+ * 必须联机,而且 vault 里还没验证过的记录会没有 id 可用。从 PAT 本身派生
1471
+ * 的 id 离线可得、永不变化,且单向散列不把 token 材料泄进设置文件、
1472
+ * 日志或浏览器快照(id 会出现在卡片的 DOM 与 URL 参数里)。
1473
+ */
1474
+ function qoderAccountId(pat) {
1475
+ return `pat-${createHash("sha256").update(pat, "utf8").digest("hex").slice(0, 16)}`;
1476
+ }
1477
+ /** 判断一个 id 是否由本插件的 PAT 派生规则产生。 */
1478
+ function isQoderAccountId(id) {
1479
+ return /^pat-[0-9a-f]{16}$/.test(id);
1480
+ }
1481
+ /** 展示用的 PAT 掩码:只留头尾,绝不出现完整 token。 */
1482
+ function maskPat(pat) {
1483
+ if (pat.length <= 10) return "…";
1484
+ return `${pat.slice(0, 5)}…${pat.slice(-4)}`;
1485
+ }
1486
+ //#endregion
1487
+ //#region src/accounts.ts
1488
+ /**
1489
+ * Qoder 账号库:按区域隔离的 PAT 保险库 + 账号解析。
1490
+ *
1491
+ * 参考:dsh-connect-workbuddy(MIT,Copyright (c) 2026 LaoDing)的
1492
+ * `src/auth.ts` —— 插件自有凭据文件的约定(`$DSH_HOME` 下按区域分文件、
1493
+ * `withFileLock` + `writeFileAtomic`、`mode: 0o600`)、「账号列表与选中
1494
+ * 分离,选中存设置」「读取永不抛异常」的语义沿用其做法。
1495
+ * 改动:WorkBuddy 的账号来自桌面端登录文件 + OAuth 扫码;Qoder 的账号
1496
+ * 就是 PAT 本身,来源三种——设置卡片的 PAT 字段、环境变量、以及本模块
1497
+ * 维护的插件自有保险库(卡片「添加账号」与转账导入写入)。账号 id 由
1498
+ * PAT 单向派生(见 `credentials.ts` 的 `qoderAccountId`),不依赖联机
1499
+ * 验证,也不把 token 材料泄进 id。
1500
+ *
1501
+ * 本模块不碰网络:PAT 的有效性验证(jobToken 交换)在 Host 路由层完成,
1502
+ * 这里只负责持久化与解析。
1503
+ *
1504
+ * @module dsh-connect-qoder/accounts
1505
+ */
1506
+ /** On-disk format version; readers reject anything else. */
1507
+ const VAULT_FORMAT_VERSION = 1;
1508
+ /** Filename prefix for one region's account vault. */
1509
+ const VAULT_PREFIX = ".qoder-accounts";
1510
+ /** Whether a parsed value looks like a stored account entry. */
1511
+ function isStoredAccount(value) {
1512
+ if (typeof value !== "object" || value === null) return false;
1513
+ const candidate = value;
1514
+ return typeof candidate["id"] === "string" && candidate["id"] !== "" && typeof candidate["pat"] === "string" && candidate["pat"] !== "" && typeof candidate["accountName"] === "string" && typeof candidate["addedAtMs"] === "number";
1515
+ }
1516
+ /** One region's vault path. */
1517
+ function qoderAccountsPath(region) {
1518
+ return join(resolveDshHome(), `${VAULT_PREFIX}.${region}.json`);
1519
+ }
1520
+ /**
1521
+ * One region's account store.
1522
+ *
1523
+ * Reads never throw: a missing, unreadable, malformed, or version-mismatched
1524
+ * vault all mean "no stored accounts", which is a valid state — the pool and
1525
+ * the card simply show an empty account list. Writes go through the file lock
1526
+ * and an atomic replace, same as the credential copies in the sibling plugin.
1527
+ */
1528
+ var QoderAccountStore = class {
1529
+ region;
1530
+ constructor(region) {
1531
+ this.region = region;
1532
+ }
1533
+ /** The vault entries, in insertion order. */
1534
+ async vaultAccounts() {
1535
+ let raw;
1536
+ try {
1537
+ raw = await readFile(qoderAccountsPath(this.region), "utf8");
1538
+ } catch {
1539
+ return [];
1540
+ }
1541
+ let parsed;
1542
+ try {
1543
+ parsed = JSON.parse(raw);
1544
+ } catch {
1545
+ return [];
1546
+ }
1547
+ if (typeof parsed !== "object" || parsed === null) return [];
1548
+ const document = parsed;
1549
+ if (document.version !== VAULT_FORMAT_VERSION) return [];
1550
+ if (!Array.isArray(document.accounts)) return [];
1551
+ return document.accounts.filter(isStoredAccount);
1552
+ }
1553
+ /**
1554
+ * Every account this region can bill, merged from all three sources.
1555
+ *
1556
+ * Order is the resolution priority: the settings PAT first, then the
1557
+ * environment PAT, then the vault. A PAT appearing in two sources is listed
1558
+ * ONCE, under the higher-priority source — the id is derived from the PAT
1559
+ * itself, so duplicates are exact.
1560
+ */
1561
+ async accounts(sources) {
1562
+ const out = [];
1563
+ const seen = /* @__PURE__ */ new Set();
1564
+ const settingsPat = sources.settingsPat?.trim();
1565
+ if (settingsPat) {
1566
+ const id = qoderAccountId(settingsPat);
1567
+ seen.add(id);
1568
+ out.push({
1569
+ id,
1570
+ accountName: "",
1571
+ source: "settings"
1572
+ });
1573
+ }
1574
+ const envPat = sources.envPat?.trim();
1575
+ if (envPat) {
1576
+ const id = qoderAccountId(envPat);
1577
+ if (!seen.has(id)) {
1578
+ seen.add(id);
1579
+ out.push({
1580
+ id,
1581
+ accountName: "",
1582
+ source: "env"
1583
+ });
1584
+ }
1585
+ }
1586
+ for (const stored of await this.vaultAccounts()) {
1587
+ if (seen.has(stored.id)) continue;
1588
+ seen.add(stored.id);
1589
+ out.push({
1590
+ id: stored.id,
1591
+ accountName: stored.accountName,
1592
+ source: "vault"
1593
+ });
1594
+ }
1595
+ return out;
1596
+ }
1597
+ /**
1598
+ * The PAT for one account id, from whichever source holds it.
1599
+ *
1600
+ * Resolving by id — rather than touching the region's selection — is what
1601
+ * lets a pool batch address every account while the user's chosen account
1602
+ * keeps serving traffic.
1603
+ */
1604
+ async credentialFor(accountId, sources) {
1605
+ const settingsPat = sources.settingsPat?.trim();
1606
+ if (settingsPat && qoderAccountId(settingsPat) === accountId) return {
1607
+ id: accountId,
1608
+ accountName: "",
1609
+ source: "settings",
1610
+ pat: settingsPat
1611
+ };
1612
+ const envPat = sources.envPat?.trim();
1613
+ if (envPat && qoderAccountId(envPat) === accountId) return {
1614
+ id: accountId,
1615
+ accountName: "",
1616
+ source: "env",
1617
+ pat: envPat
1618
+ };
1619
+ const stored = (await this.vaultAccounts()).find((account) => account.id === accountId);
1620
+ return stored === void 0 ? void 0 : {
1621
+ id: stored.id,
1622
+ accountName: stored.accountName,
1623
+ source: "vault",
1624
+ pat: stored.pat
1625
+ };
1626
+ }
1627
+ /**
1628
+ * The credential that serves traffic right now.
1629
+ *
1630
+ * `selectedId` is the user's explicit per-region choice (`''`/undefined =
1631
+ * automatic). Automatic resolution follows source priority: settings PAT,
1632
+ * then env PAT, then the first vault entry. An explicit id that resolves to
1633
+ * nothing falls back to automatic rather than stranding the region — the
1634
+ * card reports `selectionLost` so the user sees why.
1635
+ */
1636
+ async resolve(sources, selectedId) {
1637
+ if (selectedId !== void 0 && selectedId !== "") {
1638
+ const chosen = await this.credentialFor(selectedId, sources);
1639
+ if (chosen !== void 0) return chosen;
1640
+ }
1641
+ const settingsPat = sources.settingsPat?.trim();
1642
+ if (settingsPat) return {
1643
+ id: qoderAccountId(settingsPat),
1644
+ accountName: "",
1645
+ source: "settings",
1646
+ pat: settingsPat
1647
+ };
1648
+ const envPat = sources.envPat?.trim();
1649
+ if (envPat) return {
1650
+ id: qoderAccountId(envPat),
1651
+ accountName: "",
1652
+ source: "env",
1653
+ pat: envPat
1654
+ };
1655
+ const first = (await this.vaultAccounts())[0];
1656
+ return first === void 0 ? void 0 : {
1657
+ id: first.id,
1658
+ accountName: first.accountName,
1659
+ source: "vault",
1660
+ pat: first.pat
1661
+ };
1662
+ }
1663
+ /**
1664
+ * Add (or refresh) one vault entry. Keyed by the PAT-derived id: re-adding
1665
+ * the same token updates its name in place rather than duplicating it.
1666
+ */
1667
+ async add(pat, accountName) {
1668
+ const entry = {
1669
+ id: qoderAccountId(pat),
1670
+ accountName,
1671
+ pat,
1672
+ addedAtMs: Date.now()
1673
+ };
1674
+ await this.mutate((accounts) => {
1675
+ const index = accounts.findIndex((account) => account.id === entry.id);
1676
+ if (index === -1) return [...accounts, entry];
1677
+ const next = [...accounts];
1678
+ next[index] = {
1679
+ ...next[index],
1680
+ accountName: entry.accountName,
1681
+ pat: entry.pat
1682
+ };
1683
+ return next;
1684
+ });
1685
+ return entry;
1686
+ }
1687
+ /**
1688
+ * Remove one vault entry. Only vault entries are removable — the settings
1689
+ * and env PATs are managed outside the plugin. Returns whether anything
1690
+ * was actually removed.
1691
+ */
1692
+ async remove(accountId) {
1693
+ let removed = false;
1694
+ await this.mutate((accounts) => {
1695
+ const next = accounts.filter((account) => account.id !== accountId);
1696
+ removed = next.length !== accounts.length;
1697
+ return next;
1698
+ });
1699
+ return removed;
1700
+ }
1701
+ /** Backfill a display name learned later (from a jobToken exchange). */
1702
+ async rename(accountId, accountName) {
1703
+ if (accountName === "") return;
1704
+ await this.mutate((accounts) => accounts.map((account) => account.id === accountId && account.accountName !== accountName ? {
1705
+ ...account,
1706
+ accountName
1707
+ } : account));
1708
+ }
1709
+ /** Empty the vault. Used by the CLI's `logout`. */
1710
+ async clear() {
1711
+ await this.mutate(() => []);
1712
+ }
1713
+ /** One locked read-modify-write of the vault document. */
1714
+ async mutate(edit) {
1715
+ const path = qoderAccountsPath(this.region);
1716
+ await withFileLock(path, async () => {
1717
+ const accounts = edit(await this.vaultAccounts());
1718
+ await writeFileAtomic(path, `${JSON.stringify({
1719
+ version: VAULT_FORMAT_VERSION,
1720
+ accounts
1721
+ }, null, 2)}\n`, {
1722
+ mode: 384,
1723
+ dirMode: 448
1724
+ });
1725
+ });
1726
+ }
1727
+ };
1728
+ //#endregion
1729
+ //#region src/version.ts
1730
+ /** The npm package version this build was produced from. */
1731
+ const QODER_CONNECT_VERSION = "0.4.0";
1732
+ //#endregion
1733
+ //#region src/account-pool-store.ts
1734
+ /**
1735
+ * The account pool's RUNTIME store: measured facts, on disk, outside settings.
1736
+ *
1737
+ * 参考:dingminhua/dsh-connect-workbuddy(MIT)的 `src/account-pool-store.ts`。
1738
+ * 改动:文件名前缀换成本插件的 `.qoder-pool`;其余逐行沿用 —— 同一套
1739
+ * `$DSH_HOME` 落盘约定(`withFileLock` + `writeFileAtomic`、`0o600`)、
1740
+ * 「观测数据与用户设置分开落盘」(否则卡片草稿保存会用几分钟前的草稿覆盖掉
1741
+ * 探测结果)、以及「读写从不抛异常给调用方」。
1742
+ *
1743
+ * @module dsh-connect-qoder/account-pool-store
1744
+ */
1745
+ /** On-disk format version; readers reject anything else. */
1746
+ const STORE_FORMAT_VERSION = 1;
1747
+ /** Filename prefix for one region's measured pool facts. */
1748
+ const POOL_STORE_PREFIX = ".qoder-pool";
1749
+ /** Whether a parsed value looks like a stored probe. */
1750
+ function isProbe(value) {
1751
+ if (typeof value !== "object" || value === null) return false;
1752
+ const candidate = value;
1753
+ return typeof candidate["outcome"] === "string" && typeof candidate["atMs"] === "number" && (candidate["retryAtMs"] === void 0 || typeof candidate["retryAtMs"] === "number") && (candidate["message"] === void 0 || typeof candidate["message"] === "string") && (candidate["source"] === void 0 || typeof candidate["source"] === "string");
1754
+ }
1755
+ /** One region's path for measured pool facts. */
1756
+ function qoderPoolStorePath(region) {
1757
+ return join(resolveDshHome(), `${POOL_STORE_PREFIX}.${region}.json`);
1758
+ }
1759
+ /**
1760
+ * Read one region's measured facts.
1761
+ *
1762
+ * Returns an empty map for a missing, unreadable, malformed, or
1763
+ * version-mismatched file: all four mean "nothing measured yet".
1764
+ */
1765
+ async function readPoolProbes(region) {
1766
+ let raw;
1767
+ try {
1768
+ raw = await readFile(qoderPoolStorePath(region), "utf8");
1769
+ } catch {
1770
+ return {};
1771
+ }
1772
+ let parsed;
1773
+ try {
1774
+ parsed = JSON.parse(raw);
1775
+ } catch {
1776
+ return {};
1777
+ }
1778
+ if (typeof parsed !== "object" || parsed === null) return {};
1779
+ const document = parsed;
1780
+ if (document.version !== STORE_FORMAT_VERSION) return {};
1781
+ if (typeof document.probes !== "object" || document.probes === null) return {};
1782
+ const probes = {};
1783
+ for (const [accountId, probe] of Object.entries(document.probes)) {
1784
+ if (accountId === "" || !isProbe(probe)) continue;
1785
+ probes[accountId] = {
1786
+ outcome: probe.outcome,
1787
+ atMs: probe.atMs,
1788
+ ...knownProbeSource(probe.source) === void 0 ? {} : { source: knownProbeSource(probe.source) },
1789
+ ...probe.retryAtMs === void 0 ? {} : { retryAtMs: probe.retryAtMs },
1790
+ ...probe.message === void 0 ? {} : { message: probe.message }
1791
+ };
1792
+ }
1793
+ return probes;
1794
+ }
1795
+ /** A stored source string reduced to a source this build understands. */
1796
+ function knownProbeSource(value) {
1797
+ return value === "test-batch" || value === "live-request" ? value : void 0;
1798
+ }
1799
+ /**
1800
+ * Merge measurements into one region's store and write it atomically.
1801
+ *
1802
+ * A MERGE rather than a replace, because batches measure one account at a time
1803
+ * and a partial failure must not erase what earlier runs learned. Never
1804
+ * throws: a failed write loses only the newest measurements.
1805
+ */
1806
+ async function writePoolProbes(region, updates) {
1807
+ if (Object.keys(updates).length === 0) return;
1808
+ const path = qoderPoolStorePath(region);
1809
+ try {
1810
+ await withFileLock(path, async () => {
1811
+ const existing = await readPoolProbes(region);
1812
+ const document = {
1813
+ version: STORE_FORMAT_VERSION,
1814
+ probes: {
1815
+ ...existing,
1816
+ ...updates
1817
+ }
1818
+ };
1819
+ await writeFileAtomic(path, `${JSON.stringify(document, null, 2)}\n`, {
1820
+ mode: 384,
1821
+ dirMode: 448
1822
+ });
1823
+ });
1824
+ } catch {}
1825
+ }
1826
+ //#endregion
1827
+ //#region src/host-heartbeat.ts
1828
+ /**
1829
+ * Host-side heartbeat: a small JSON file written under `$DSH_HOME` once the
1830
+ * `qoder` provider is registered. The status CLI reads it to report
1831
+ * whether the host bundle is alive, independent of the browser card.
1832
+ *
1833
+ * 参考:corrinehu/dsh-workbuddy-connect(MIT,Copyright (c) 2026 Corrine Hu)
1834
+ * — 该机制由其设计:浏览器端无法写文件,其健康只能靠 console.error 上报,
1835
+ * 因此由宿主写心跳文件,缺失即代表宿主从未启动;崩溃后的陈旧心跳
1836
+ * 通过 PID 存活检查识别。
1837
+ * 参考:dsh-connect-workbuddy(MIT,Copyright (c) 2026 LaoDing)的同名模块
1838
+ * 改动:纯改名移植(WorkBuddy → Qoder,包名/版本常量/心跳文件名),机制、
1839
+ * `windowsHide`、PID 与 start-time 逻辑原样沿用。
1840
+ *
1841
+ * @module dsh-connect-qoder/host-heartbeat
1842
+ */
1843
+ /** Basename of the host heartbeat file inside the Harness home. */
1844
+ const QODER_HOST_HEARTBEAT_FILENAME = ".qoder-host-heartbeat.json";
1845
+ /** Current on-disk heartbeat format; readers reject others. */
1846
+ const HEARTBEAT_FORMAT_VERSION = 1;
1847
+ /** Absolute path of the host heartbeat file. */
1848
+ function qoderHostHeartbeatPath() {
1849
+ return join(resolveDshHome(), QODER_HOST_HEARTBEAT_FILENAME);
1850
+ }
1851
+ /**
1852
+ * Spawn options every external probe in this module is run with.
1853
+ *
1854
+ * `windowsHide` is REQUIRED here, not cosmetic. Windows hands a console-program
1855
+ * child a brand-new console window whenever its PARENT has none — and the
1856
+ * parent frequently has none: the DSH Desktop host is an Electron GUI process
1857
+ * (`MainWindowHandle = 0`, verified on the live host), as are Task Scheduler
1858
+ * jobs and CI services. That window is created VISIBLE, so every start-time
1859
+ * probe would flash a black box on the user's screen.
1860
+ *
1861
+ * Evidence (real Windows 11, parent created console-less through the WMI
1862
+ * service — the same condition as the Electron host; the child reports the
1863
+ * console it actually owns):
1864
+ *
1865
+ * - without `windowsHide`: `NEW-console hwnd=1573500 visible=True`
1866
+ * - with `windowsHide: true`: `no-console`
1867
+ *
1868
+ * Invoked from a terminal the parent HAS a console and the child simply
1869
+ * inherits it (no new window), which is why this never showed up in local
1870
+ * runs — the same blind spot `docs/WINDOWS.md` §0 describes. The sibling call
1871
+ * site `src/at-rest.ts` (the at-rest key fetch) already sets this option; this
1872
+ * module did not, and that inconsistency is what the test pins.
1873
+ *
1874
+ * On POSIX the option is ignored.
1875
+ */
1876
+ const PROCESS_PROBE_OPTIONS = {
1877
+ encoding: "utf8",
1878
+ windowsHide: true
1879
+ };
1880
+ /**
1881
+ * The command one start-time probe runs, per platform.
1882
+ *
1883
+ * Split out as a value so the spawn can be asserted WITHOUT a Windows machine:
1884
+ * `tests/host-heartbeat.spec.ts` checks the platform branch and the options
1885
+ * object directly, which is the only way a Windows-only spawn defect here can
1886
+ * be caught on the macOS development machine (§0: not "unreproduced" but
1887
+ * "impossible to reproduce").
1888
+ *
1889
+ * `platform` is injectable for the same reason, in the same spirit as
1890
+ * `defaultDesktopAuthDirs()` and `qoderAppExecutableCandidates()`.
1891
+ */
1892
+ function processStartProbe(pid, platform = process.platform) {
1893
+ if (platform === "win32") return {
1894
+ file: "powershell",
1895
+ args: [
1896
+ "-NoProfile",
1897
+ "-NonInteractive",
1898
+ "-Command",
1899
+ `(Get-Process -Id ${pid} -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToString('o')`
1900
+ ]
1901
+ };
1902
+ return {
1903
+ file: "ps",
1904
+ args: [
1905
+ "-o",
1906
+ "lstart=",
1907
+ "-p",
1908
+ String(pid)
1909
+ ]
1910
+ };
1911
+ }
1912
+ /**
1913
+ * Process start time in epoch milliseconds; undefined when unavailable.
1914
+ *
1915
+ * POSIX reads `ps -o lstart=`; Windows has no such command, so the creation
1916
+ * time is taken from PowerShell's `Get-Process` StartTime, emitted as UTC ISO
1917
+ * 8601 so `Date.parse` understands it without locale assumptions. Absent or
1918
+ * unqueryable processes (other users' processes) degrade to undefined.
1919
+ */
1920
+ function processStartTimeMs(pid) {
1921
+ try {
1922
+ const probe = processStartProbe(pid);
1923
+ const output = execFileSync(probe.file, probe.args, PROCESS_PROBE_OPTIONS);
1924
+ const parsed = Date.parse(output.trim());
1925
+ return Number.isFinite(parsed) ? parsed : void 0;
1926
+ } catch {
1927
+ return;
1928
+ }
1929
+ }
1930
+ /**
1931
+ * Whether the recorded host process still matches the heartbeat's PID.
1932
+ *
1933
+ * A PID can be reused after a crash, so the recorded start time is compared
1934
+ * against the live process: a different start time means a different process.
1935
+ */
1936
+ function isHeartbeatProcessAlive(heartbeat) {
1937
+ if (!Number.isInteger(heartbeat.pid) || heartbeat.pid <= 0) return false;
1938
+ try {
1939
+ process.kill(heartbeat.pid, 0);
1940
+ } catch {
1941
+ return false;
1942
+ }
1943
+ const startedAt = processStartTimeMs(heartbeat.pid);
1944
+ if (startedAt === void 0) return true;
1945
+ return Math.abs(startedAt - heartbeat.registeredAt) < 6e4;
1946
+ }
1947
+ /** Read the heartbeat; absent or unparsable files report undefined. */
1948
+ async function readHostHeartbeat() {
1949
+ try {
1950
+ const parsed = JSON.parse(await readFile(qoderHostHeartbeatPath(), "utf8"));
1951
+ if (typeof parsed !== "object" || parsed === null) return void 0;
1952
+ const document = parsed;
1953
+ if (document["version"] !== HEARTBEAT_FORMAT_VERSION) return void 0;
1954
+ if (document["package"] !== "dsh-connect-qoder") return void 0;
1955
+ const pid = document["pid"];
1956
+ const registeredAt = document["registeredAt"];
1957
+ if (typeof pid !== "number" || typeof registeredAt !== "number") return void 0;
1958
+ return {
1959
+ version: HEARTBEAT_FORMAT_VERSION,
1960
+ package: "dsh-connect-qoder",
1961
+ pluginVersion: typeof document["pluginVersion"] === "string" ? document["pluginVersion"] : QODER_CONNECT_VERSION,
1962
+ registeredAt,
1963
+ pid
1964
+ };
1965
+ } catch {
1966
+ return;
1967
+ }
1968
+ }
1969
+ /** Write the heartbeat for the current process. */
1970
+ async function writeHostHeartbeat() {
1971
+ const heartbeat = {
1972
+ version: HEARTBEAT_FORMAT_VERSION,
1973
+ package: "dsh-connect-qoder",
1974
+ pluginVersion: QODER_CONNECT_VERSION,
1975
+ registeredAt: Date.now(),
1976
+ pid: process.pid
1977
+ };
1978
+ await writeFile(qoderHostHeartbeatPath(), `${JSON.stringify(heartbeat, null, 2)}\n`, { mode: 384 });
1979
+ }
1980
+ /** Remove the heartbeat; called when the plugin is disposed. */
1981
+ async function clearHostHeartbeat() {
1982
+ await rm(qoderHostHeartbeatPath(), { force: true });
1983
+ }
1984
+ //#endregion
1985
+ export { deriveFingerprint as S, piAiRuntimeInfo as _, writeHostHeartbeat as a, QoderUpstreamError as b, writePoolProbes as c, qoderAccountsPath as d, QODER_PAT_ENV_VARS as f, qoderAccountId as g, patFromEnv as h, readHostHeartbeat as i, QODER_CONNECT_VERSION as l, maskPat as m, isHeartbeatProcessAlive as n, qoderPoolStorePath as o, isQoderAccountId as p, qoderHostHeartbeatPath as r, readPoolProbes as s, clearHostHeartbeat as t, QoderAccountStore as u, QODER_REGIONS as v, isQoderUpstreamError as x, QoderUpstreamClient as y };