dsh-workbuddy-connect 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { D as WorkBuddyUpstreamClient, Y as resolveAppVersion, b as WorkBuddyCredentialStore, f as WORKBUDDY_CONNECT_VERSION, g as FALLBACK_WORKBUDDY_MODELS, h as FALLBACK_WORKBUDDY_AI_MODELS, i as variantFor, l as readHostHeartbeat, n as CN_VARIANT, r as WORKBUDDY_VARIANTS, s as isHeartbeatProcessAlive, u as workbuddyHostHeartbeatPath } from "./variants-CnrmSn0Q.js";
2
+ import { D as WorkBuddyUpstreamClient, E as WorkBuddyAtRestKeyProvider, Y as resolveAppVersion, b as WorkBuddyCredentialStore, f as WORKBUDDY_CONNECT_VERSION, g as FALLBACK_WORKBUDDY_MODELS, h as FALLBACK_WORKBUDDY_AI_MODELS, i as variantFor, l as readHostHeartbeat, n as CN_VARIANT, r as WORKBUDDY_VARIANTS, s as isHeartbeatProcessAlive, u as workbuddyHostHeartbeatPath } from "./variants-B3Pa4EBr.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/bin.ts
@@ -26,12 +26,21 @@ function printHelp() {
26
26
  function printJson(value) {
27
27
  process.stdout.write(`${JSON.stringify(value)}\n`);
28
28
  }
29
- /** One variant's store plus the client that performs its refreshes. */
29
+ /**
30
+ * One variant's store plus the client that performs its refreshes.
31
+ *
32
+ * The key provider is injected explicitly, and per variant, for the same
33
+ * reason the plugin host does it: discovery is CN/macOS-only, so the provider
34
+ * must not be left to its no-arg default — that default is deliberately
35
+ * `discovery: 'none'`, and relying on it here would quietly strip the CN CLI
36
+ * of the decryption it has always had.
37
+ */
30
38
  function makeStore(variant) {
31
39
  const client = new WorkBuddyUpstreamClient();
32
40
  return new WorkBuddyCredentialStore({
33
41
  variant,
34
- refresh: (credential) => client.refreshToken(credential)
42
+ refresh: (credential) => client.refreshToken(credential),
43
+ keyProvider: new WorkBuddyAtRestKeyProvider({ discovery: variant.id === CN_VARIANT.id ? "macos-workbuddy" : "none" })
35
44
  });
36
45
  }
37
46
  /** The plugin-owned credential copy for one variant, for display. */
package/lib/client.js CHANGED
@@ -30,6 +30,21 @@ window.__ModuleLoader__.load({
30
30
  */
31
31
  const WORKBUDDY_AI_STATUS_PATH = "/plugins/dsh-workbuddy-connect/ai/status";
32
32
  const WORKBUDDY_AI_PROBE_PATH = "/plugins/dsh-workbuddy-connect/ai/probe";
33
+ /** Every reason code, for validation without trusting a wire value. */
34
+ const SIGNED_OUT_REASON_CODES = [
35
+ "no-credential",
36
+ "credential-region-mismatch",
37
+ "encrypted-credential-unreadable",
38
+ "electron-binary-not-found",
39
+ "electron-binary-ambiguous",
40
+ "electron-binary-unavailable",
41
+ "electron-path-invalid",
42
+ "electron-discovery-incomplete"
43
+ ];
44
+ /** Whether a value is one of the closed set of signed-out reason codes. */
45
+ function isWorkBuddySignedOutReasonCode(value) {
46
+ return typeof value === "string" && SIGNED_OUT_REASON_CODES.includes(value);
47
+ }
33
48
  //#endregion
34
49
  //#region src/client/status-document.ts
35
50
  /**
@@ -43,6 +58,11 @@ window.__ModuleLoader__.load({
43
58
  * The check is deliberately limited to the discriminator (plus `error`'s
44
59
  * `message`, which the error paragraph renders): validating optional fields
45
60
  * here would reject documents the host legitimately omits fields from.
61
+ *
62
+ * `reasonCode` is therefore *not* rejected here — a card renders `reason`
63
+ * either way — but every reader must narrow it with
64
+ * `isWorkBuddySignedOutReasonCode` before branching on it, since the wire
65
+ * value is not guaranteed to be inside the enum.
46
66
  */
47
67
  function isWorkBuddyWebStatus(value) {
48
68
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
@@ -67,7 +87,9 @@ window.__ModuleLoader__.load({
67
87
  introKey: "intro",
68
88
  signedOutKey: "signedOutHint",
69
89
  statusPath: WORKBUDDY_STATUS_PATH,
70
- probePath: WORKBUDDY_PROBE_PATH
90
+ probePath: WORKBUDDY_PROBE_PATH,
91
+ appName: "WorkBuddy",
92
+ unavailableKey: "assistUnavailableCN"
71
93
  };
72
94
  /** International WorkBuddy AI. */
73
95
  const AI_CARD_VARIANT = {
@@ -76,11 +98,119 @@ window.__ModuleLoader__.load({
76
98
  introKey: "introAI",
77
99
  signedOutKey: "signedOutHintAI",
78
100
  statusPath: WORKBUDDY_AI_STATUS_PATH,
79
- probePath: WORKBUDDY_AI_PROBE_PATH
101
+ probePath: WORKBUDDY_AI_PROBE_PATH,
102
+ appName: "WorkBuddy AI",
103
+ unavailableKey: "assistUnavailableAI"
80
104
  };
81
105
  /** Both cards, in display order. */
82
106
  const CARD_VARIANTS = [CN_CARD_VARIANT, AI_CARD_VARIANT];
83
107
  const POLL_INTERVAL_MS = 6e4;
108
+ /**
109
+ * The reason codes whose failures the Agent assist block covers: the plugin
110
+ * cannot reach a decryption program, for any of the five reasons in §5.5.
111
+ *
112
+ * This is the *only* place the card decides whether the block applies. It
113
+ * branches on the code, never on `reason` text: the prose is written for a
114
+ * human and is expected to change, so matching it would silently stop
115
+ * matching after any wording edit.
116
+ *
117
+ * `encrypted-credential-unreadable` is deliberately absent — the app was found
118
+ * and ran, so "look for the app" is not the fix for it.
119
+ */
120
+ const ASSIST_REASON_CODES = [
121
+ "electron-binary-not-found",
122
+ "electron-binary-ambiguous",
123
+ "electron-binary-unavailable",
124
+ "electron-path-invalid",
125
+ "electron-discovery-incomplete"
126
+ ];
127
+ /** Locale key for one failure's summary inside the Agent prompt. */
128
+ function assistSummaryKey(code, variant) {
129
+ switch (code) {
130
+ case "electron-binary-not-found": return "assistNotFound";
131
+ case "electron-binary-ambiguous": return "assistAmbiguous";
132
+ case "electron-discovery-incomplete": return "assistIncomplete";
133
+ case "electron-path-invalid": return "assistPathInvalid";
134
+ default: return variant.unavailableKey;
135
+ }
136
+ }
137
+ /** Copy text to the clipboard, reporting whether it worked. */
138
+ async function copyPrompt(text) {
139
+ try {
140
+ if (navigator.clipboard?.writeText === void 0) return false;
141
+ await navigator.clipboard.writeText(text);
142
+ return true;
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+ /**
148
+ * The Agent assist block for a path failure: what is wrong, one copyable
149
+ * request, and a re-check. Rendered only for the codes above.
150
+ */
151
+ function AssistBlock({ t, variant, code, busy, onRecheck }) {
152
+ const [copied, setCopied] = (0, react.useState)(false);
153
+ const [copyFailed, setCopyFailed] = (0, react.useState)(false);
154
+ const prompt = t("assistantPrompt", {
155
+ appName: variant.appName,
156
+ failureSummary: t(assistSummaryKey(code, variant))
157
+ });
158
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
159
+ style: assistStyle,
160
+ children: [
161
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", {
162
+ style: assistHeadingStyle,
163
+ children: t("assistantHeading")
164
+ }),
165
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
166
+ style: bodyStyle,
167
+ children: t("assistantIntro")
168
+ }),
169
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
170
+ style: assistPromptRowStyle,
171
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
172
+ style: assistPromptStyle,
173
+ children: prompt
174
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
175
+ type: "button",
176
+ style: buttonStyle$1,
177
+ onClick: () => {
178
+ (async () => {
179
+ const ok = await copyPrompt(prompt);
180
+ setCopied(ok);
181
+ setCopyFailed(!ok);
182
+ })();
183
+ },
184
+ children: t("assistantCopy")
185
+ })]
186
+ }),
187
+ copied ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
188
+ style: assistFeedbackStyle,
189
+ role: "status",
190
+ children: t("assistantCopied")
191
+ }) : null,
192
+ copyFailed ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
193
+ style: assistFeedbackStyle,
194
+ role: "status",
195
+ children: t("assistantCopyFailed")
196
+ }) : null,
197
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
198
+ style: bodyStyle,
199
+ children: t("assistantAfter")
200
+ }),
201
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
202
+ style: rowStyle,
203
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
204
+ type: "button",
205
+ style: buttonStyle$1,
206
+ disabled: busy,
207
+ onClick: onRecheck,
208
+ children: busy ? t("assistantRechecking") : t("assistantRecheck")
209
+ })
210
+ })
211
+ ]
212
+ });
213
+ }
84
214
  const cardStyle = {
85
215
  overflow: "hidden",
86
216
  border: "1px solid var(--dsw-alias-border-l2)",
@@ -225,6 +355,53 @@ window.__ModuleLoader__.load({
225
355
  gap: 2
226
356
  };
227
357
  /**
358
+ * The Agent assist block. Sits beside the existing error line rather than
359
+ * replacing it: the short diagnosis stays the headline, this adds the way out.
360
+ */
361
+ const assistStyle = {
362
+ display: "flex",
363
+ flexDirection: "column",
364
+ gap: 10,
365
+ padding: "12px 14px",
366
+ marginTop: 12,
367
+ border: "1px solid var(--dsw-alias-border-l2)",
368
+ borderRadius: 10,
369
+ background: "var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.04))"
370
+ };
371
+ const assistHeadingStyle = {
372
+ margin: 0,
373
+ fontSize: 14,
374
+ lineHeight: "20px",
375
+ fontWeight: 600,
376
+ color: "var(--dsw-alias-label-primary)"
377
+ };
378
+ const assistPromptRowStyle = {
379
+ display: "flex",
380
+ flexWrap: "wrap",
381
+ alignItems: "flex-start",
382
+ gap: 8,
383
+ padding: "8px 9px 8px 11px",
384
+ borderRadius: 7,
385
+ background: "var(--dsw-alias-bg-layer-1)"
386
+ };
387
+ const assistPromptStyle = {
388
+ flex: "1 1 220px",
389
+ minWidth: 0,
390
+ margin: 0,
391
+ color: "var(--dsw-alias-label-primary)",
392
+ fontSize: 12,
393
+ lineHeight: "19px",
394
+ whiteSpace: "pre-wrap",
395
+ overflowWrap: "anywhere",
396
+ userSelect: "text"
397
+ };
398
+ const assistFeedbackStyle = {
399
+ margin: 0,
400
+ fontSize: 12,
401
+ lineHeight: "19px",
402
+ color: "var(--dsw-alias-label-secondary)"
403
+ };
404
+ /**
228
405
  * Left half of one merged context/visibility row: the visibility checkbox (when
229
406
  * the account has one) beside the model's name and rate. Kept as a flex span so
230
407
  * the capacity column stays flush right no matter how long the name runs.
@@ -987,6 +1164,13 @@ window.__ModuleLoader__.load({
987
1164
  });
988
1165
  }, [control]);
989
1166
  const title = t(variant.titleKey);
1167
+ /**
1168
+ * The failure the assist block covers, when this document has one. Computed
1169
+ * once so the block and the header's refresh button agree on whether the
1170
+ * block owns the re-check action — showing both would put two buttons with
1171
+ * the same effect side by side.
1172
+ */
1173
+ const assistCode = status?.status === "signed-out" && isWorkBuddySignedOutReasonCode(status.reasonCode) && ASSIST_REASON_CODES.includes(status.reasonCode) ? status.reasonCode : void 0;
990
1174
  const label = status === void 0 ? t("loading") : status.status === "signed-in" ? status.nickname === void 0 ? t("signedInAs", { nickname: "" }).trimEnd().replace(/[::]$/, "") : t("signedInAs", { nickname: status.nickname }) : status.status === "error" ? t("requestFailed") : t("signedOut");
991
1175
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
992
1176
  style: cardStyle,
@@ -1032,7 +1216,7 @@ window.__ModuleLoader__.load({
1032
1216
  "aria-hidden": "true",
1033
1217
  style: dotStyle(status === void 0 ? "loading" : status.status)
1034
1218
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label })]
1035
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1219
+ }), assistCode === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1036
1220
  type: "button",
