dsh-workbuddy-connect 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +58 -0
- package/README.md +64 -0
- package/cordis.patch.yml +6 -0
- package/lib/bin.d.ts +6 -0
- package/lib/bin.js +167 -0
- package/lib/client.js +439 -0
- package/lib/host-heartbeat-DehQgrb9.js +830 -0
- package/lib/index.d.ts +332 -0
- package/lib/index.js +556 -0
- package/package.json +102 -0
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
import { readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
|
|
5
|
+
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
//#region src/auth.ts
|
|
8
|
+
/**
|
|
9
|
+
* WorkBuddy credential resolution. The primary source is the WorkBuddy
|
|
10
|
+
* desktop app's own auth file, read-only; a plugin-owned copy under
|
|
11
|
+
* `$DSH_HOME` holds token refreshes so the desktop file is never written.
|
|
12
|
+
* The effective credential is whichever of the two expires later, so a
|
|
13
|
+
* refresh by either side wins.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-workbuddy-connect/auth
|
|
16
|
+
*/
|
|
17
|
+
/** Basename of the plugin-owned credential copy inside the Harness home. */
|
|
18
|
+
const WORKBUDDY_AUTH_FILENAME = ".workbuddy-auth.json";
|
|
19
|
+
/** Env variable that overrides the desktop auth-file location. */
|
|
20
|
+
const WORKBUDDY_AUTH_FILE_ENV = "WORKBUDDY_AUTH_FILE";
|
|
21
|
+
/** Current on-disk format of the plugin-owned copy; readers reject others. */
|
|
22
|
+
const OWN_FORMAT_VERSION = 1;
|
|
23
|
+
/** Plugin-owned copy path inside the Harness home. */
|
|
24
|
+
function workbuddyOwnAuthPath() {
|
|
25
|
+
return join(resolveDshHome(), WORKBUDDY_AUTH_FILENAME);
|
|
26
|
+
}
|
|
27
|
+
/** Platform default for the WorkBuddy desktop app's auth file. */
|
|
28
|
+
function defaultDesktopAuthPath() {
|
|
29
|
+
const home = homedir();
|
|
30
|
+
if (process.platform === "darwin") return join(home, "Library", "Application Support", "CodeBuddyExtension", "Data", "Public", "auth", "workbuddy-desktop.info");
|
|
31
|
+
if (process.platform === "win32") return join(home, "AppData", "Roaming", "CodeBuddyExtension", "Data", "Public", "auth", "workbuddy-desktop.info");
|
|
32
|
+
if (process.platform === "linux") return join(home, ".config", "CodeBuddyExtension", "Data", "Public", "auth", "workbuddy-desktop.info");
|
|
33
|
+
}
|
|
34
|
+
/** Normalize an expiry that may arrive in seconds or milliseconds. */
|
|
35
|
+
function expiryToMs(value) {
|
|
36
|
+
if (value <= 0) return 0;
|
|
37
|
+
return value > 0xe8d4a51000 ? value : value * 1e3;
|
|
38
|
+
}
|
|
39
|
+
function optionalString(value) {
|
|
40
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Parse a WorkBuddy auth document in either on-disk shape: the plugin OAuth
|
|
44
|
+
* nested form `{"auth":{...},"account":{...}}` and the flat panel form.
|
|
45
|
+
* Returns undefined when the document carries no access token.
|
|
46
|
+
*/
|
|
47
|
+
function parseWorkBuddyAuth(text) {
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(text);
|
|
51
|
+
} catch {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
55
|
+
const document = parsed;
|
|
56
|
+
let auth;
|
|
57
|
+
let identity;
|
|
58
|
+
if (typeof document["auth"] === "object" && document["auth"] !== null) {
|
|
59
|
+
auth = document["auth"];
|
|
60
|
+
identity = typeof document["account"] === "object" && document["account"] !== null ? document["account"] : {};
|
|
61
|
+
} else {
|
|
62
|
+
auth = document;
|
|
63
|
+
identity = document;
|
|
64
|
+
}
|
|
65
|
+
const accessToken = typeof auth["accessToken"] === "string" ? auth["accessToken"] : "";
|
|
66
|
+
if (accessToken === "") return void 0;
|
|
67
|
+
const expiresAtMs = typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0;
|
|
68
|
+
const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
|
|
69
|
+
const enterpriseId = optionalString(identity["enterpriseId"]);
|
|
70
|
+
const nickname = optionalString(identity["nickname"]);
|
|
71
|
+
return {
|
|
72
|
+
accessToken,
|
|
73
|
+
refreshToken: typeof auth["refreshToken"] === "string" ? auth["refreshToken"] : "",
|
|
74
|
+
expiresAtMs,
|
|
75
|
+
...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
|
|
76
|
+
domain: optionalString(auth["domain"]) ?? "",
|
|
77
|
+
uid: optionalString(identity["uid"]) ?? "",
|
|
78
|
+
...enterpriseId === void 0 ? {} : { enterpriseId },
|
|
79
|
+
...nickname === void 0 ? {} : { nickname },
|
|
80
|
+
source: "desktop"
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Serialize the plugin-owned copy. */
|
|
84
|
+
function ownDocument(credential) {
|
|
85
|
+
return {
|
|
86
|
+
version: OWN_FORMAT_VERSION,
|
|
87
|
+
credential
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** Parse the plugin-owned copy; other versions and shapes are rejected. */
|
|
91
|
+
function parseOwnDocument(text) {
|
|
92
|
+
let parsed;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(text);
|
|
95
|
+
} catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
99
|
+
const document = parsed;
|
|
100
|
+
if (document["version"] !== OWN_FORMAT_VERSION) return void 0;
|
|
101
|
+
if (typeof document["credential"] !== "object" || document["credential"] === null) return void 0;
|
|
102
|
+
const credential = parseWorkBuddyAuth(JSON.stringify({ auth: document["credential"] }));
|
|
103
|
+
if (credential === void 0) return void 0;
|
|
104
|
+
return {
|
|
105
|
+
...credential,
|
|
106
|
+
source: "dsh"
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** Whether a filesystem error reports an absent path. */
|
|
110
|
+
function isENOENT(error) {
|
|
111
|
+
return error?.code === "ENOENT";
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Read-only credential store with demand-driven refresh.
|
|
115
|
+
*
|
|
116
|
+
* Refresh policy: refresh only when the access token is inside the margin
|
|
117
|
+
* (or already expired), keep the refreshed credential in the plugin-owned
|
|
118
|
+
* copy, and never write the desktop app's file. A failed refresh still
|
|
119
|
+
* returns a not-yet-expired token so an unreachable refresh endpoint does
|
|
120
|
+
* not take down a working session.
|
|
121
|
+
*/
|
|
122
|
+
var WorkBuddyCredentialStore = class {
|
|
123
|
+
refresh;
|
|
124
|
+
refreshMarginMs;
|
|
125
|
+
ownPath;
|
|
126
|
+
desktopPathOverride;
|
|
127
|
+
inflight;
|
|
128
|
+
constructor(options) {
|
|
129
|
+
this.refresh = options.refresh;
|
|
130
|
+
this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
|
|
131
|
+
this.ownPath = options.ownPath ?? workbuddyOwnAuthPath();
|
|
132
|
+
this.desktopPathOverride = options.desktopPath;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Configuration precedence for the desktop file: the plugin's configured
|
|
136
|
+
* path, then the environment variable, then the platform default.
|
|
137
|
+
*/
|
|
138
|
+
resolveDesktopPath() {
|
|
139
|
+
const fromEnv = process.env[WORKBUDDY_AUTH_FILE_ENV];
|
|
140
|
+
return this.desktopPathOverride ?? (fromEnv !== void 0 && fromEnv.trim() !== "" ? fromEnv : void 0) ?? defaultDesktopAuthPath();
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Repoint the desktop file; a settings change applies on the next read.
|
|
144
|
+
*/
|
|
145
|
+
setDesktopPath(path) {
|
|
146
|
+
this.desktopPathOverride = path;
|
|
147
|
+
}
|
|
148
|
+
/** The resolved desktop auth-file path, for diagnostics. */
|
|
149
|
+
desktopAuthPath() {
|
|
150
|
+
return this.resolveDesktopPath();
|
|
151
|
+
}
|
|
152
|
+
/** The plugin-owned copy path, for diagnostics. */
|
|
153
|
+
ownAuthPath() {
|
|
154
|
+
return this.ownPath;
|
|
155
|
+
}
|
|
156
|
+
/** Read the freshest stored credential without refreshing anything. */
|
|
157
|
+
async current() {
|
|
158
|
+
const [desktop, own] = await Promise.all([this.readDesktop(), this.readOwn()]);
|
|
159
|
+
if (desktop === void 0) return own;
|
|
160
|
+
if (own === void 0) return desktop;
|
|
161
|
+
return own.expiresAtMs > desktop.expiresAtMs ? own : desktop;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The credential to send upstream: {@link current}, refreshed on demand.
|
|
165
|
+
* Single-flight, so parallel requests share one refresh.
|
|
166
|
+
*/
|
|
167
|
+
async resolve() {
|
|
168
|
+
const credential = await this.current();
|
|
169
|
+
if (credential === void 0) {
|
|
170
|
+
const desktop = this.resolveDesktopPath() ?? "(no desktop path on this platform)";
|
|
171
|
+
throw new Error(`workbuddy: no signed-in WorkBuddy account found; sign in once in the WorkBuddy desktop app (expected ${desktop} or WORKBUDDY_AUTH_FILE), or refresh an existing session`);
|
|
172
|
+
}
|
|
173
|
+
if (!this.needsRefresh(credential)) return credential;
|
|
174
|
+
this.inflight ??= this.refreshNow(credential).finally(() => {
|
|
175
|
+
this.inflight = void 0;
|
|
176
|
+
});
|
|
177
|
+
return this.inflight;
|
|
178
|
+
}
|
|
179
|
+
/** Read-only sign-in summary; never refreshes and never throws. */
|
|
180
|
+
async status() {
|
|
181
|
+
try {
|
|
182
|
+
const credential = await this.current();
|
|
183
|
+
if (credential === void 0) return { state: "signed-out" };
|
|
184
|
+
return {
|
|
185
|
+
state: "signed-in",
|
|
186
|
+
expiresAtMs: credential.expiresAtMs,
|
|
187
|
+
...credential.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs: credential.refreshExpiresAtMs },
|
|
188
|
+
...credential.nickname === void 0 ? {} : { nickname: credential.nickname },
|
|
189
|
+
...credential.domain === "" ? {} : { domain: credential.domain },
|
|
190
|
+
source: credential.source
|
|
191
|
+
};
|
|
192
|
+
} catch {
|
|
193
|
+
return { state: "signed-out" };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Remove the plugin-owned copy; the desktop file is untouched. */
|
|
197
|
+
async logout() {
|
|
198
|
+
await rm(this.ownPath, { force: true });
|
|
199
|
+
await rm(`${this.ownPath}.lock`, { force: true });
|
|
200
|
+
}
|
|
201
|
+
needsRefresh(credential) {
|
|
202
|
+
if (credential.expiresAtMs <= 0) return true;
|
|
203
|
+
return Date.now() + this.refreshMarginMs >= credential.expiresAtMs;
|
|
204
|
+
}
|
|
205
|
+
async refreshNow(credential) {
|
|
206
|
+
if (credential.refreshToken === "") {
|
|
207
|
+
if (credential.expiresAtMs > Date.now() + 3e4) return credential;
|
|
208
|
+
throw new Error("workbuddy: access token expired and no refresh token is stored; sign in again in the WorkBuddy desktop app");
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
const outcome = await this.refresh(credential);
|
|
212
|
+
const refreshed = {
|
|
213
|
+
...credential,
|
|
214
|
+
accessToken: outcome.accessToken,
|
|
215
|
+
...outcome.refreshToken === void 0 ? {} : { refreshToken: outcome.refreshToken },
|
|
216
|
+
expiresAtMs: outcome.expiresInSec !== void 0 ? Date.now() + outcome.expiresInSec * 1e3 : credential.expiresAtMs,
|
|
217
|
+
...outcome.domain === void 0 || outcome.domain === "" ? {} : { domain: outcome.domain },
|
|
218
|
+
source: "dsh"
|
|
219
|
+
};
|
|
220
|
+
await this.saveOwn(refreshed);
|
|
221
|
+
return refreshed;
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (credential.expiresAtMs > Date.now() + 3e4) return credential;
|
|
224
|
+
throw new Error(`workbuddy: token refresh failed and the access token is expired (${String(error)}); open the WorkBuddy desktop app once to sign in again`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async saveOwn(credential) {
|
|
228
|
+
await withFileLock(this.ownPath, async () => {
|
|
229
|
+
await writeFileAtomic(this.ownPath, `${JSON.stringify(ownDocument(credential), null, 2)}\n`, {
|
|
230
|
+
mode: 384,
|
|
231
|
+
dirMode: 448
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
async readDesktop() {
|
|
236
|
+
const desktopPath = this.resolveDesktopPath();
|
|
237
|
+
if (desktopPath === void 0) return void 0;
|
|
238
|
+
try {
|
|
239
|
+
return parseWorkBuddyAuth(await readFile(desktopPath, "utf8"));
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (isENOENT(error)) return void 0;
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async readOwn() {
|
|
246
|
+
try {
|
|
247
|
+
return parseOwnDocument(await readFile(this.ownPath, "utf8"));
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (isENOENT(error)) return void 0;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** Whether the desktop file exists and is a regular file; diagnostics only. */
|
|
254
|
+
async desktopFilePresent() {
|
|
255
|
+
const desktopPath = this.resolveDesktopPath();
|
|
256
|
+
if (desktopPath === void 0) return false;
|
|
257
|
+
try {
|
|
258
|
+
return (await stat(desktopPath)).isFile();
|
|
259
|
+
} catch {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
//#endregion
|
|
265
|
+
//#region src/catalog.ts
|
|
266
|
+
/**
|
|
267
|
+
* Static CLI models observed on the CN endpoint (2026-08-17). The upstream
|
|
268
|
+
* refresh replaces this list at startup; it exists so the provider registers
|
|
269
|
+
* with a usable catalog even while the first fetch is in flight or offline.
|
|
270
|
+
*/
|
|
271
|
+
const FALLBACK_WORKBUDDY_MODELS = [
|
|
272
|
+
{
|
|
273
|
+
id: "auto",
|
|
274
|
+
name: "Auto",
|
|
275
|
+
contextWindow: 168e3,
|
|
276
|
+
maxTokens: 32e3
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
id: "hy3",
|
|
280
|
+
name: "Hy3",
|
|
281
|
+
contextWindow: 192e3,
|
|
282
|
+
maxTokens: 64e3
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
id: "glm-5v-turbo",
|
|
286
|
+
name: "GLM-5v-Turbo",
|
|
287
|
+
contextWindow: 2e5,
|
|
288
|
+
maxTokens: 64e3
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
id: "glm-5.2",
|
|
292
|
+
name: "GLM-5.2",
|
|
293
|
+
contextWindow: 1e6,
|
|
294
|
+
maxTokens: 48e3
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
id: "glm-5.1",
|
|
298
|
+
name: "GLM-5.1",
|
|
299
|
+
contextWindow: 2e5,
|
|
300
|
+
maxTokens: 48e3
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
id: "minimax-m3",
|
|
304
|
+
name: "MiniMax-M3",
|
|
305
|
+
contextWindow: 512e3,
|
|
306
|
+
maxTokens: 128e3
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
id: "kimi-k3-1",
|
|
310
|
+
name: "Kimi-K3",
|
|
311
|
+
contextWindow: 1e6,
|
|
312
|
+
maxTokens: 32e3
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
id: "kimi-k2.7",
|
|
316
|
+
name: "Kimi-K2.7-Code",
|
|
317
|
+
contextWindow: 256e3,
|
|
318
|
+
maxTokens: 32e3
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
id: "kimi-k2.6",
|
|
322
|
+
name: "Kimi-K2.6",
|
|
323
|
+
contextWindow: 256e3,
|
|
324
|
+
maxTokens: 32e3
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
id: "deepseek-v4-flash",
|
|
328
|
+
name: "Deepseek-V4-Flash",
|
|
329
|
+
contextWindow: 1e6,
|
|
330
|
+
maxTokens: 5e4
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
id: "deepseek-v4-pro",
|
|
334
|
+
name: "Deepseek-V4-Pro",
|
|
335
|
+
contextWindow: 1e6,
|
|
336
|
+
maxTokens: 5e4
|
|
337
|
+
}
|
|
338
|
+
];
|
|
339
|
+
/** Mutable catalog shared by the shim's `/v1/models` and the adapter. */
|
|
340
|
+
var WorkBuddyCatalog = class {
|
|
341
|
+
models = FALLBACK_WORKBUDDY_MODELS;
|
|
342
|
+
/** Current entries; the fallback list until the upstream answer lands. */
|
|
343
|
+
current() {
|
|
344
|
+
return this.models;
|
|
345
|
+
}
|
|
346
|
+
/** Replace the list; callers invalidate their adapter snapshot after this. */
|
|
347
|
+
set(models) {
|
|
348
|
+
this.models = [...models];
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
//#endregion
|
|
352
|
+
//#region src/upstream.ts
|
|
353
|
+
const CN_CHAT_BASE = "https://copilot.tencent.com";
|
|
354
|
+
const CN_BILLING_BASE = "https://www.codebuddy.cn";
|
|
355
|
+
const GLOBAL_BASE = "https://www.workbuddy.ai";
|
|
356
|
+
const CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
|
|
357
|
+
const JSON_TIMEOUT_MS = 3e4;
|
|
358
|
+
const ERROR_BODY_LIMIT = 4096;
|
|
359
|
+
/** Insufficient-credit markers, ASCII lowercase plus the original Chinese. */
|
|
360
|
+
const HARD_CREDIT_MARKERS = [
|
|
361
|
+
"insufficient credit",
|
|
362
|
+
"no credit",
|
|
363
|
+
"credit exhausted",
|
|
364
|
+
"out of credit",
|
|
365
|
+
"quota exceeded",
|
|
366
|
+
"quota exhaust",
|
|
367
|
+
"payment required",
|
|
368
|
+
"credit not enough",
|
|
369
|
+
"not enough credit",
|
|
370
|
+
"积分不足",
|
|
371
|
+
"额度不足",
|
|
372
|
+
"余额不足",
|
|
373
|
+
"积分用完",
|
|
374
|
+
"额度用尽",
|
|
375
|
+
"没有积分"
|
|
376
|
+
];
|
|
377
|
+
/** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
|
|
378
|
+
const SESSION_DEAD_MARKERS = ["Offline user session not found", "12153"];
|
|
379
|
+
/** Classify an upstream failure from its HTTP status and body excerpt. */
|
|
380
|
+
function classifyUpstreamError(status, body) {
|
|
381
|
+
if (status === 402) return "hard_credit";
|
|
382
|
+
const lower = body.toLowerCase();
|
|
383
|
+
for (const marker of HARD_CREDIT_MARKERS) if (lower.includes(marker.toLowerCase()) || body.includes(marker)) return "hard_credit";
|
|
384
|
+
for (const marker of SESSION_DEAD_MARKERS) if (body.includes(marker)) return "session_dead";
|
|
385
|
+
if (status === 429) return "soft_rate";
|
|
386
|
+
if (status === 404) return "not_found";
|
|
387
|
+
if (status >= 500) return "server";
|
|
388
|
+
if (status >= 400) return "client";
|
|
389
|
+
return "client";
|
|
390
|
+
}
|
|
391
|
+
/** Region for a login domain; an empty domain means CN (matching upstream tooling). */
|
|
392
|
+
function regionOf(domain) {
|
|
393
|
+
const lowered = domain.trim().toLowerCase();
|
|
394
|
+
if (lowered === "workbuddy.ai" || lowered.endsWith(".workbuddy.ai")) return "global";
|
|
395
|
+
return "cn";
|
|
396
|
+
}
|
|
397
|
+
function chatBase(credential) {
|
|
398
|
+
return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_CHAT_BASE;
|
|
399
|
+
}
|
|
400
|
+
function billingBase(credential) {
|
|
401
|
+
return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
|
|
402
|
+
}
|
|
403
|
+
function originReferer(credential) {
|
|
404
|
+
return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
|
|
405
|
+
}
|
|
406
|
+
/** Headers every upstream request shares. */
|
|
407
|
+
function commonHeaders(credential) {
|
|
408
|
+
return {
|
|
409
|
+
"Accept": "application/json, text/plain, */*",
|
|
410
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
411
|
+
"Origin": originReferer(credential),
|
|
412
|
+
"Referer": `${originReferer(credential)}/`,
|
|
413
|
+
"User-Agent": CLIENT_UA
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
/** Chat request headers, including the X-No-* conventions the official CLI uses. */
|
|
417
|
+
function chatHeaders(credential) {
|
|
418
|
+
return {
|
|
419
|
+
...commonHeaders(credential),
|
|
420
|
+
"Content-Type": "application/json",
|
|
421
|
+
...credential.uid === "" ? { "X-No-User-Id": "1" } : { "X-User-Id": credential.uid },
|
|
422
|
+
...credential.enterpriseId === void 0 || credential.enterpriseId === "" ? { "X-No-Enterprise-Id": "1" } : { "X-Enterprise-Id": credential.enterpriseId },
|
|
423
|
+
...credential.domain === "" ? { "X-No-Department-Info": "1" } : { "X-Domain": credential.domain },
|
|
424
|
+
"X-Product": "SaaS"
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
/** Refresh-endpoint headers; X-Refresh-Token appears here and nowhere else. */
|
|
428
|
+
function refreshHeaders(credential) {
|
|
429
|
+
const headers = {
|
|
430
|
+
...commonHeaders(credential),
|
|
431
|
+
"X-Refresh-Token": credential.refreshToken,
|
|
432
|
+
"X-Auth-Refresh-Source": "workbuddy"
|
|
433
|
+
};
|
|
434
|
+
if (credential.enterpriseId !== void 0 && credential.enterpriseId !== "") headers["X-Enterprise-Id"] = credential.enterpriseId;
|
|
435
|
+
return headers;
|
|
436
|
+
}
|
|
437
|
+
/** Billing request headers. */
|
|
438
|
+
function billingHeaders(credential) {
|
|
439
|
+
const headers = {
|
|
440
|
+
"Authorization": `Bearer ${credential.accessToken}`,
|
|
441
|
+
"Accept": "application/json",
|
|
442
|
+
"Content-Type": "application/json"
|
|
443
|
+
};
|
|
444
|
+
if (credential.uid !== "") headers["X-User-Id"] = credential.uid;
|
|
445
|
+
if (credential.enterpriseId !== void 0 && credential.enterpriseId !== "") {
|
|
446
|
+
headers["X-Enterprise-Id"] = credential.enterpriseId;
|
|
447
|
+
headers["X-Tenant-Id"] = credential.enterpriseId;
|
|
448
|
+
}
|
|
449
|
+
if (credential.domain !== "") headers["X-Domain"] = credential.domain;
|
|
450
|
+
return headers;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
|
|
454
|
+
* force `stream: true` (the upstream rejects non-streaming) and flatten
|
|
455
|
+
* `tool_choice` (the upstream's field is a string; object forms return 400).
|
|
456
|
+
*/
|
|
457
|
+
function prepareChatBody(source) {
|
|
458
|
+
let body;
|
|
459
|
+
try {
|
|
460
|
+
body = JSON.parse(source);
|
|
461
|
+
} catch {
|
|
462
|
+
return source;
|
|
463
|
+
}
|
|
464
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return source;
|
|
465
|
+
const obj = body;
|
|
466
|
+
obj["stream"] = true;
|
|
467
|
+
normalizeToolChoice(obj);
|
|
468
|
+
return JSON.stringify(obj);
|
|
469
|
+
}
|
|
470
|
+
/** Rewrite OpenAI `tool_choice` spellings into the upstream's string form. */
|
|
471
|
+
function normalizeToolChoice(obj) {
|
|
472
|
+
const suppress = () => {
|
|
473
|
+
delete obj["tools"];
|
|
474
|
+
delete obj["functions"];
|
|
475
|
+
};
|
|
476
|
+
if (!("tool_choice" in obj)) return;
|
|
477
|
+
const choice = obj["tool_choice"];
|
|
478
|
+
if (typeof choice === "string") {
|
|
479
|
+
if (choice.trim().toLowerCase() === "none") {
|
|
480
|
+
delete obj["tool_choice"];
|
|
481
|
+
suppress();
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (typeof choice === "object" && choice !== null && !Array.isArray(choice)) {
|
|
486
|
+
const wrapped = choice;
|
|
487
|
+
const type = typeof wrapped["type"] === "string" ? wrapped["type"].trim().toLowerCase() : "";
|
|
488
|
+
if (type === "none") {
|
|
489
|
+
delete obj["tool_choice"];
|
|
490
|
+
suppress();
|
|
491
|
+
} else if (type === "auto" || type === "required") obj["tool_choice"] = type;
|
|
492
|
+
else if (type === "function") {
|
|
493
|
+
const fn = typeof wrapped["function"] === "object" && wrapped["function"] !== null ? wrapped["function"] : void 0;
|
|
494
|
+
let name = typeof fn?.["name"] === "string" ? fn["name"] : "";
|
|
495
|
+
if (name === "" && typeof wrapped["name"] === "string") name = wrapped["name"];
|
|
496
|
+
name = name.trim();
|
|
497
|
+
obj["tool_choice"] = name !== "" ? name : "auto";
|
|
498
|
+
} else delete obj["tool_choice"];
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
delete obj["tool_choice"];
|
|
502
|
+
}
|
|
503
|
+
async function readEnvelope(response) {
|
|
504
|
+
const text = await response.text();
|
|
505
|
+
let parsed;
|
|
506
|
+
try {
|
|
507
|
+
parsed = JSON.parse(text);
|
|
508
|
+
} catch {
|
|
509
|
+
throw new Error(`workbuddy upstream returned non-JSON (http ${response.status}): ${text.slice(0, 160)}`);
|
|
510
|
+
}
|
|
511
|
+
if (typeof parsed !== "object" || parsed === null) throw new Error(`workbuddy upstream returned an unexpected document (http ${response.status})`);
|
|
512
|
+
const document = parsed;
|
|
513
|
+
return {
|
|
514
|
+
code: typeof document["code"] === "number" ? document["code"] : 0,
|
|
515
|
+
msg: typeof document["msg"] === "string" ? document["msg"] : "",
|
|
516
|
+
data: "data" in document ? document["data"] : void 0
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
/** Fail an envelope whose business code is non-zero, classified like HTTP errors. */
|
|
520
|
+
function envelopeError(status, envelope) {
|
|
521
|
+
const kind = classifyUpstreamError(status, envelope.msg);
|
|
522
|
+
return /* @__PURE__ */ new Error(`workbuddy upstream ${kind} (http ${status}): ${envelope.msg.slice(0, 160)}`);
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Upstream HTTP client. One instance serves the whole plugin; requests take
|
|
526
|
+
* the credential explicitly so token refreshes apply on the next call.
|
|
527
|
+
*/
|
|
528
|
+
var WorkBuddyUpstreamClient = class {
|
|
529
|
+
/** POST the chat endpoint; a successful answer is the raw SSE response. */
|
|
530
|
+
async chatStream(credential, bodyJson, signal) {
|
|
531
|
+
let response;
|
|
532
|
+
try {
|
|
533
|
+
response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
|
|
534
|
+
method: "POST",
|
|
535
|
+
headers: {
|
|
536
|
+
...chatHeaders(credential),
|
|
537
|
+
"Authorization": `Bearer ${credential.accessToken}`
|
|
538
|
+
},
|
|
539
|
+
body: bodyJson,
|
|
540
|
+
...signal === void 0 ? {} : { signal }
|
|
541
|
+
});
|
|
542
|
+
} catch (error) {
|
|
543
|
+
return {
|
|
544
|
+
ok: false,
|
|
545
|
+
status: 0,
|
|
546
|
+
kind: "server",
|
|
547
|
+
message: `transport error: ${String(error)}`
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
if (response.ok) return {
|
|
551
|
+
ok: true,
|
|
552
|
+
response
|
|
553
|
+
};
|
|
554
|
+
const text = (await response.text()).slice(0, ERROR_BODY_LIMIT);
|
|
555
|
+
return {
|
|
556
|
+
ok: false,
|
|
557
|
+
status: response.status,
|
|
558
|
+
kind: classifyUpstreamError(response.status, text),
|
|
559
|
+
message: text
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
/** POST the token-refresh endpoint; the caller merges the outcome. */
|
|
563
|
+
async refreshToken(credential) {
|
|
564
|
+
const response = await fetch(`${chatBase(credential)}/v2/plugin/auth/token/refresh`, {
|
|
565
|
+
method: "POST",
|
|
566
|
+
headers: refreshHeaders(credential),
|
|
567
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
568
|
+
});
|
|
569
|
+
const envelope = await readEnvelope(response);
|
|
570
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
571
|
+
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
572
|
+
const accessToken = typeof data["accessToken"] === "string" ? data["accessToken"] : "";
|
|
573
|
+
if (accessToken === "") throw new Error("workbuddy token refresh returned no accessToken; sign in again in the WorkBuddy app");
|
|
574
|
+
const outcome = { accessToken };
|
|
575
|
+
if (typeof data["refreshToken"] === "string" && data["refreshToken"] !== "") outcome.refreshToken = data["refreshToken"];
|
|
576
|
+
if (typeof data["expiresIn"] === "number" && data["expiresIn"] > 0) outcome.expiresInSec = data["expiresIn"];
|
|
577
|
+
if (typeof data["domain"] === "string" && data["domain"] !== "") outcome.domain = data["domain"];
|
|
578
|
+
return outcome;
|
|
579
|
+
}
|
|
580
|
+
/** GET the personal model catalog and keep the `cli` agent's models only. */
|
|
581
|
+
async fetchModels(credential) {
|
|
582
|
+
const response = await fetch(`${chatBase(credential)}/console/enterprises/personal/models`, {
|
|
583
|
+
headers: {
|
|
584
|
+
"Authorization": `Bearer ${credential.accessToken}`,
|
|
585
|
+
"Accept": "application/json",
|
|
586
|
+
"Origin": originReferer(credential),
|
|
587
|
+
"Referer": `${originReferer(credential)}/`,
|
|
588
|
+
"User-Agent": CLIENT_UA
|
|
589
|
+
},
|
|
590
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
591
|
+
});
|
|
592
|
+
const envelope = await readEnvelope(response);
|
|
593
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
594
|
+
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
595
|
+
const rawModels = Array.isArray(data["models"]) ? data["models"] : [];
|
|
596
|
+
const agents = Array.isArray(data["agents"]) ? data["agents"] : [];
|
|
597
|
+
let cliIds;
|
|
598
|
+
for (const agent of agents) if (typeof agent === "object" && agent !== null) {
|
|
599
|
+
const wrapped = agent;
|
|
600
|
+
if (wrapped["name"] === "cli" && Array.isArray(wrapped["models"])) {
|
|
601
|
+
cliIds = wrapped["models"].filter((id) => typeof id === "string");
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (cliIds === void 0 || cliIds.length === 0) throw new Error("workbuddy model catalog lists no cli agent models");
|
|
606
|
+
const byId = /* @__PURE__ */ new Map();
|
|
607
|
+
for (const model of rawModels) {
|
|
608
|
+
if (typeof model !== "object" || model === null) continue;
|
|
609
|
+
const wrapped = model;
|
|
610
|
+
const id = typeof wrapped["id"] === "string" ? wrapped["id"] : "";
|
|
611
|
+
if (id === "" || wrapped["disabled"] === true) continue;
|
|
612
|
+
const input = typeof wrapped["maxInputTokens"] === "number" ? wrapped["maxInputTokens"] : 0;
|
|
613
|
+
const output = typeof wrapped["maxOutputTokens"] === "number" ? wrapped["maxOutputTokens"] : 0;
|
|
614
|
+
if (input <= 0 || output <= 0) continue;
|
|
615
|
+
byId.set(id, {
|
|
616
|
+
id,
|
|
617
|
+
name: typeof wrapped["name"] === "string" && wrapped["name"] !== "" ? wrapped["name"] : id,
|
|
618
|
+
contextWindow: input,
|
|
619
|
+
maxTokens: output
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
const models = cliIds.map((id) => byId.get(id)).filter((model) => model !== void 0);
|
|
623
|
+
if (models.length === 0) throw new Error("workbuddy model catalog resolved to an empty list");
|
|
624
|
+
return models;
|
|
625
|
+
}
|
|
626
|
+
/** POST the billing endpoint for the aggregated remaining credit. */
|
|
627
|
+
async fetchCredits(credential) {
|
|
628
|
+
const now = /* @__PURE__ */ new Date();
|
|
629
|
+
const format = (date) => [
|
|
630
|
+
date.getFullYear().toString().padStart(4, "0"),
|
|
631
|
+
(date.getMonth() + 1).toString().padStart(2, "0"),
|
|
632
|
+
date.getDate().toString().padStart(2, "0")
|
|
633
|
+
].join("-") + " " + [
|
|
634
|
+
date.getHours().toString().padStart(2, "0"),
|
|
635
|
+
date.getMinutes().toString().padStart(2, "0"),
|
|
636
|
+
date.getSeconds().toString().padStart(2, "0")
|
|
637
|
+
].join(":");
|
|
638
|
+
const response = await fetch(`${billingBase(credential)}/v2/billing/meter/get-user-resource`, {
|
|
639
|
+
method: "POST",
|
|
640
|
+
headers: billingHeaders(credential),
|
|
641
|
+
body: JSON.stringify({
|
|
642
|
+
PageNumber: 1,
|
|
643
|
+
PageSize: 100,
|
|
644
|
+
ProductCode: "p_tcaca",
|
|
645
|
+
Status: [0, 3],
|
|
646
|
+
PackageEndTimeRangeBegin: format(now),
|
|
647
|
+
PackageEndTimeRangeEnd: format(new Date(now.getTime() + 3185136e6))
|
|
648
|
+
}),
|
|
649
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
650
|
+
});
|
|
651
|
+
const envelope = await readEnvelope(response);
|
|
652
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
653
|
+
const responseWrapper = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
654
|
+
const data = typeof responseWrapper["Response"] === "object" && responseWrapper["Response"] !== null ? responseWrapper["Response"] : {};
|
|
655
|
+
const inner = typeof data["Data"] === "object" && data["Data"] !== null ? data["Data"] : {};
|
|
656
|
+
const rawAccounts = Array.isArray(inner["Accounts"]) ? inner["Accounts"] : [];
|
|
657
|
+
const accounts = [];
|
|
658
|
+
let total = 0;
|
|
659
|
+
for (const raw of rawAccounts) {
|
|
660
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
661
|
+
const account = raw;
|
|
662
|
+
const numberField = (key) => typeof account[key] === "number" ? account[key] : 0;
|
|
663
|
+
const size = numberField("CycleCapacitySize");
|
|
664
|
+
const cycleRemain = numberField("CycleCapacityRemain");
|
|
665
|
+
const cycleUsed = numberField("CycleCapacityUsed");
|
|
666
|
+
const capacityRemain = numberField("CapacityRemain");
|
|
667
|
+
let remain;
|
|
668
|
+
if (size > 0) remain = cycleRemain;
|
|
669
|
+
else if (cycleRemain > 0 || cycleUsed > 0) remain = cycleRemain;
|
|
670
|
+
else remain = capacityRemain;
|
|
671
|
+
if (remain < 0) remain = 0;
|
|
672
|
+
total += remain;
|
|
673
|
+
accounts.push({
|
|
674
|
+
packageName: typeof account["PackageName"] === "string" ? account["PackageName"] : "(unnamed)",
|
|
675
|
+
remain,
|
|
676
|
+
size: size > 0 ? size : numberField("CapacitySize")
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
return {
|
|
680
|
+
total,
|
|
681
|
+
accounts
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
//#endregion
|
|
686
|
+
//#region src/version.ts
|
|
687
|
+
const WORKBUDDY_CONNECT_VERSION = "0.2.1";
|
|
688
|
+
//#endregion
|
|
689
|
+
//#region src/host-heartbeat.ts
|
|
690
|
+
/**
|
|
691
|
+
* Host-side heartbeat: a small JSON file written under `$DSH_HOME` once the
|
|
692
|
+
* `workbuddy` provider is registered. The status CLI reads it to report
|
|
693
|
+
* whether the host bundle is alive, independent of the browser card.
|
|
694
|
+
*
|
|
695
|
+
* The browser (client) bundle cannot write files; its health is reported
|
|
696
|
+
* only through `console.error` on failure (see `src/client/index.tsx`).
|
|
697
|
+
* This asymmetry is intentional: the host is the load-bearing half, and
|
|
698
|
+
* a missing heartbeat unambiguously means the host never started.
|
|
699
|
+
*
|
|
700
|
+
* @module dsh-workbuddy-connect/host-heartbeat
|
|
701
|
+
*/
|
|
702
|
+
/** Basename of the host heartbeat file inside the Harness home. */
|
|
703
|
+
const WORKBUDDY_HOST_HEARTBEAT_FILENAME = ".workbuddy-host-heartbeat.json";
|
|
704
|
+
/** Current on-disk heartbeat format; readers reject others. */
|
|
705
|
+
const HEARTBEAT_FORMAT_VERSION = 1;
|
|
706
|
+
/** Absolute path of the host heartbeat file. */
|
|
707
|
+
function workbuddyHostHeartbeatPath() {
|
|
708
|
+
return join(resolveDshHome(), WORKBUDDY_HOST_HEARTBEAT_FILENAME);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Write (or overwrite) the heartbeat after the host bundle registered the
|
|
712
|
+
* provider. A failed write is non-fatal: the host is already running, and
|
|
713
|
+
* the status CLI will simply report "heartbeat missing" rather than failing.
|
|
714
|
+
*/
|
|
715
|
+
async function writeHostHeartbeat() {
|
|
716
|
+
const document = {
|
|
717
|
+
version: HEARTBEAT_FORMAT_VERSION,
|
|
718
|
+
package: "dsh-workbuddy-connect",
|
|
719
|
+
pluginVersion: WORKBUDDY_CONNECT_VERSION,
|
|
720
|
+
registeredAt: Date.now(),
|
|
721
|
+
pid: process.pid
|
|
722
|
+
};
|
|
723
|
+
try {
|
|
724
|
+
await writeFile(workbuddyHostHeartbeatPath(), JSON.stringify(document), "utf8");
|
|
725
|
+
} catch {}
|
|
726
|
+
}
|
|
727
|
+
/** Remove the heartbeat on plugin disposal so a stale file does not linger. */
|
|
728
|
+
async function clearHostHeartbeat() {
|
|
729
|
+
try {
|
|
730
|
+
await rm(workbuddyHostHeartbeatPath(), { force: true });
|
|
731
|
+
} catch {}
|
|
732
|
+
}
|
|
733
|
+
/** Read and validate the heartbeat; returns `undefined` when absent or malformed. */
|
|
734
|
+
async function readHostHeartbeat() {
|
|
735
|
+
let raw;
|
|
736
|
+
try {
|
|
737
|
+
raw = await readFile(workbuddyHostHeartbeatPath(), "utf8");
|
|
738
|
+
} catch {
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
try {
|
|
742
|
+
const parsed = JSON.parse(raw);
|
|
743
|
+
if (parsed.version === HEARTBEAT_FORMAT_VERSION && parsed.package === "dsh-workbuddy-connect" && typeof parsed.registeredAt === "number" && typeof parsed.pid === "number") return {
|
|
744
|
+
version: HEARTBEAT_FORMAT_VERSION,
|
|
745
|
+
package: "dsh-workbuddy-connect",
|
|
746
|
+
pluginVersion: typeof parsed.pluginVersion === "string" ? parsed.pluginVersion : "unknown",
|
|
747
|
+
registeredAt: parsed.registeredAt,
|
|
748
|
+
pid: parsed.pid
|
|
749
|
+
};
|
|
750
|
+
} catch {}
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Absolute start time (epoch ms) of the process holding `pid`, or `undefined`
|
|
754
|
+
* when it cannot be determined (no such PID, platform lacks a readable source).
|
|
755
|
+
*
|
|
756
|
+
* - macOS / Linux: `ps -o lstart=` prints a local-time "EEE MMM DD HH:MM:SS YYYY";
|
|
757
|
+
* `Date.parse` resolves it against the local clock, which matches how
|
|
758
|
+
* `registeredAt` (a `Date.now()` absolute value) is expressed.
|
|
759
|
+
* - Windows: WMI `CreationDate` is UTC (`YYYYMMDDHHMMSS.mmm+zzzz`); parsed with
|
|
760
|
+
* `Date.UTC`, again comparable to `registeredAt`.
|
|
761
|
+
*
|
|
762
|
+
* Failures return `undefined` so callers can fall back to plain PID liveness
|
|
763
|
+
* rather than mis-report a running host as dead.
|
|
764
|
+
*/
|
|
765
|
+
function processStartTimeMs(pid) {
|
|
766
|
+
try {
|
|
767
|
+
if (process.platform === "win32") {
|
|
768
|
+
const m = execFileSync("wmic", [
|
|
769
|
+
"process",
|
|
770
|
+
"where",
|
|
771
|
+
`processid=${pid}`,
|
|
772
|
+
"get",
|
|
773
|
+
"CreationDate"
|
|
774
|
+
], {
|
|
775
|
+
encoding: "utf8",
|
|
776
|
+
windowsHide: true
|
|
777
|
+
}).match(/(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.\d+([+-]\d{4})/);
|
|
778
|
+
if (m === null) return void 0;
|
|
779
|
+
const [, y, mo, d, h, mi, s] = m;
|
|
780
|
+
const ms = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s));
|
|
781
|
+
return Number.isFinite(ms) ? ms : void 0;
|
|
782
|
+
}
|
|
783
|
+
const out = execFileSync("ps", [
|
|
784
|
+
"-o",
|
|
785
|
+
"lstart=",
|
|
786
|
+
"-p",
|
|
787
|
+
String(pid)
|
|
788
|
+
], {
|
|
789
|
+
encoding: "utf8",
|
|
790
|
+
env: {
|
|
791
|
+
...process.env,
|
|
792
|
+
LC_ALL: "C",
|
|
793
|
+
LANG: "C"
|
|
794
|
+
}
|
|
795
|
+
}).trim();
|
|
796
|
+
if (out === "") return void 0;
|
|
797
|
+
const ms = Date.parse(out);
|
|
798
|
+
return Number.isFinite(ms) ? ms : void 0;
|
|
799
|
+
} catch {
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Whether the heartbeat's PID is still alive *and* still the same process that
|
|
805
|
+
* registered it. A stale heartbeat (host crashed without clearing the file)
|
|
806
|
+
* is distinguished from a live host by two checks:
|
|
807
|
+
*
|
|
808
|
+
* 1. `process.kill(pid, 0)` — the PID exists (signal 0 tests existence).
|
|
809
|
+
* 2. The process holding that PID started at or before `registeredAt`. A host
|
|
810
|
+
* that registered the heartbeat must have been started before writing it,
|
|
811
|
+
* so `start <= registeredAt`; a recycled PID belongs to an unrelated process
|
|
812
|
+
* started after the host died, so `start > registeredAt` correctly reads dead.
|
|
813
|
+
*
|
|
814
|
+
* PID-only detection is not enough: after a crash the OS may hand the same PID
|
|
815
|
+
* to an unrelated process, and the un-cleared stale heartbeat would otherwise
|
|
816
|
+
* produce a false "Host running". When the process start time cannot be read
|
|
817
|
+
* (e.g. unsupported platform) the check degrades to plain PID liveness.
|
|
818
|
+
*/
|
|
819
|
+
function isHeartbeatProcessAlive(heartbeat) {
|
|
820
|
+
try {
|
|
821
|
+
process.kill(heartbeat.pid, 0);
|
|
822
|
+
} catch {
|
|
823
|
+
return false;
|
|
824
|
+
}
|
|
825
|
+
const startAtMs = processStartTimeMs(heartbeat.pid);
|
|
826
|
+
if (startAtMs === void 0) return true;
|
|
827
|
+
return startAtMs <= heartbeat.registeredAt;
|
|
828
|
+
}
|
|
829
|
+
//#endregion
|
|
830
|
+
export { WorkBuddyCredentialStore as _, readHostHeartbeat as a, workbuddyOwnAuthPath as b, WORKBUDDY_CONNECT_VERSION as c, prepareChatBody as d, regionOf as f, WORKBUDDY_AUTH_FILE_ENV as g, WORKBUDDY_AUTH_FILENAME as h, processStartTimeMs as i, WorkBuddyUpstreamClient as l, WorkBuddyCatalog as m, clearHostHeartbeat as n, workbuddyHostHeartbeatPath as o, FALLBACK_WORKBUDDY_MODELS as p, isHeartbeatProcessAlive as r, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, classifyUpstreamError as u, defaultDesktopAuthPath as v, parseWorkBuddyAuth as y };
|