1037
1221
  style: buttonStyle$1,
1038
1222
  disabled: busy,
@@ -1040,7 +1224,7 @@ window.__ModuleLoader__.load({
1040
1224
  manualRefresh();
1041
1225
  },
1042
1226
  children: busy ? t("refreshing") : t("refresh")
1043
- })]
1227
+ }) : null]
1044
1228
  }),
1045
1229
  readFailure === void 0 || signedIn === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1046
1230
  style: errorStyle,
@@ -1178,10 +1362,18 @@ window.__ModuleLoader__.load({
1178
1362
  })]
1179
1363
  })
1180
1364
  ] }) : null,
1181
- status?.status === "signed-out" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1365
+ status?.status === "signed-out" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1182
1366
  style: status.reason === void 0 ? bodyStyle : errorStyle,
1183
1367
  children: status.reason ?? t(variant.signedOutKey)
1184
- }) : null,
1368
+ }), assistCode === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AssistBlock, {
1369
+ t,
1370
+ variant,
1371
+ code: assistCode,
1372
+ busy,
1373
+ onRecheck: () => {
1374
+ manualRefresh();
1375
+ }
1376
+ })] }) : null,
1185
1377
  status?.status === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1186
1378
  style: errorStyle,
1187
1379
  children: status.message
@@ -1745,7 +1937,22 @@ window.__ModuleLoader__.load({
1745
1937
  probeResultUnknown: "Detection did not complete",
1746
1938
  probeResultAt: "Detected {time}",
1747
1939
  probeResultEmpty: "No detectable models right now.",
1748
- probeFailed: "Detection failed: {message}"
1940
+ probeFailed: "Detection failed: {message}",
1941
+ assistantHeading: "Let an Agent sort this out",
1942
+ assistantIntro: "Send the request below to your Agent; it will check the app location and the launch configuration for you.",
1943
+ assistantCopy: "Copy for Agent",
1944
+ assistantCopied: "Copied",
1945
+ assistantCopyFailed: "Copy failed — select the text above and copy it manually",
1946
+ assistantAfter: "When your Agent is done, come back and check again. If the DSH launch environment was changed, restart DSH first as instructed.",
1947
+ assistantRecheck: "Done — check again",
1948
+ assistantRechecking: "Checking…",
1949
+ assistantPrompt: "DSH's dsh-workbuddy-connect cannot use my {appName}: {failureSummary}. Please check the actual installation location and any existing path configuration, help the plugin use it correctly, and verify recovery. If the DSH launch environment must be changed or DSH restarted, give me clear steps; do not only set an environment variable temporarily in the current shell.",
1950
+ assistNotFound: "no usable decryption program was found",
1951
+ assistAmbiguous: "more than one WorkBuddy copy was found and none could be chosen safely",
1952
+ assistIncomplete: "the automatic search could not be completed",
1953
+ assistPathInvalid: "the configured program path is not usable",
1954
+ assistUnavailableCN: "no decryption program is configured for this platform",
1955
+ assistUnavailableAI: "no decryption program is configured for WorkBuddy AI"
1749
1956
  };
1750
1957
  const zh = {
1751
1958
  title: "DSH WorkBuddy Connect",
@@ -1831,7 +2038,22 @@ window.__ModuleLoader__.load({
1831
2038
  probeResultUnknown: "检测未完成",
1832
2039
  probeResultAt: "检测于 {time}",
1833
2040
  probeResultEmpty: "当前没有可检测的模型。",
1834
- probeFailed: "检测失败:{message}"
2041
+ probeFailed: "检测失败:{message}",
2042
+ assistantHeading: "让 Agent 帮你处理",
2043
+ assistantIntro: "把下面这段请求发给你的 Agent,它会协助检查应用位置和启动配置。",
2044
+ assistantCopy: "复制给 Agent",
2045
+ assistantCopied: "已复制",
2046
+ assistantCopyFailed: "复制失败,请手动选择上方文字复制",
2047
+ assistantAfter: "Agent 处理完成后,回到这里重新检查;如果修改了 DSH 的启动环境,请先按指引重启 DSH。",
2048
+ assistantRecheck: "已处理,重新检查",
2049
+ assistantRechecking: "正在检查…",
2050
+ assistantPrompt: "DSH 的 dsh-workbuddy-connect 无法使用我的 {appName}:{failureSummary}。请帮我检查实际安装位置和已有路径配置,让插件能正确使用它,并验证恢复结果;如果需要修改 DSH 的启动环境或重启,请给我明确的操作步骤,不要只在当前 shell 临时设置环境变量。",
2051
+ assistNotFound: "没有找到可用的解密程序",
2052
+ assistAmbiguous: "找到了多个 WorkBuddy 副本,无法安全自动选择",
2053
+ assistIncomplete: "自动定位未能完成",
2054
+ assistPathInvalid: "指定的程序路径不可用",
2055
+ assistUnavailableCN: "当前平台尚未配置解密程序",
2056
+ assistUnavailableAI: "尚未配置 WorkBuddy AI 的解密程序"
1835
2057
  };
1836
2058
  //#endregion
1837
2059
  //#region src/client/index.tsx
package/lib/index.d.ts CHANGED
@@ -1,39 +1,65 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
2
  import "@earendil-works/pi-ai";
3
- import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
3
+ import { PiAiAdapter, PiAiAdapterOptions } from "@deepseek-ai/dsh-llm-pi-ai";
4
4
  import { Context } from "@deepseek-ai/cordis";
5
5
  import { SettingsNamespace } from "@deepseek-ai/dsh-settings";
6
6
  import { AttachmentStore } from "@deepseek-ai/dsh-attachment";
7
- //#region src/desktop-credential-protection.d.ts
7
+ //#region src/status-paths.d.ts
8
8
  /**
9
- * WorkBuddy 5.6.x at-rest credential protection: classification, key
10
- * resolution, and field decryption for the desktop app's encrypted auth file.
11
- *
12
- * Since WorkBuddy 5.6 the desktop app encrypts `auth.accessToken` and
13
- * `auth.refreshToken` at rest (`buildPolicy: "fields"`, on by default), so the
14
- * plugin reads `{$wbEncrypted:1, envelope}` wrappers instead of token strings
15
- * (issues #39/#40). Everything needed to open them lives on the same machine:
16
- *
17
- * - the sealed payload (`{version:1, atRestSecretKey}`) comes from the
18
- * WorkBuddy-modified Electron's private `workbuddyStorage` binding, reached
19
- * by running *its own* binary once with `ELECTRON_RUN_AS_NODE=1`;
20
- * - `protectorKey = sha256(atRestSecretKey, utf8)` opens the envelopes with
21
- * AES-256-GCM; the AAD builder below is transcribed from the app's own
22
- * `buildAuthenticatedContextAad` (verified live against 5.6.2, see
23
- * `docs/r3-final.js` in the working copy — not committed).
9
+ * Why no credential is usable, as a closed enum the browser half switches on.
24
10
  *
25
- * The plugin process itself can never call `_linkedBinding` (it runs in DSH's
26
- * Node, not the forked Electron), so the helper is spawned. The key is cached
27
- * in memory only, single-flight, and re-resolved when an envelope names a
28
- * different key id. Neither the payload, the key, nor any token is ever
29
- * logged; error messages carry sizes, ids, and exit codes only.
30
- *
31
- * @module dsh-workbuddy-connect/desktop-credential-protection
11
+ * Deliberately separate from `reason`: `reason` is free text meant for a human
12
+ * to read, so matching on it would break the moment the wording changes. This
13
+ * is the machine-readable half, and the card uses it — never a substring of
14
+ * `reason` — to decide whether the Agent assist block applies.
32
15
  */
16
+ type WorkBuddySignedOutReasonCode =
17
+ /** Nobody is signed in; nothing diagnosable beyond that. */
18
+ 'no-credential' |
19
+ /** A credential for the *other* product was found in this variant's file. */
20
+ 'credential-region-mismatch' |
21
+ /** An encrypted credential exists but could not be opened (wrong key, GCM failure, helper crash). */
22
+ 'encrypted-credential-unreadable' |
23
+ /** CN/macOS: discovery ran to completion and produced no usable candidate. */
24
+ 'electron-binary-not-found' |
25
+ /** CN/macOS: discovery found more than one distinct usable app. */
26
+ 'electron-binary-ambiguous' |
27
+ /** No auto-discovery for this product/platform and no explicit path configured. */
28
+ 'electron-binary-unavailable' |
29
+ /** An explicit path (option or env) is set but missing or not executable. */
30
+ 'electron-path-invalid' |
31
+ /** Discovery could not finish: tool missing, timeout, output overflow, unreadable plist. */
32
+ 'electron-discovery-incomplete';
33
+ //#endregion
34
+ //#region src/desktop-credential-protection.d.ts
33
35
  /** The four states a desktop auth document can be read as. */
34
36
  type DesktopAuthFormat = 'absent' | 'plaintext' | 'encrypted' | 'unrecognized';
35
37
  /** The spawned helper. Separated from the provider so tests can stand it in. */
36
38
  type WorkBuddyKeyPayloadSource = () => Promise<string>;
39
+ /**
40
+ * Which automatic discovery, if any, this provider may run when no explicit
41
+ * binary is configured.
42
+ *
43
+ * `none` is the safe default: a provider that has not been told which product
44
+ * it serves must not reach for another product's app. `macos-workbuddy` is the
45
+ * CN line — the only one whose at-rest credentials and app layout have been
46
+ * verified live — and resolves the platform default and then Spotlight.
47
+ */
48
+ type WorkBuddyElectronDiscovery = 'none' | 'macos-workbuddy';
49
+ /** Seams the discovery flow runs through, so tests never spawn a process. */
50
+ interface WorkBuddyDiscoveryTools {
51
+ /** Candidate `.app` bundles for the CN bundle id, or a throw for an unusable tool. */
52
+ findApps: (signal: AbortSignal) => Promise<readonly string[]>;
53
+ /**
54
+ * `CFBundleIdentifier` of a bundle, or `undefined` when the tool could not
55
+ * read it — which is "we could not check this candidate", never "it does not
56
+ * match". A successful read of a *different* id returns that id, and the
57
+ * caller excludes the candidate.
58
+ */
59
+ bundleIdentifier: (bundlePath: string, signal: AbortSignal) => Promise<string | undefined>;
60
+ /** Display version, best effort; `undefined` when unavailable. */
61
+ bundleVersion: (bundlePath: string, signal: AbortSignal) => Promise<string | undefined>;
62
+ }
37
63
  /** Provider options. */
38
64
  interface WorkBuddyAtRestKeyProviderOptions {
39
65
  /** Explicit Electron binary; overrides the platform default and env. */
@@ -43,23 +69,76 @@ interface WorkBuddyAtRestKeyProviderOptions {
43
69
  /**
44
70
  * Where the payload comes from. Defaults to spawning WorkBuddy's own
45
71
  * Electron with `ELECTRON_RUN_AS_NODE=1`; tests supply a stand-in so no
46
- * test ever touches the real binary or a real key.
72
+ * test ever touches the real binary or a real key. Supplying this replaces
73
+ * path *resolution* too, so tests about resolution use
74
+ * {@link spawnHelper} instead.
47
75
  */
48
76
  source?: WorkBuddyKeyPayloadSource;
77
+ /**
78
+ * Runs the helper at the resolved path. Distinct from {@link source}, which
79
+ * replaces the whole payload path: this seam keeps resolution — explicit
80
+ * config, platform default, discovery — real, so tests can exercise it
81
+ * without spawning anything.
82
+ */
83
+ spawnHelper?: (electronPath: string) => Promise<string>;
84
+ /**
85
+ * Automatic discovery budget; defaults to `'none'` (see
86
+ * {@link WorkBuddyElectronDiscovery}). Passed explicitly per variant at the
87
+ * composition root, never inferred from the environment.
88
+ */
89
+ discovery?: WorkBuddyElectronDiscovery;
90
+ /**
91
+ * Platform default binary, consulted only when `discovery` is enabled and no
92
+ * explicit path is configured. Injectable so tests can force the fallback
93
+ * branch without moving the real app; `null` means "no default here".
94
+ */
95
+ defaultElectronPath?: string | undefined;
96
+ /** Discovery subprocesses; injectable so tests never spawn. */
97
+ tools?: WorkBuddyDiscoveryTools;
98
+ /**
99
+ * Total budget for one discovery run, covering the search and every
100
+ * candidate check. Injectable so tests can exercise exhaustion without
101
+ * waiting out the production 10s.
102
+ */
103
+ discoveryBudgetMs?: number;
49
104
  }
50
105
  /**
51
106
  * In-memory protector-key resolver: one spawn per key id, single-flight, never
52
107
  * persisted. The cache is keyed by the id envelopes ask for, so an envelope
53
108
  * sealed under a rotated key triggers exactly one fresh resolution.
54
- */
55
- declare class WorkBuddyAtRestKeyProvider {
56
- private readonly electronPath;
109
+ */ declare class WorkBuddyAtRestKeyProvider {
110
+ /**
111
+ * The explicit binary, when one was configured. `undefined` here means "the
112
+ * caller did not name one", which is what lets discovery run — an explicit
113
+ * path that turns out to be unusable is an error, never a reason to look for
114
+ * a different app.
115
+ */
116
+ private readonly explicitPath;
117
+ private readonly defaultPath;
118
+ private readonly discovery;
119
+ private readonly tools;
120
+ private readonly discoveryBudgetMs;
57
121
  private readonly timeoutMs;
58
122
  private readonly source;
123
+ private readonly spawnHelper;
124
+ /**
125
+ * The path discovery settled on, cached only on success. A failure leaves
126
+ * this unset so the next attempt tries again — the user may install or move
127
+ * the app without restarting DSH.
128
+ */
129
+ private discoveredPath;
59
130
  private cache;
60
131
  private inflight;
61
132
  constructor(options?: WorkBuddyAtRestKeyProviderOptions);
62
- /** The binary the default helper would use, for diagnostics. */
133
+ /**
134
+ * The binary the default helper would use, for diagnostics.
135
+ *
136
+ * Reports a *discovery result* once one exists, so diagnostics describe what
137
+ * would actually run rather than the default that was bypassed. Discovery
138
+ * itself stays in {@link resolveElectronPath}: this accessor never triggers a
139
+ * search (the constructor must remain I/O-free, and callers may ask before
140
+ * any resolution has happened).
141
+ */
63
142
  helperPath(): string | undefined;
64
143
  /**
65
144
  * A protector key matching one of the requested envelope key ids. The first
@@ -70,7 +149,25 @@ declare class WorkBuddyAtRestKeyProvider {
70
149
  */
71
150
  protectorKeyFor(requested: readonly string[]): Promise<Buffer>;
72
151
  private ingest;
152
+ /**
153
+ * The binary to spawn, or a diagnosable error saying why there is none.
154
+ *
155
+ * Order is the contract: an explicit path is used as-is and never falls back;
156
+ * discovery runs only for a provider that was configured for it, and only
157
+ * after the platform default has been tried and found unusable.
158
+ */
159
+ private resolveElectronPath;
160
+ /**
161
+ * Resolve the CN app through Spotlight, then prove each candidate's identity
162
+ * before it can be executed.
163
+ *
164
+ * The whole flow shares one budget: a hang in one candidate must not extend
165
+ * the wait for the others, and running out of budget is reported as an
166
+ * unfinished check rather than an absent app.
167
+ */
168
+ private discoverMacosApp;
73
169
  private spawnPayload;
170
+ private spawnAt;
74
171
  }
75
172
  //#endregion
76
173
  //#region src/app-version.d.ts
@@ -726,6 +823,11 @@ interface WorkBuddyAuthStatus {
726
823
  * Present only on `signed-out`, and never a substitute for fixing the file.
727
824
  */
728
825
  reason?: string;
826
+ /**
827
+ * Machine-readable companion to {@link reason}, for callers that must branch
828
+ * on the cause. Never derived by matching `reason` text.
829
+ */
830
+ reasonCode?: WorkBuddySignedOutReasonCode;
729
831
  }
730
832
  /** Constructor options; only {@link refresh} is required. */
731
833
  interface WorkBuddyStoreOptions {
@@ -1104,6 +1206,8 @@ interface WorkBuddyAdapterOptions {
1104
1206
  catalog: WorkBuddyCatalog;
1105
1207
  /** Resolve the durable attachment service at request time, when present. */
1106
1208
  resolveAttachments?: () => AttachmentStore | undefined;
1209
+ /** Resolve one image's path in the current model-tool execution world. */
1210
+ resolveImageAccess?: NonNullable<PiAiAdapterOptions['resolveImageAccess']>;
1107
1211
  /**
1108
1212
  * Look up a local probe observation for a model. Consulted only for rows the
1109
1213
  * upstream left undeclared; absent means declared-set-only behavior.
@@ -1362,8 +1466,9 @@ declare function readHostHeartbeat(): Promise<WorkBuddyHostHeartbeat | undefined
1362
1466
  * - macOS / Linux: `ps -o lstart=` prints a local-time "EEE MMM DD HH:MM:SS YYYY";
1363
1467
  * `Date.parse` resolves it against the local clock, which matches how
1364
1468
  * `registeredAt` (a `Date.now()` absolute value) is expressed.
1365
- * - Windows: WMI `CreationDate` is UTC (`YYYYMMDDHHMMSS.mmm+zzzz`); parsed with
1366
- * `Date.UTC`, again comparable to `registeredAt`.
1469
+ * - Windows: `wmic` prints a CIM_DATETIME `CreationDate` — local fields plus a
1470
+ * signed minute offset (see {@link parseWmiCreationDate}); the epoch it
1471
+ * yields is comparable to `registeredAt`.
1367
1472
  *
1368
1473
  * Failures return `undefined` so callers can fall back to plain PID liveness
1369
1474
  * rather than mis-report a running host as dead.
package/lib/index.js CHANGED
@@ -1,12 +1,12 @@
1
- import { A as normalizeCredits, B as chatUserAgent, C as desktopAuthCandidatesFor, D as WorkBuddyUpstreamClient, E as WorkBuddyAtRestKeyProvider, F as PROBE_EFFORT_CANDIDATES, G as WORKBUDDY_APP_VERSION_FILENAME, H as readCliVersion, I as probeModel, J as readBundleVersion, K as appUserAgent, L as randomSentinel, M as prepareChatBody, N as prepareInternationalChatBody, O as classifyUpstreamError, P as regionOf, R as CN_APP_VERSION_FILENAME, S as defaultDesktopAuthPath, T as workbuddyOwnAuthPath, U as resolveChatIdentity, V as fallbackChatIdentity, W as validCliVersion, X as validAppVersion, Y as resolveAppVersion, _ as WorkBuddyCatalog, a as WORKBUDDY_HOST_HEARTBEAT_FILENAME, b as WorkBuddyCredentialStore, c as processStartTimeMs, d as writeHostHeartbeat, f as WORKBUDDY_CONNECT_VERSION, g as FALLBACK_WORKBUDDY_MODELS, h as FALLBACK_WORKBUDDY_AI_MODELS, i as variantFor, j as parseModelCatalog, k as modelWithCurrentPromotion, l as readHostHeartbeat, n as CN_VARIANT, o as clearHostHeartbeat, q as installedAppVersion, r as WORKBUDDY_VARIANTS, s as isHeartbeatProcessAlive, t as AI_VARIANT, u as workbuddyHostHeartbeatPath, v as WORKBUDDY_AUTH_FILENAME, w as parseWorkBuddyAuth, x as defaultDesktopAuthCandidates, y as WORKBUDDY_AUTH_FILE_ENV, z as FALLBACK_CN_APP_VERSION } from "./variants-CnrmSn0Q.js";
1
+ import { A as normalizeCredits, B as chatUserAgent, C as desktopAuthCandidatesFor, D as WorkBuddyUpstreamClient, E as WorkBuddyAtRestKeyProvider, F as PROBE_EFFORT_CANDIDATES, G as WORKBUDDY_APP_VERSION_FILENAME, H as readCliVersion, I as probeModel, J as readBundleVersion, K as appUserAgent, L as randomSentinel, M as prepareChatBody, N as prepareInternationalChatBody, O as classifyUpstreamError, P as regionOf, R as CN_APP_VERSION_FILENAME, S as defaultDesktopAuthPath, T as workbuddyOwnAuthPath, U as resolveChatIdentity, V as fallbackChatIdentity, W as validCliVersion, X as validAppVersion, Y as resolveAppVersion, _ as WorkBuddyCatalog, a as WORKBUDDY_HOST_HEARTBEAT_FILENAME, b as WorkBuddyCredentialStore, c as processStartTimeMs, d as writeHostHeartbeat, f as WORKBUDDY_CONNECT_VERSION, g as FALLBACK_WORKBUDDY_MODELS, h as FALLBACK_WORKBUDDY_AI_MODELS, i as variantFor, j as parseModelCatalog, k as modelWithCurrentPromotion, l as readHostHeartbeat, n as CN_VARIANT, o as clearHostHeartbeat, q as installedAppVersion, r as WORKBUDDY_VARIANTS, s as isHeartbeatProcessAlive, t as AI_VARIANT, u as workbuddyHostHeartbeatPath, v as WORKBUDDY_AUTH_FILENAME, w as parseWorkBuddyAuth, x as defaultDesktopAuthCandidates, y as WORKBUDDY_AUTH_FILE_ENV, z as FALLBACK_CN_APP_VERSION } from "./variants-B3Pa4EBr.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
+ import { resolveImageAttachmentAccess, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
3
4
  import { dirname, join, resolve } from "node:path";
4
5
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
5
6
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
6
7
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7
8
  import { createProvider } from "@earendil-works/pi-ai";
8
9
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
9
- import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
10
10
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
11
11
  import { createServer } from "node:http";
12
12
  import { Readable } from "node:stream";
@@ -328,6 +328,7 @@ const NO_COST = {
328
328
  */
329
329
  function withLegacyImageBudget(store) {
330
330
  return new Proxy(store, { get(target, property, receiver) {
331
+ if (property === "imageHostPath") return target.imageHostPath.bind(target);
331
332
  if (property !== "readImageRequest") return Reflect.get(target, property, receiver);
332
333
  return (...args) => {
333
334
  const [ref, policy, signal] = args;
@@ -458,7 +459,7 @@ function toPiModel(info, baseUrl, observed, providerId = WORKBUDDY_PROVIDER) {
458
459
  * `modelErrors` since 0.1.5-alpha.2 (#12).
459
460
  */
460
461
  function createWorkBuddyAdapter(options) {
461
- const { shim, store, catalog, resolveAttachments, observe, hidden } = options;
462
+ const { shim, store, catalog, resolveAttachments, resolveImageAccess, observe, hidden } = options;
462
463
  const providerId = options.providerId ?? "workbuddy";
463
464
  const displayName = options.displayName ?? "WorkBuddy";
464
465
  const buildModels = () => {
@@ -503,7 +504,8 @@ function createWorkBuddyAdapter(options) {
503
504
  ...resolveAttachments === void 0 ? {} : { resolveAttachments: () => {
504
505
  const store = resolveAttachments();
505
506
  return store === void 0 ? void 0 : withLegacyImageBudget(store);
506
- } }
507
+ } },
508
+ ...resolveImageAccess === void 0 ? {} : { resolveImageAccess }
507
509
  }),
508
510
  invalidate: () => {
509
511
  profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
@@ -1170,7 +1172,8 @@ async function workBuddyWebStatus(deps) {
1170
1172
  const authStatus = await deps.store.status();
1171
1173
  if (authStatus.state !== "signed-in") return {
1172
1174
  status: "signed-out",
1173
- ...authStatus.reason === void 0 ? {} : { reason: authStatus.reason }
1175
+ ...authStatus.reason === void 0 ? {} : { reason: authStatus.reason },
1176
+ ...authStatus.reasonCode === void 0 ? {} : { reasonCode: authStatus.reasonCode }
1174
1177
  };
1175
1178
  const status = {
1176
1179
  status: "signed-in",
@@ -1706,6 +1709,7 @@ async function startVariant(ctx, runtime) {
1706
1709
  store,
1707
1710
  catalog,
1708
1711
  resolveAttachments: () => ctx.get("attachments"),
1712
+ resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(attachments, (hostPath) => ctx.get("fs")?.processPathFromHostPath(hostPath), ref),
1709
1713
  observe: (modelId) => probeService.recordFor(modelId),
1710
1714
  hidden: () => {
1711
1715
  const account = runtime.account();
@@ -1762,8 +1766,8 @@ function apply(ctx, config) {
1762
1766
  * case that must not fall back to a shared preference bucket.
1763
1767
  */
1764
1768
  const lastAccounts = /* @__PURE__ */ new Map();
1765
- const atRestKeys = new WorkBuddyAtRestKeyProvider();
1766
- const runtimes = WORKBUDDY_VARIANTS.map((variant) => createVariantRuntime(config, variant, () => current(), (id) => lastIdentities.get(id), (id) => lastAccounts.get(id), atRestKeys));
1769
+ const atRestKeysFor = (variant) => new WorkBuddyAtRestKeyProvider({ discovery: variant.id === CN_VARIANT.id ? "macos-workbuddy" : "none" });
1770
+ const runtimes = WORKBUDDY_VARIANTS.map((variant) => createVariantRuntime(config, variant, () => current(), (id) => lastIdentities.get(id), (id) => lastAccounts.get(id), atRestKeysFor(variant)));
1767
1771
  const probeKey = createProbeKey();
1768
1772
  let setMaximumContextWindow;
1769
1773
  /**
@@ -5,7 +5,7 @@ import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
5
5
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
6
6
  import { createDecipheriv, createHash, randomBytes } from "node:crypto";
7
7
  import { execFile, execFileSync } from "node:child_process";
8
- import { accessSync, constants } from "node:fs";
8
+ import { accessSync, constants, realpathSync, statSync } from "node:fs";
9
9
  //#region src/app-version.ts
10
10
  /**
11
11
  * The international desktop app's version, used as the `/v3/config` UA.
@@ -1389,10 +1389,11 @@ function prepareInternationalChatBody(source) {
1389
1389
  return prepared;
1390
1390
  }
1391
1391
  if (!isObject(body)) return prepared;
1392
+ dropUnsupportedEffort(body);
1392
1393
  const messages = body["messages"];
1393
- if (!Array.isArray(messages)) return prepared;
1394
+ if (!Array.isArray(messages)) return JSON.stringify(body);
1394
1395
  const first = messages[0];
1395
- if (isObject(first) && first["role"] === "system") return prepared;
1396
+ if (isObject(first) && first["role"] === "system") return JSON.stringify(body);
1396
1397
  messages.unshift({
1397
1398
  role: "system",
1398
1399
  content: INTERNATIONAL_SYSTEM_PROMPT
@@ -1400,6 +1401,30 @@ function prepareInternationalChatBody(source) {
1400
1401
  return JSON.stringify(body);
1401
1402
  }
1402
1403
  /**
1404
+ * Remove the adapter's own `off` effort spelling from the **international** wire.
1405
+ *
1406
+ * `thinkingLevelMap.off` is pinned to the literal `'off'` for models that
1407
+ * declare `canDisableThinking`, so that the level stays selectable. pi-ai
1408
+ * sends that value for any request carrying no explicit level, and the
1409
+ * international endpoint rejects it on the GPT family with HTTP 400 `11133` /
1410
+ * `extError.param === 'reasoning.effort'` (issue #49). Omission is the only
1411
+ * form measured good on every such model; a literal `'none'` is *not* a safe
1412
+ * substitute — accepted by the GPT-5.6 family and GLM, rejected by
1413
+ * `gpt-6-astra`.
1414
+ *
1415
+ * Consequences, stated honestly: the international picker still offers Off,
1416
+ * but selecting it now means "the field is omitted" — the model's actual
1417
+ * behaviour is decided upstream and is *not* guaranteed to disable thinking
1418
+ * or to match the catalog's `defaultEffort`. Declared spellings
1419
+ * (`low`/`medium`/`high`/`xhigh`/`max`) and an explicit `none` pass through
1420
+ * untouched. The CN variant is deliberately unaffected: its endpoint has
1421
+ * accepted this spelling in every measurement so far, and keeping its wire
1422
+ * unchanged is a scope decision, not a claim about that endpoint's future.
1423
+ */
1424
+ function dropUnsupportedEffort(obj) {
1425
+ if (obj["reasoning_effort"] === "off") delete obj["reasoning_effort"];
1426
+ }
1427
+ /**
1403
1428
  * The system prompt injected when the international endpoint receives a body
1404
1429
  * with none.
1405
1430
  *
@@ -1636,26 +1661,145 @@ function parseAtRestPayload(text) {
1636
1661
  function deriveProtectorKey(secret) {
1637
1662
  return createHash("sha256").update(secret, "utf8").digest();
1638
1663
  }
1664
+ /** The CN app's bundle id; the only one this round resolves by discovery. */
1665
+ const WORKBUDDY_CN_BUNDLE_ID = "com.tencent.workbuddy.mac";
1666
+ /** Absolute tool paths: never resolved through PATH, which a user can change. */
1667
+ const MDFIND_BIN = "/usr/bin/mdfind";
1668
+ const PLUTIL_BIN = "/usr/bin/plutil";
1669
+ /** One discovery subprocess's own limits; see {@link WorkBuddyAtRestKeyProviderOptions}. */
1670
+ const WORKBUDDY_DISCOVERY_STEP_TIMEOUT_MS = 3e3;
1671
+ const MDFIND_MAX_OUTPUT_BYTES = 1048576;
1672
+ const PLUTIL_MAX_OUTPUT_BYTES = 65536;
1673
+ /**
1674
+ * Why a discovery step could not produce an answer. Every one of these means
1675
+ * "we do not know", explicitly *not* "the candidate does not exist" — the
1676
+ * distinction is what keeps a half-finished check from being mistaken for a
1677
+ * unique candidate.
1678
+ */
1679
+ var DiscoveryIncompleteError = class extends Error {};
1680
+ /**
1681
+ * The default discovery tools: Spotlight for the bundle, `/usr/bin/plutil` for
1682
+ * identity. Every failure that means "we could not tell" — a missing tool, a
1683
+ * timeout, an oversized answer — is raised as {@link DiscoveryIncompleteError}
1684
+ * so it can never be silently read as "no such app".
1685
+ */
1686
+ function workBuddyDiscoveryTools() {
1687
+ const runTool = (bin, args, maxBytes, signal) => new Promise((resolve, reject) => {
1688
+ if (signal.aborted) {
1689
+ reject(new DiscoveryIncompleteError(`${bin} was not started: the discovery budget was already spent`));
1690
+ return;
1691
+ }
1692
+ let settled = false;
1693
+ const child = execFile(bin, [...args], {
1694
+ maxBuffer: maxBytes,
1695
+ timeout: WORKBUDDY_DISCOVERY_STEP_TIMEOUT_MS
1696
+ }, (error, stdout) => {
1697
+ if (settled) return;
1698
+ settled = true;
1699
+ if (error !== null && error !== void 0) {
1700
+ reject(new DiscoveryIncompleteError(`${bin} could not complete (${error.killed === true ? "timed out" : String(error.code ?? "unavailable")})`));
1701
+ return;
1702
+ }
1703
+ resolve(stdout);
1704
+ });
1705
+ const abort = () => {
1706
+ if (settled) return;
1707
+ settled = true;
1708
+ child.kill();
1709
+ reject(new DiscoveryIncompleteError(`${bin} was abandoned: the discovery budget was spent`));
1710
+ };
1711
+ signal.addEventListener("abort", abort, { once: true });
1712
+ child.on("close", () => {
1713
+ signal.removeEventListener("abort", abort);
1714
+ });
1715
+ });
1716
+ return {
1717
+ findApps: async (signal) => {
1718
+ return (await runTool(MDFIND_BIN, [`kMDItemCFBundleIdentifier == '${WORKBUDDY_CN_BUNDLE_ID}'`], MDFIND_MAX_OUTPUT_BYTES, signal)).split("\n").map((line) => line.trim()).filter((line) => line.endsWith(".app"));
1719
+ },
1720
+ bundleIdentifier: async (bundlePath, signal) => {
1721
+ try {
1722
+ return (await runTool(PLUTIL_BIN, [
1723
+ "-extract",
1724
+ "CFBundleIdentifier",
1725
+ "raw",
1726
+ "-o",
1727
+ "-",
1728
+ join(bundlePath, "Contents", "Info.plist")
1729
+ ], PLUTIL_MAX_OUTPUT_BYTES, signal)).trim();
1730
+ } catch {
1731
+ return;
1732
+ }
1733
+ },
1734
+ bundleVersion: async (bundlePath, signal) => {
1735
+ try {
1736
+ const version = (await runTool(PLUTIL_BIN, [
1737
+ "-extract",
1738
+ "CFBundleShortVersionString",
1739
+ "raw",
1740
+ "-o",
1741
+ "-",
1742
+ join(bundlePath, "Contents", "Info.plist")
1743
+ ], PLUTIL_MAX_OUTPUT_BYTES, signal)).trim();
1744
+ return version === "" ? void 0 : version;
1745
+ } catch {
1746
+ return;
1747
+ }
1748
+ }
1749
+ };
1750
+ }
1639
1751
  /**
1640
1752
  * In-memory protector-key resolver: one spawn per key id, single-flight, never
1641
1753
  * persisted. The cache is keyed by the id envelopes ask for, so an envelope
1642
1754
  * sealed under a rotated key triggers exactly one fresh resolution.
1643
- */
1644
- var WorkBuddyAtRestKeyProvider = class {
1645
- electronPath;
1755
+ */ var WorkBuddyAtRestKeyProvider = class {
1756
+ /**
1757
+ * The explicit binary, when one was configured. `undefined` here means "the
1758
+ * caller did not name one", which is what lets discovery run — an explicit
1759
+ * path that turns out to be unusable is an error, never a reason to look for
1760
+ * a different app.
1761
+ */
1762
+ explicitPath;
1763
+ defaultPath;
1764
+ discovery;
1765
+ tools;
1766
+ discoveryBudgetMs;
1646
1767
  timeoutMs;
1647
1768
  source;
1769
+ spawnHelper;
1770
+ /**
1771
+ * The path discovery settled on, cached only on success. A failure leaves
1772
+ * this unset so the next attempt tries again — the user may install or move
1773
+ * the app without restarting DSH.
1774
+ */
1775
+ discoveredPath;
1648
1776
  cache;
1649
1777
  inflight;
1650
1778
  constructor(options = {}) {
1651
1779
  const fromEnv = process.env[WORKBUDDY_ELECTRON_BIN_ENV]?.trim();
1652
- this.electronPath = options.electronPath ?? (fromEnv !== void 0 && fromEnv !== "" ? fromEnv : void 0) ?? defaultWorkBuddyElectronPath();
1780
+ const envPath = fromEnv === void 0 || fromEnv === "" ? void 0 : fromEnv;
1781
+ this.explicitPath = options.electronPath ?? envPath;
1782
+ this.discovery = options.discovery ?? "none";
1783
+ this.defaultPath = options.defaultElectronPath === void 0 ? defaultWorkBuddyElectronPath() : options.defaultElectronPath ?? void 0;
1784
+ this.tools = options.tools ?? workBuddyDiscoveryTools();
1785
+ this.discoveryBudgetMs = options.discoveryBudgetMs ?? 1e4;
1653
1786
  this.timeoutMs = options.timeoutMs ?? 1e4;
1787
+ this.spawnHelper = options.spawnHelper ?? ((path) => this.spawnAt(path));
1654
1788
  this.source = options.source ?? (() => this.spawnPayload());
1655
1789
  }
1656
- /** The binary the default helper would use, for diagnostics. */
1790
+ /**
1791
+ * The binary the default helper would use, for diagnostics.
1792
+ *
1793
+ * Reports a *discovery result* once one exists, so diagnostics describe what
1794
+ * would actually run rather than the default that was bypassed. Discovery
1795
+ * itself stays in {@link resolveElectronPath}: this accessor never triggers a
1796
+ * search (the constructor must remain I/O-free, and callers may ask before
1797
+ * any resolution has happened).
1798
+ */
1657
1799
  helperPath() {
1658
- return this.electronPath;
1800
+ if (this.explicitPath !== void 0) return this.explicitPath;
1801
+ if (this.discovery === "none") return void 0;
1802
+ return this.discoveredPath ?? this.defaultPath;
1659
1803
  }
1660
1804
  /**
1661
1805
  * A protector key matching one of the requested envelope key ids. The first
@@ -1665,19 +1809,19 @@ var WorkBuddyAtRestKeyProvider = class {
1665
1809
  * reach will open them.
1666
1810
  */
1667
1811
  async protectorKeyFor(requested) {
1668
- if (requested.length === 0) throw new Error("encrypted desktop credential carries no key ids");
1812
+ if (requested.length === 0) throw new WorkBuddyElectronPathError("encrypted-credential-unreadable", "encrypted desktop credential carries no key ids");
1669
1813
  const cached = this.cache;
1670
1814
  if (cached !== void 0 && requested.includes(cached.keyId)) return cached.key;
1671
1815
  this.inflight ??= this.source().then((text) => this.ingest(text)).finally(() => {
1672
1816
  this.inflight = void 0;
1673
1817
  });
1674
1818
  const resolved = await this.inflight;
1675
- if (!requested.includes(resolved.keyId)) throw new Error(`WorkBuddy's current at-rest key (id ${resolved.keyId}) does not match the credential's envelope (id ${requested.join(" or ")}); the desktop credential was sealed by a different WorkBuddy installation`);
1819
+ if (!requested.includes(resolved.keyId)) throw new WorkBuddyElectronPathError("encrypted-credential-unreadable", `WorkBuddy's current at-rest key (id ${resolved.keyId}) does not match the credential's envelope (id ${requested.join(" or ")}); the desktop credential was sealed by a different WorkBuddy installation`);
1676
1820
  return resolved.key;
1677
1821
  }
1678
1822
  ingest(text) {
1679
1823
  const payload = parseAtRestPayload(text);
1680
- if (payload === void 0) throw new Error("WorkBuddy key helper returned an unusable at-rest payload (expected {version:1, atRestSecretKey})");
1824
+ if (payload === void 0) throw new WorkBuddyElectronPathError("encrypted-credential-unreadable", "WorkBuddy key helper returned an unusable at-rest payload (expected {version:1, atRestSecretKey})");
1681
1825
  const key = deriveProtectorKey(payload.atRestSecretKey);
1682
1826
  const resolved = {
1683
1827
  key,
@@ -1686,14 +1830,103 @@ var WorkBuddyAtRestKeyProvider = class {
1686
1830
  this.cache = resolved;
1687
1831
  return resolved;
1688
1832
  }
1689
- async spawnPayload() {
1690
- const electronPath = this.electronPath;
1691
- if (electronPath === void 0) throw new Error(`no WorkBuddy Electron binary is known for ${process.platform}; set ${WORKBUDDY_ELECTRON_BIN_ENV} to the app's Electron binary`);
1833
+ /**
1834
+ * The binary to spawn, or a diagnosable error saying why there is none.
1835
+ *
1836
+ * Order is the contract: an explicit path is used as-is and never falls back;
1837
+ * discovery runs only for a provider that was configured for it, and only
1838
+ * after the platform default has been tried and found unusable.
1839
+ */
1840
+ async resolveElectronPath() {
1841
+ if (this.explicitPath !== void 0) {
1842
+ if (!isExecutable(this.explicitPath)) throw new WorkBuddyElectronPathError("electron-path-invalid", `the configured WorkBuddy Electron binary is not available at ${this.explicitPath}; check ${WORKBUDDY_ELECTRON_BIN_ENV} or unset it to let the plugin look for the app itself`);
1843
+ return this.explicitPath;
1844
+ }
1845
+ if (this.discovery === "none") throw new WorkBuddyElectronPathError("electron-binary-unavailable", `no WorkBuddy Electron binary is configured for this platform; set ${WORKBUDDY_ELECTRON_BIN_ENV} to the app's Electron binary`);
1846
+ if (this.defaultPath !== void 0 && isExecutable(this.defaultPath)) return this.defaultPath;
1847
+ if (this.discoveredPath !== void 0) {
1848
+ if (isExecutable(this.discoveredPath)) return this.discoveredPath;
1849
+ this.discoveredPath = void 0;
1850
+ }
1851
+ const found = await this.discoverMacosApp();
1852
+ this.discoveredPath = found;
1853
+ return found;
1854
+ }
1855
+ /**
1856
+ * Resolve the CN app through Spotlight, then prove each candidate's identity
1857
+ * before it can be executed.
1858
+ *
1859
+ * The whole flow shares one budget: a hang in one candidate must not extend
1860
+ * the wait for the others, and running out of budget is reported as an
1861
+ * unfinished check rather than an absent app.
1862
+ */
1863
+ async discoverMacosApp() {
1864
+ if (process.platform !== "darwin") throw new WorkBuddyElectronPathError("electron-binary-unavailable", `no WorkBuddy Electron binary is configured for this platform; set ${WORKBUDDY_ELECTRON_BIN_ENV} to the app's Electron binary`);
1865
+ const controller = new AbortController();
1866
+ const timer = setTimeout(() => controller.abort(), this.discoveryBudgetMs);
1692
1867
  try {
1693
- accessSync(electronPath, constants.X_OK);
1694
- } catch {
1695
- throw new Error(`the WorkBuddy Electron binary is not available at ${electronPath}; set ${WORKBUDDY_ELECTRON_BIN_ENV} if it lives elsewhere`);
1868
+ let candidates;
1869
+ try {
1870
+ candidates = await this.tools.findApps(controller.signal);
1871
+ } catch {
1872
+ throw discoveryIncomplete("the app search did not complete");
1873
+ }
1874
+ const seen = /* @__PURE__ */ new Map();
1875
+ let unresolved = false;
1876
+ for (const candidate of candidates) {
1877
+ try {
1878
+ statSync(candidate);
1879
+ } catch (error) {
1880
+ if (isENOENT$1(error)) continue;
1881
+ unresolved = true;
1882
+ continue;
1883
+ }
1884
+ let bundleIdentifier;
1885
+ try {
1886
+ bundleIdentifier = await this.tools.bundleIdentifier(candidate, controller.signal);
1887
+ } catch {
1888
+ unresolved = true;
1889
+ continue;
1890
+ }
1891
+ if (bundleIdentifier === void 0) {
1892
+ unresolved = true;
1893
+ continue;
1894
+ }
1895
+ if (bundleIdentifier !== "com.tencent.workbuddy.mac") continue;
1896
+ const electronPath = join(candidate, "Contents", "MacOS", "Electron");
1897
+ if (!isExecutable(electronPath)) continue;
1898
+ let identity;
1899
+ try {
1900
+ identity = realpathSync(candidate);
1901
+ } catch {
1902
+ identity = candidate;
1903
+ }
1904
+ if (seen.has(identity)) continue;
1905
+ let version;
1906
+ try {
1907
+ version = await this.tools.bundleVersion(candidate, controller.signal);
1908
+ } catch {
1909
+ version = void 0;
1910
+ }
1911
+ seen.set(identity, {
1912
+ bundlePath: candidate,
1913
+ electronPath,
1914
+ ...version === void 0 ? {} : { version }
1915
+ });
1916
+ }
1917
+ if (seen.size > 1) throw new WorkBuddyElectronPathError("electron-binary-ambiguous", `more than one WorkBuddy application was found, so none was chosen:\n${[...seen.values()].map((app) => ` - ${app.bundlePath}${app.version === void 0 ? "" : ` (${app.version})`}`).join("\n")}\n set ${WORKBUDDY_ELECTRON_BIN_ENV} to the one to use`);
1918
+ if (unresolved) throw discoveryIncomplete("some candidates could not be checked");
1919
+ if (seen.size === 0) throw new WorkBuddyElectronPathError("electron-binary-not-found", "no WorkBuddy application was found in the default location or the system index; if WorkBuddy is installed elsewhere, it may not be indexed yet");
1920
+ return [...seen.values()][0].electronPath;
1921
+ } finally {
1922
+ clearTimeout(timer);
1923
+ controller.abort();
1696
1924
  }
1925
+ }
1926
+ async spawnPayload() {
1927
+ return await this.spawnHelper(await this.resolveElectronPath());
1928
+ }
1929
+ async spawnAt(electronPath) {
1697
1930
  return await new Promise((resolve, reject) => {
1698
1931
  execFile(electronPath, [HELPER_SCRIPT_ARGUMENT_FLAG, HELPER_SCRIPT], {
1699
1932
  timeout: this.timeoutMs,
@@ -1705,13 +1938,12 @@ var WorkBuddyAtRestKeyProvider = class {
1705
1938
  }
1706
1939
  }, (error, stdout) => {
1707
1940
  if (error !== null && error !== void 0) {
1708
- const reason = error.killed === true ? `timed out or was killed after ${String(this.timeoutMs)}ms` : error.code !== void 0 ? `exited with code ${String(error.code)}` : "could not be started";
1709
- reject(/* @__PURE__ */ new Error(`the WorkBuddy key helper (${electronPath}) ${reason}`));
1941
+ reject(new WorkBuddyElectronPathError("encrypted-credential-unreadable", `the WorkBuddy key helper (${electronPath}) ${error.killed === true ? `timed out or was killed after ${String(this.timeoutMs)}ms` : error.code !== void 0 ? `exited with code ${String(error.code)}` : "could not be started"}`));
1710
1942
  return;
1711
1943
  }
1712
1944
  const output = stdout.trim();
1713
1945
  if (output === "") {
1714
- reject(/* @__PURE__ */ new Error(`the WorkBuddy key helper (${electronPath}) produced no payload`));
1946
+ reject(new WorkBuddyElectronPathError("encrypted-credential-unreadable", `the WorkBuddy key helper (${electronPath}) produced no payload`));
1715
1947
  return;
1716
1948
  }
1717
1949
  resolve(output);
@@ -1719,6 +1951,39 @@ var WorkBuddyAtRestKeyProvider = class {
1719
1951
  });
1720
1952
  }
1721
1953
  };
1954
+ /** Whether a path exists and is executable; never throws. */
1955
+ function isExecutable(path) {
1956
+ try {
1957
+ accessSync(path, constants.X_OK);
1958
+ return true;
1959
+ } catch {
1960
+ return false;
1961
+ }
1962
+ }
1963
+ /**
1964
+ * A failure the card must be able to classify. The code travels with the error
1965
+ * so the store can promote it to `reasonCode` without re-deriving the cause
1966
+ * from prose.
1967
+ */
1968
+ var WorkBuddyElectronPathError = class extends Error {
1969
+ reasonCode;
1970
+ constructor(reasonCode, message) {
1971
+ super(message);
1972
+ this.name = "WorkBuddyElectronPathError";
1973
+ this.reasonCode = reasonCode;
1974
+ }
1975
+ };
1976
+ /** Read the reason code off an arbitrary thrown value, when it carries one. */
1977
+ function reasonCodeOf(error) {
1978
+ return error instanceof WorkBuddyElectronPathError ? error.reasonCode : void 0;
1979
+ }
1980
+ /** Whether a filesystem error reports an absent path (`existsSync` cannot tell). */
1981
+ function isENOENT$1(error) {
1982
+ return error?.code === "ENOENT";
1983
+ }
1984
+ function discoveryIncomplete(detail) {
1985
+ return new WorkBuddyElectronPathError("electron-discovery-incomplete", `the WorkBuddy application search did not finish (${detail}); this is not proof that the app is missing`);
1986
+ }
1722
1987
  /**
1723
1988
  * The helper: run inside WorkBuddy's Electron as plain Node, where the
1724
1989
  * private `workbuddyStorage` binding exists, and print only the payload. It
@@ -1971,7 +2236,7 @@ var WorkBuddyCredentialStore = class {
1971
2236
  if (this.variant !== void 0) for (const [label, credential] of [["desktop file", desktop], ["plugin copy", own]]) {
1972
2237
  if (credential === void 0) continue;
1973
2238
  const region = regionOf(credential.domain);
1974
- if (region !== this.variant.region) throw new Error(`${this.variant.displayName} received a ${region === "cn" ? "WorkBuddy (CN)" : "WorkBuddy AI"} credential in its ${label} (domain ${JSON.stringify(credential.domain)}); point ${this.variant.env} at the ${this.variant.appName} sign-in, or remove the mismatched file`);
2239
+ if (region !== this.variant.region) throw new WorkBuddyElectronPathError("credential-region-mismatch", `${this.variant.displayName} received a ${region === "cn" ? "WorkBuddy (CN)" : "WorkBuddy AI"} credential in its ${label} (domain ${JSON.stringify(credential.domain)}); point ${this.variant.env} at the ${this.variant.appName} sign-in, or remove the mismatched file`);
1975
2240
  }
1976
2241
  if (desktop === void 0) return own;
1977
2242
  if (own === void 0) return desktop;
@@ -2000,7 +2265,10 @@ var WorkBuddyCredentialStore = class {
2000
2265
  async status() {
2001
2266
  try {
2002
2267
  const credential = await this.current();
2003
- if (credential === void 0) return { state: "signed-out" };
2268
+ if (credential === void 0) return {
2269
+ state: "signed-out",
2270
+ reasonCode: "no-credential"
2271
+ };
2004
2272
  return {
2005
2273
  state: "signed-in",
2006
2274
  expiresAtMs: credential.expiresAtMs,
@@ -2012,7 +2280,8 @@ var WorkBuddyCredentialStore = class {
2012
2280
  } catch (error) {
2013
2281
  return {
2014
2282
  state: "signed-out",
2015
- reason: error instanceof Error ? error.message : String(error)
2283
+ reason: error instanceof Error ? error.message : String(error),
2284
+ ...reasonCodeOf(error) === void 0 ? {} : { reasonCode: reasonCodeOf(error) }
2016
2285
  };
2017
2286
  }
2018
2287
  }
@@ -2092,7 +2361,7 @@ var WorkBuddyCredentialStore = class {
2092
2361
  const key = await this.keyProvider.protectorKeyFor(keyIdsOf(wrapped.fields));
2093
2362
  return parseWorkBuddyAuth(unwrapDesktopAuthDocument(classification, (field) => {
2094
2363
  const plaintext = openAuthField(key, field.envelope);
2095
- if (plaintext === void 0) throw new Error(`the encrypted desktop credential's ${field.field} could not be decrypted (envelope key id ${field.envelope.keyId}); the WorkBuddy app may hold a different at-rest key — open it once to reseal the sign-in`);
2364
+ if (plaintext === void 0) throw new WorkBuddyElectronPathError("encrypted-credential-unreadable", `the encrypted desktop credential's ${field.field} could not be decrypted (envelope key id ${field.envelope.keyId}); the WorkBuddy app may hold a different at-rest key — open it once to reseal the sign-in`);
2096
2365
  return plaintext;
2097
2366
  }));
2098
2367
  }
@@ -2960,7 +3229,7 @@ const WORKBUDDY_AI_STATUS_PATH = "/plugins/dsh-workbuddy-connect/ai/status";
2960
3229
  const WORKBUDDY_AI_PROBE_PATH = "/plugins/dsh-workbuddy-connect/ai/probe";
2961
3230
  //#endregion
2962
3231
  //#region src/version.ts
2963
- const WORKBUDDY_CONNECT_VERSION = "0.6.0";
3232
+ const WORKBUDDY_CONNECT_VERSION = "0.6.2";
2964
3233
  //#endregion
2965
3234
  //#region src/host-heartbeat.ts
2966
3235
  /**
@@ -3026,14 +3295,44 @@ async function readHostHeartbeat() {
3026
3295
  } catch {}
3027
3296
  }
3028
3297
  /**
3298
+ * Parse a WMI `CreationDate` (CIM_DATETIME) into epoch milliseconds; returns
3299
+ * `undefined` for anything that does not match the format, never throws.
3300
+ *
3301
+ * The CIM_DATETIME layout is `yyyymmddHHMMSS.mmmmmmsUUU`: local wall-clock
3302
+ * fields, a 6-digit microsecond fraction, and a **3-digit signed UTC offset
3303
+ * in minutes** (`+000`, `+480` for UTC+8, `-300` for UTC−5). The fields are
3304
+ * therefore *not* UTC — the offset must be subtracted to obtain the epoch:
3305
+ * `+480` means local time runs 480 minutes ahead of UTC, so
3306
+ * `20260923104314.239907+480` is `2026-09-23T02:43:14.000Z`. A 4-digit offset
3307
+ * is not part of the format and is rejected rather than partially matched.
3308
+ */
3309
+ function parseWmiCreationDate(value) {
3310
+ const m = value.trim().match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.(\d+)([+-]\d{3})$/);
3311
+ if (m === null) return void 0;
3312
+ const [, y, mo, d, h, mi, s, , offset] = m;
3313
+ const [year, month, day, hour, minute, second] = [
3314
+ y,
3315
+ mo,
3316
+ d,
3317
+ h,
3318
+ mi,
3319
+ s
3320
+ ].map(Number);
3321
+ if (month < 1 || month > 12 || day < 1 || day > 31) return void 0;
3322
+ if (hour > 23 || minute > 59 || second > 59) return void 0;
3323
+ const epoch = Date.UTC(year, month - 1, day, hour, minute, second) - Number(offset) * 6e4;
3324
+ return Number.isFinite(epoch) ? epoch : void 0;
3325
+ }
3326
+ /**
3029
3327
  * Absolute start time (epoch ms) of the process holding `pid`, or `undefined`
3030
3328
  * when it cannot be determined (no such PID, platform lacks a readable source).
3031
3329
  *
3032
3330
  * - macOS / Linux: `ps -o lstart=` prints a local-time "EEE MMM DD HH:MM:SS YYYY";
3033
3331
  * `Date.parse` resolves it against the local clock, which matches how
3034
3332
  * `registeredAt` (a `Date.now()` absolute value) is expressed.
3035
- * - Windows: WMI `CreationDate` is UTC (`YYYYMMDDHHMMSS.mmm+zzzz`); parsed with
3036
- * `Date.UTC`, again comparable to `registeredAt`.
3333
+ * - Windows: `wmic` prints a CIM_DATETIME `CreationDate` — local fields plus a
3334
+ * signed minute offset (see {@link parseWmiCreationDate}); the epoch it
3335
+ * yields is comparable to `registeredAt`.
3037
3336
  *
3038
3337
  * Failures return `undefined` so callers can fall back to plain PID liveness
3039
3338
  * rather than mis-report a running host as dead.
@@ -3041,7 +3340,7 @@ async function readHostHeartbeat() {
3041
3340
  function processStartTimeMs(pid) {
3042
3341
  try {
3043
3342
  if (process.platform === "win32") {
3044
- const m = execFileSync("wmic", [
3343
+ const token = execFileSync("wmic", [
3045
3344
  "process",
3046
3345
  "where",
3047
3346
  `processid=${pid}`,
@@ -3050,11 +3349,9 @@ function processStartTimeMs(pid) {
3050
3349
  ], {
3051
3350
  encoding: "utf8",
3052
3351
  windowsHide: true
3053
- }).match(/(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.\d+([+-]\d{4})/);
3054
- if (m === null) return void 0;
3055
- const [, y, mo, d, h, mi, s] = m;
3056
- const ms = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s));
3057
- return Number.isFinite(ms) ? ms : void 0;
3352
+ }).match(/(\d{14})\.(\d+)([+-]\d{3})(?=\s|$)/)?.[0];
3353
+ if (token === void 0) return void 0;
3354
+ return parseWmiCreationDate(token);
3058
3355
  }
3059
3356
  const out = execFileSync("ps", [
3060
3357
  "-o",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-workbuddy-connect",
3
3
  "displayName": "DSH WorkBuddy Connect",
4
4
  "description": "将 WorkBuddy 桌面 App 包含的模型自动接入 DeepSeek Harness — bring WorkBuddy desktop app models into DeepSeek Harness with zero configuration.",
5
- "version": "0.6.0",
5
+ "version": "0.6.2",
6
6
  "author": "corrinehu",
7
7
  "keywords": [
8
8
  "dsh-plugin",