dsh-workbuddy-bridge 0.5.5 → 0.5.6

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 { C as variantFor, D as WorkBuddyUpstreamClient, S as WORKBUDDY_VARIANTS, Z as resolveAppVersion, a as readHostHeartbeat, c as WORKBUDDY_BRIDGE_VERSION, l as FALLBACK_WORKBUDDY_AI_MODELS, m as WorkBuddyCredentialStore, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, x as CN_VARIANT } from "./heartbeat-DYtNMdMm.js";
2
+ import { C as variantFor, D as WorkBuddyUpstreamClient, Q as resolveAppVersion, S as WORKBUDDY_VARIANTS, a as readHostHeartbeat, c as WORKBUDDY_BRIDGE_VERSION, l as FALLBACK_WORKBUDDY_AI_MODELS, m as WorkBuddyCredentialStore, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, x as CN_VARIANT } from "./heartbeat-BOSjBL0B.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/cli/bin.ts
@@ -367,14 +367,16 @@ const UPSTREAM_CODE_KINDS = {
367
367
  "12153": "session_dead",
368
368
  "11128": "client",
369
369
  "11133": "client",
370
- "11140": "client"
370
+ "11140": "client",
371
+ "11158": "client"
371
372
  };
372
373
  /** 业务码 → 失败类别:信封 code 非 0 时的第一落点。 */
373
374
  const ENVELOPE_CODE_KINDS = {
374
375
  12153: "session_dead",
375
376
  11128: "client",
376
377
  11133: "client",
377
- 11140: "client"
378
+ 11140: "client",
379
+ 11158: "client"
378
380
  };
379
381
  /**
380
382
  * 文案兜底标记。**只在既无机器码也无业务码可依时使用**,且命中后必须记日志:
@@ -692,6 +694,30 @@ async function probeModel(options) {
692
694
  *
693
695
  * @module dsh-workbuddy-bridge/upstream
694
696
  */
697
+ /**
698
+ * Wait before each gate retry, in milliseconds; the length is the retry budget.
699
+ *
700
+ * Measured 2026-11-06 (CN gateway): the first attempt of a conversation is refused
701
+ * and the identical request answers 200 at +0.74 s. Two steps cover that window
702
+ * with room to spare; a third would only add latency to a turn that is genuinely
703
+ * blocked.
704
+ */
705
+ const GATE_RETRY_DELAYS_MS = [400, 1200];
706
+ /** Abortable delay: a cancelled turn must not keep retrying. */
707
+ async function sleep(ms, signal) {
708
+ if (signal?.aborted === true) return;
709
+ await new Promise((resolve) => {
710
+ const timer = setTimeout(() => {
711
+ signal?.removeEventListener("abort", onAbort);
712
+ resolve();
713
+ }, ms);
714
+ const onAbort = () => {
715
+ clearTimeout(timer);
716
+ resolve();
717
+ };
718
+ signal?.addEventListener("abort", onAbort, { once: true });
719
+ });
720
+ }
695
721
  const CN_CHAT_BASE = "https://copilot.tencent.com";
696
722
  const CN_BILLING_BASE = "https://www.codebuddy.cn";
697
723
  const GLOBAL_BASE = "https://www.workbuddy.ai";
@@ -1057,8 +1083,8 @@ function neutralizeAssistantExtras(message) {
1057
1083
  */
1058
1084
  function rewriteChannelGateSentences(text) {
1059
1085
  let out = text;
1060
- for (const [from, to] of [...IDENTITY_REWRITES, ...FILE_REFERENCE_REWRITES]) if (out.includes(from)) out = out.split(from).join(to);
1061
- return out;
1086
+ for (const [from, to] of IDENTITY_REWRITES) if (out.includes(from)) out = out.split(from).join(to);
1087
+ return out.replace(FILE_REFERENCE_PARAGRAPH, FILE_REFERENCE_REPLACEMENT);
1062
1088
  }
1063
1089
  /**
1064
1090
  * Apply the signature rewrites to one string; see {@link neutralizeChannelSignature}.
@@ -1069,46 +1095,93 @@ function rewriteChannelGateSentences(text) {
1069
1095
  * so a U+200B separator was equivalent to not rewriting at all.
1070
1096
  */
1071
1097
  function neutralizeSignatureText(text) {
1072
- return rewriteChannelGateSentences(text.replace(CHANNEL_OPENER, "$1You are an AI agent.").replace(CHANNEL_SIGNATURE_PHRASE, "the agent runtime"));
1098
+ return replaceBrandPhrase(rewriteChannelGateSentences(text).replace(CHANNEL_OPENER, "$1You are an AI agent."));
1099
+ }
1100
+ /** What the product phrase becomes; see {@link replaceBrandPhrase}. */
1101
+ const BRAND_PHRASE_REPLACEMENT = "the agent runtime";
1102
+ /**
1103
+ * The phrase as it appears after an article the sentence already wrote
1104
+ * (`… through the DeepSeek Harness web interface`).
1105
+ *
1106
+ * Matched **with** that article so the replacement supplies the only one:
1107
+ * replacing the bare phrase there produced `through the the agent runtime`,
1108
+ * which is what this pattern exists to prevent. It also collapses whatever
1109
+ * whitespace run separated the three words, so the sentence reads normally
1110
+ * afterwards. The trailing slash guard is the same one the bare pattern uses, so
1111
+ * a real `…/DeepSeek Harness/…` path is still never touched.
1112
+ */
1113
+ const CHANNEL_SIGNATURE_PHRASE_AFTER_ARTICLE = /\bthe[\s\u00A0]+DeepSeek[\s\u00A0]+Harness(?![\\/])/gi;
1114
+ /**
1115
+ * Replace the brand phrase with a neutral noun phrase, in both of the forms a
1116
+ * sentence can use it.
1117
+ *
1118
+ * The replacement carries its own `the` because the phrase is normally the
1119
+ * subject of a mid-sentence clause (`Powered by DeepSeek Harness.` → `Powered by
1120
+ * the agent runtime.`). The article-bearing spelling is handled first and
1121
+ * absorbs that article; the bare spelling then gets the article from the
1122
+ * replacement. One pattern cannot do both, because the replacement text is a
1123
+ * constant.
1124
+ */
1125
+ function replaceBrandPhrase(text) {
1126
+ return text.replace(CHANNEL_SIGNATURE_PHRASE_AFTER_ARTICLE, BRAND_PHRASE_REPLACEMENT).replace(CHANNEL_SIGNATURE_PHRASE, BRAND_PHRASE_REPLACEMENT);
1073
1127
  }
1074
1128
  /**
1075
1129
  * DSH's identity lines. The gate matches each one **independently** (measured
1076
1130
  * 2026-10-29 by two probes on the sibling plugin), so a body whose brand is
1077
1131
  * already neutralized still fails on these.
1132
+ *
1133
+ * These rows match the host's **original** wording and therefore run before the
1134
+ * brand pass (see {@link neutralizeSignatureText}). A `to` side must not
1135
+ * reintroduce the brand: the brand pass that follows would rewrite it again,
1136
+ * and the second pass would then produce a different string — the
1137
+ * non-idempotence the idempotence test catches.
1078
1138
  */
1079
1139
  const IDENTITY_REWRITES = [
1080
1140
  ["You are a helpful AI assistant.", "You are a capable AI helper."],
1081
1141
  ["You are an AI assistant", "You are an AI agent."],
1082
1142
  ["You are a coding agent powered by the ", "You are a coding agent running on the "],
1083
- ["You are interacting with the user through the DeepSeek Harness web interface", "You are working with the user inside the DeepSeek Harness web interface"],
1143
+ ["You are interacting with the user through the DeepSeek web interface", "You are working with the user inside the agent runtime web interface"],
1084
1144
  ["Agent Teams is available in this session", "Agent Teams is enabled for this session"]
1085
1145
  ];
1086
1146
  /**
1087
1147
  * The @-path paragraph `@deepseek-ai/dsh-file-reference` injects into every
1088
- * system prompt — a **combination** fingerprint, not one magic sentence:
1089
- * every sentence alone answers 200, the paragraph answers 400, and rewriting
1090
- * one sentence breaks it (all three measured on 2026-10-27→29 by the two
1091
- * reference plugins). Hence one row per sentence: a single whole-paragraph
1092
- * literal would stop matching the moment the host rewords one word.
1093
- *
1094
- * The `from` strings come from the upstream reports' wording. A capture of THIS
1095
- * host (issue #25 diagnostic, `~/.dsh/workbuddy-sent-body.json`) shows it
1096
- * already ships several of the neutralized forms, so most rows are no-ops here
1097
- * and are kept for hosts that still send the originals. Only `from` strings a
1098
- * measurement actually produced appear in the table — an unmatched sentence
1099
- * passes through byte-identical rather than being guessed at.
1148
+ * system prompt.
1149
+ *
1150
+ * **Whole-paragraph replacement, measured.** The gateway answers 200 to each
1151
+ * sentence alone and 400 to the paragraph, i.e. the match unit is the paragraph,
1152
+ * not a sentence. Everything short of replacing it leaves the count above the
1153
+ * threshold, and that includes the sibling implementation's one-word insertion —
1154
+ * measured 2026-11-06 against this gateway, on a real 92 KB turn:
1155
+ *
1156
+ * | form sent in \`system\` | refused |
1157
+ * | --- | --- |
1158
+ * | original paragraph | 3/3 |
1159
+ * | original + \`that\` inserted into the first sentence | 3/3 |
1160
+ * | **replaced with the neutral paragraph below** | **0/3** |
1161
+ * | removed outright | 0/3 |
1162
+ *
1163
+ * The rule matches the paragraph by its shape rather than by one literal string:
1164
+ * the host has shipped at least two wordings (this repo's own older rewrite
1165
+ * produced "Tokens prefixed with @ are paths that the user explicitly
1166
+ * referenced."), and a literal would silently stop matching the next time the host
1167
+ * reworded a word. The `@` glyph and its meaning survive in the replacement: the
1168
+ * model still has to know what a leading `@` means, only the phrasing the gate
1169
+ * reads is gone.
1170
+ *
1171
+ * Idempotent by construction: the replacement contains none of the shapes the
1172
+ * pattern looks for.
1173
+ */
1174
+ const FILE_REFERENCE_PARAGRAPH = /(?:Paths prefixed with @|Tokens prefixed with @|Tokens starting with @)[\s\S]{0,600}?quotes a path containing spaces\./iu;
1175
+ /** What the paragraph becomes; see {@link FILE_REFERENCE_PARAGRAPH}. */
1176
+ const FILE_REFERENCE_REPLACEMENT = "A token beginning with @ names a location the user pointed at. Resolve relative ones from the project folder and treat absolute ones as host paths. A trailing slash means a directory, otherwise it is a file. Quote a path containing spaces with @\"...\".";
1177
+ /**
1178
+ * The same paragraph as an older release of this plugin left it in history.
1179
+ *
1180
+ * A `to` side becomes a fingerprint once the gate has been measured against it,
1181
+ * and a session that ran 0.5.5 carries those forms in its `assistant` echo. They
1182
+ * are matched by the same pattern above, so nothing extra is needed here — this
1183
+ * note exists because the pattern's alternation is what makes that true.
1100
1184
  */
1101
- const FILE_REFERENCE_REWRITES = [
1102
- ["Paths prefixed with @ point to files the user referenced.", "Tokens starting with @ are locations the user explicitly named."],
1103
- ["Relative paths resolve against the working directory;", "Relative paths are resolved against the workspace root;"],
1104
- ["absolute paths identify files or directories on the host.", "absolute paths point at files or directories on the host."],
1105
- ["A trailing slash marks a directory:", "A trailing slash indicates a directory:"],
1106
- ["list it when its contents matter.", "list it whenever its contents matter."],
1107
- ["Anything else is a file:", "Everything else is a file:"],
1108
- ["use the read tool when its contents are needed,", "use the read tool whenever you need its contents,"],
1109
- ["and do not claim to have inspected it before reading.", "and never claim to have inspected it before reading it."],
1110
- ["@\"...\" quotes a path containing spaces.", "A path containing spaces is quoted with @\"...\"."]
1111
- ];
1112
1185
  /**
1113
1186
  * The opener DSH puts at the head of every system prompt.
1114
1187
  *
@@ -1143,6 +1216,23 @@ const CHANNEL_GATE_COPY = /请求被拦截/;
1143
1216
  function isChannelGateBody(body) {
1144
1217
  return CHANNEL_GATE_CODE.test(body) || CHANNEL_GATE_COPY.test(body);
1145
1218
  }
1219
+ /**
1220
+ * The plan-limit refusal: the account's tier does not include the model
1221
+ * (issue #24, measured on a free international account).
1222
+ *
1223
+ * Two shapes, because the code can be in the envelope or written into the
1224
+ * message text — `{"code":11158,"msg":"model [gpt-6-luna] is not allowed for
1225
+ * user type [personal_free_user]", "displayMsg":{…}}`. Both must be recognised
1226
+ * for the same reason as the channel gate: the actionable advice (pick another
1227
+ * model) is not guessable from `请求被拦截`-style copy, and retrying the same
1228
+ * model can never succeed.
1229
+ */
1230
+ const PLAN_LIMIT_CODE = /\b11158\b/;
1231
+ const PLAN_LIMIT_COPY = /is not allowed for user type/i;
1232
+ /** True when an upstream body says the account's plan excludes the model. */
1233
+ function isPlanLimitBody(body) {
1234
+ return PLAN_LIMIT_CODE.test(body) || PLAN_LIMIT_COPY.test(body);
1235
+ }
1146
1236
  /** Rewrite `role: "developer"` messages to `role: "system"` (upstream rejects developer). */
1147
1237
  function normalizeDeveloperRole(obj) {
1148
1238
  const messages = obj["messages"];
@@ -1224,11 +1314,28 @@ var WorkBuddyUpstreamClient = class {
1224
1314
  resolveAppVersion;
1225
1315
  /** Chat-identity resolver; see {@link WorkBuddyUpstreamClientOptions.resolveChatIdentity}. */
1226
1316
  resolveChatIdentity;
1317
+ /** Request observer; see {@link WorkBuddyUpstreamClientOptions.onChatRequest}. */
1318
+ onChatRequest;
1319
+ /** Outcome observer; see {@link WorkBuddyUpstreamClientOptions.onChatOutcome}. */
1320
+ onChatOutcome;
1227
1321
  /** Provenance of the most recent successful catalog fetch, for the card. */
1228
1322
  lastCatalog;
1229
1323
  constructor(options = {}) {
1230
1324
  this.resolveAppVersion = options.resolveAppVersion ?? (() => resolveAppVersion());
1231
1325
  this.resolveChatIdentity = options.resolveChatIdentity ?? ((region) => resolveChatIdentity(region));
1326
+ this.onChatRequest = options.onChatRequest;
1327
+ this.onChatOutcome = options.onChatOutcome;
1328
+ }
1329
+ /**
1330
+ * Report an outcome without letting an observer break the request it observes.
1331
+ *
1332
+ * Both observers exist for diagnostics, so their failure must stay invisible to
1333
+ * the message being diagnosed.
1334
+ */
1335
+ reportOutcome(outcome) {
1336
+ try {
1337
+ this.onChatOutcome?.(outcome);
1338
+ } catch {}
1232
1339
  }
1233
1340
  /**
1234
1341
  * POST the chat endpoint; a successful answer is the raw SSE response.
@@ -1253,35 +1360,72 @@ var WorkBuddyUpstreamClient = class {
1253
1360
  userAgent = chatUserAgent(fallbackChatIdentity(region), region);
1254
1361
  }
1255
1362
  const body = region === "global" ? prepareInternationalChatBody(bodyJson) : prepareChatBody(bodyJson);
1256
- let response;
1363
+ const url = `${chatBase(credential)}/v2/chat/completions`;
1364
+ const headers = {
1365
+ ...chatHeaders(credential, userAgent),
1366
+ Authorization: `Bearer ${credential.accessToken}`
1367
+ };
1257
1368
  try {
1258
- response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
1259
- method: "POST",
1260
- headers: {
1261
- ...chatHeaders(credential, userAgent),
1262
- Authorization: `Bearer ${credential.accessToken}`
1263
- },
1264
- body,
1265
- ...signal === void 0 ? {} : { signal }
1369
+ this.onChatRequest?.({
1370
+ url,
1371
+ headers,
1372
+ body
1266
1373
  });
1267
- } catch (error) {
1268
- return {
1269
- ok: false,
1270
- status: 0,
1271
- kind: "server",
1272
- message: `transport error: ${String(error)}`
1374
+ } catch {}
1375
+ let response;
1376
+ let attempt = 0;
1377
+ /** The last refusal, kept for the report once the retry budget is spent. */
1378
+ let refusal;
1379
+ for (;;) {
1380
+ attempt++;
1381
+ try {
1382
+ response = await fetch(url, {
1383
+ method: "POST",
1384
+ headers,
1385
+ body,
1386
+ ...signal === void 0 ? {} : { signal }
1387
+ });
1388
+ } catch (error) {
1389
+ this.reportOutcome({
1390
+ status: 0,
1391
+ message: `transport error: ${String(error)}`
1392
+ });
1393
+ return {
1394
+ ok: false,
1395
+ status: 0,
1396
+ kind: "server",
1397
+ message: `transport error: ${String(error)}`
1398
+ };
1399
+ }
1400
+ if (response.ok) {
1401
+ this.reportOutcome({ status: response.status });
1402
+ return {
1403
+ ok: true,
1404
+ response
1405
+ };
1406
+ }
1407
+ const text = (await response.text()).slice(0, ERROR_BODY_LIMIT);
1408
+ refusal = {
1409
+ status: response.status,
1410
+ text
1273
1411
  };
1412
+ if (!isChannelGateBody(text) || attempt > GATE_RETRY_DELAYS_MS.length) break;
1413
+ if (signal?.aborted === true) break;
1414
+ await sleep(GATE_RETRY_DELAYS_MS[attempt - 1], signal);
1274
1415
  }
1275
- if (response.ok) return {
1276
- ok: true,
1277
- response
1416
+ const last = refusal ?? {
1417
+ status: response.status,
1418
+ text: ""
1278
1419
  };
1279
- const text = (await response.text()).slice(0, ERROR_BODY_LIMIT);
1420
+ this.reportOutcome({
1421
+ status: last.status,
1422
+ message: last.text
1423
+ });
1280
1424
  return {
1281
1425
  ok: false,
1282
- status: response.status,
1283
- kind: classifyUpstreamError(response.status, text),
1284
- message: text
1426
+ status: last.status,
1427
+ kind: classifyUpstreamError(last.status, last.text),
1428
+ message: last.text
1285
1429
  };
1286
1430
  }
1287
1431
  /** POST the token-refresh endpoint; the caller merges the outcome. */
@@ -4021,7 +4165,7 @@ var WorkBuddyCatalog = class {
4021
4165
  };
4022
4166
  //#endregion
4023
4167
  //#region src/version.ts
4024
- const WORKBUDDY_BRIDGE_VERSION = "0.5.5";
4168
+ const WORKBUDDY_BRIDGE_VERSION = "0.5.6";
4025
4169
  //#endregion
4026
4170
  //#region src/web/heartbeat.ts
4027
4171
  /**
@@ -4192,4 +4336,4 @@ function isHeartbeatProcessAlive(heartbeat) {
4192
4336
  return startAtMs <= heartbeat.registeredAt;
4193
4337
  }
4194
4338
  //#endregion
4195
- export { normalizeCredits as A, CN_APP_VERSION_FILENAME as B, variantFor as C, WorkBuddyUpstreamClient as D, WORKBUDDY_STATUS_PATH as E, PROBE_EFFORT_CANDIDATES as F, resolveChatIdentity as G, chatUserAgent as H, probeModel as I, appUserAgent as J, validCliVersion as K, randomSentinel as L, prepareChatBody as M, prepareInternationalChatBody as N, isChannelGateBody as O, regionOf as P, validAppVersion as Q, classifyUpstreamError as R, WORKBUDDY_VARIANTS as S, WORKBUDDY_PROBE_PATH as T, fallbackChatIdentity as U, FALLBACK_CN_APP_VERSION as V, readCliVersion as W, readBundleVersion as X, installedAppVersion as Y, resolveAppVersion as Z, desktopAuthCandidatesFor as _, readHostHeartbeat as a, AI_VARIANT as b, WORKBUDDY_BRIDGE_VERSION as c, WorkBuddyCatalog as d, WORKBUDDY_AUTH_FILENAME as f, defaultDesktopAuthPath as g, defaultDesktopAuthCandidates as h, processStartTimeMs as i, parseModelCatalog as j, modelWithCurrentPromotion as k, FALLBACK_WORKBUDDY_AI_MODELS as l, WorkBuddyCredentialStore as m, clearHostHeartbeat as n, workbuddyHostHeartbeatPath as o, WORKBUDDY_AUTH_FILE_ENV as p, WORKBUDDY_APP_VERSION_FILENAME as q, isHeartbeatProcessAlive as r, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, FALLBACK_WORKBUDDY_MODELS as u, parseWorkBuddyAuth as v, WORKBUDDY_CREDIT_PATH as w, CN_VARIANT as x, workbuddyOwnAuthPath as y, upstreamDisplayMessage as z };
4339
+ export { validAppVersion as $, modelWithCurrentPromotion as A, upstreamDisplayMessage as B, variantFor as C, WorkBuddyUpstreamClient as D, WORKBUDDY_STATUS_PATH as E, regionOf as F, readCliVersion as G, FALLBACK_CN_APP_VERSION as H, PROBE_EFFORT_CANDIDATES as I, WORKBUDDY_APP_VERSION_FILENAME as J, resolveChatIdentity as K, probeModel as L, parseModelCatalog as M, prepareChatBody as N, isChannelGateBody as O, prepareInternationalChatBody as P, resolveAppVersion as Q, randomSentinel as R, WORKBUDDY_VARIANTS as S, WORKBUDDY_PROBE_PATH as T, chatUserAgent as U, CN_APP_VERSION_FILENAME as V, fallbackChatIdentity as W, installedAppVersion as X, appUserAgent as Y, readBundleVersion as Z, desktopAuthCandidatesFor as _, readHostHeartbeat as a, AI_VARIANT as b, WORKBUDDY_BRIDGE_VERSION as c, WorkBuddyCatalog as d, WORKBUDDY_AUTH_FILENAME as f, defaultDesktopAuthPath as g, defaultDesktopAuthCandidates as h, processStartTimeMs as i, normalizeCredits as j, isPlanLimitBody as k, FALLBACK_WORKBUDDY_AI_MODELS as l, WorkBuddyCredentialStore as m, clearHostHeartbeat as n, workbuddyHostHeartbeatPath as o, WORKBUDDY_AUTH_FILE_ENV as p, validCliVersion as q, isHeartbeatProcessAlive as r, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, FALLBACK_WORKBUDDY_MODELS as u, parseWorkBuddyAuth as v, WORKBUDDY_CREDIT_PATH as w, CN_VARIANT as x, workbuddyOwnAuthPath as y, classifyUpstreamError as z };
package/lib/index.d.ts CHANGED
@@ -656,6 +656,33 @@ interface WorkBuddyUpstreamClientOptions {
656
656
  * billing never consult it — those requests keep their long-standing headers.
657
657
  */
658
658
  resolveChatIdentity?: (region: WorkBuddyRegion) => Promise<ChatIdentity>;
659
+ /**
660
+ * Observes the final chat request — URL, complete headers, exact body — before
661
+ * it leaves. Diagnostic only; see `llm/capture.ts` for why headers are as
662
+ * load-bearing as the body when a request is refused on content.
663
+ *
664
+ * Kept as an injected hook rather than a built-in file write so the client
665
+ * stays free of filesystem policy, and so a test can assert the exact bytes
666
+ * without touching a disk.
667
+ */
668
+ onChatRequest?: (request: {
669
+ url: string;
670
+ headers: Record<string, string>;
671
+ body: string;
672
+ }) => void;
673
+ /**
674
+ * Observes how the chat request ended — the verdict that belongs to the
675
+ * request {@link onChatRequest} just reported. Status `0` is a transport
676
+ * failure.
677
+ *
678
+ * Paired with the request hook rather than left to the caller, because only
679
+ * this function sees both halves; splitting them is how a captured body gets
680
+ * paired with another request's verdict.
681
+ */
682
+ onChatOutcome?: (outcome: {
683
+ status: number;
684
+ message?: string;
685
+ }) => void;
659
686
  }
660
687
  /**
661
688
  * Upstream HTTP client. One instance serves the whole plugin; requests take
@@ -673,9 +700,20 @@ declare class WorkBuddyUpstreamClient {
673
700
  private readonly resolveAppVersion;
674
701
  /** Chat-identity resolver; see {@link WorkBuddyUpstreamClientOptions.resolveChatIdentity}. */
675
702
  private readonly resolveChatIdentity;
703
+ /** Request observer; see {@link WorkBuddyUpstreamClientOptions.onChatRequest}. */
704
+ private readonly onChatRequest;
705
+ /** Outcome observer; see {@link WorkBuddyUpstreamClientOptions.onChatOutcome}. */
706
+ private readonly onChatOutcome;
676
707
  /** Provenance of the most recent successful catalog fetch, for the card. */
677
708
  lastCatalog: WorkBuddyCatalogFetch | undefined;
678
709
  constructor(options?: WorkBuddyUpstreamClientOptions);
710
+ /**
711
+ * Report an outcome without letting an observer break the request it observes.
712
+ *
713
+ * Both observers exist for diagnostics, so their failure must stay invisible to
714
+ * the message being diagnosed.
715
+ */
716
+ private reportOutcome;
679
717
  /**
680
718
  * POST the chat endpoint; a successful answer is the raw SSE response.
681
719
  *
package/lib/index.js CHANGED
@@ -1,9 +1,9 @@
1
- import { A as normalizeCredits, B as CN_APP_VERSION_FILENAME, C as variantFor, D as WorkBuddyUpstreamClient, F as PROBE_EFFORT_CANDIDATES, G as resolveChatIdentity, H as chatUserAgent, I as probeModel, J as appUserAgent, K as validCliVersion, L as randomSentinel, M as prepareChatBody, N as prepareInternationalChatBody, O as isChannelGateBody, P as regionOf, Q as validAppVersion, R as classifyUpstreamError, S as WORKBUDDY_VARIANTS, U as fallbackChatIdentity, V as FALLBACK_CN_APP_VERSION, W as readCliVersion, X as readBundleVersion, Y as installedAppVersion, Z as resolveAppVersion, _ as desktopAuthCandidatesFor, a as readHostHeartbeat, b as AI_VARIANT, c as WORKBUDDY_BRIDGE_VERSION, d as WorkBuddyCatalog, f as WORKBUDDY_AUTH_FILENAME, g as defaultDesktopAuthPath, h as defaultDesktopAuthCandidates, i as processStartTimeMs, j as parseModelCatalog, k as modelWithCurrentPromotion, l as FALLBACK_WORKBUDDY_AI_MODELS, m as WorkBuddyCredentialStore, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as WORKBUDDY_AUTH_FILE_ENV, q as WORKBUDDY_APP_VERSION_FILENAME, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS, v as parseWorkBuddyAuth, w as WORKBUDDY_CREDIT_PATH, x as CN_VARIANT, y as workbuddyOwnAuthPath, z as upstreamDisplayMessage } from "./heartbeat-DYtNMdMm.js";
1
+ import { $ as validAppVersion, A as modelWithCurrentPromotion, B as upstreamDisplayMessage, C as variantFor, D as WorkBuddyUpstreamClient, F as regionOf, G as readCliVersion, H as FALLBACK_CN_APP_VERSION, I as PROBE_EFFORT_CANDIDATES, J as WORKBUDDY_APP_VERSION_FILENAME, K as resolveChatIdentity, L as probeModel, M as parseModelCatalog, N as prepareChatBody, O as isChannelGateBody, P as prepareInternationalChatBody, Q as resolveAppVersion, R as randomSentinel, S as WORKBUDDY_VARIANTS, U as chatUserAgent, V as CN_APP_VERSION_FILENAME, W as fallbackChatIdentity, X as installedAppVersion, Y as appUserAgent, Z as readBundleVersion, _ as desktopAuthCandidatesFor, a as readHostHeartbeat, b as AI_VARIANT, c as WORKBUDDY_BRIDGE_VERSION, d as WorkBuddyCatalog, f as WORKBUDDY_AUTH_FILENAME, g as defaultDesktopAuthPath, h as defaultDesktopAuthCandidates, i as processStartTimeMs, j as normalizeCredits, k as isPlanLimitBody, l as FALLBACK_WORKBUDDY_AI_MODELS, m as WorkBuddyCredentialStore, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as WORKBUDDY_AUTH_FILE_ENV, q as validCliVersion, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS, v as parseWorkBuddyAuth, w as WORKBUDDY_CREDIT_PATH, x as CN_VARIANT, y as workbuddyOwnAuthPath, z as classifyUpstreamError } from "./heartbeat-BOSjBL0B.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
5
5
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
6
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7
7
  import { createProvider } from "@earendil-works/pi-ai";
8
8
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
9
9
  import { ReasoningEffortId, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
@@ -648,6 +648,139 @@ var WorkBuddyPiAiAdapter = class extends PiAiAdapter {
648
648
  };
649
649
  }
650
650
  };
651
+ /** The capture log under `$DSH_HOME`; one JSON object per line. */
652
+ const CAPTURE_LOG_FILENAME = ".workbuddy-captures.jsonl";
653
+ /**
654
+ * Held across the request/response pair.
655
+ *
656
+ * A single slot is deliberate rather than a map: this is a human-driven
657
+ * diagnostic, the host serialises one turn at a time, and the cost of a
658
+ * collision (one record with another's verdict) is a re-run, not a wrong fix.
659
+ * A map would need per-request ids threaded through the client for no gain here.
660
+ */
661
+ let pending;
662
+ /** Where the log goes right now, or `undefined` when disarmed. */
663
+ function captureTarget() {
664
+ const fromEnv = process.env["WORKBUDDY_CAPTURE_BODY"];
665
+ if (fromEnv !== void 0 && fromEnv.trim() !== "") return fromEnv;
666
+ try {
667
+ const home = resolveDshHome();
668
+ return existsSync(join(home, ".workbuddy-capture.on")) ? join(home, CAPTURE_LOG_FILENAME) : void 0;
669
+ } catch {
670
+ return;
671
+ }
672
+ }
673
+ /**
674
+ * Strip credential material before anything is written.
675
+ *
676
+ * The log is meant to be pasted into an issue. A bearer token in it would hand
677
+ * the reporter's account to whoever reads the issue, so redaction happens on the
678
+ * way in rather than being left to whoever copies the file.
679
+ */
680
+ function redacted(headers) {
681
+ const out = {};
682
+ for (const [name, value] of Object.entries(headers)) {
683
+ const lowered = name.toLowerCase();
684
+ out[name] = lowered === "authorization" || lowered === "cookie" || lowered.includes("token") ? "[redacted]" : value;
685
+ }
686
+ return out;
687
+ }
688
+ /**
689
+ * A non-reversible fingerprint of the bearer token, for comparing two runs.
690
+ *
691
+ * The token itself stays out of the log, but "was this the same token?" is a real
692
+ * question when the same body and headers answer 200 on a replay and 400 from the
693
+ * host: a token refresh between the two makes them different requests. A digest
694
+ * answers it without storing the secret.
695
+ */
696
+ function tokenFingerprint(headers) {
697
+ const entry = Object.entries(headers).find(([name]) => name.toLowerCase() === "authorization");
698
+ if (entry === void 0) return void 0;
699
+ const token = entry[1].replace(/^Bearer\s+/i, "");
700
+ if (token === "") return void 0;
701
+ return createHash("sha256").update(token).digest("hex").slice(0, 12);
702
+ }
703
+ /**
704
+ * Remember the request that is about to leave.
705
+ *
706
+ * Called from the client's `onChatRequest` hook, i.e. after every header and the
707
+ * body are final and before the fetch — so what is recorded is what the upstream
708
+ * receives, not what the caller intended.
709
+ */
710
+ function rememberChatRequest(url, headers, body) {
711
+ if (captureTarget() === void 0) return;
712
+ pending = {
713
+ url,
714
+ headers: redacted(headers),
715
+ body,
716
+ at: (/* @__PURE__ */ new Date()).toISOString(),
717
+ token: tokenFingerprint(headers)
718
+ };
719
+ }
720
+ /**
721
+ * The most recent request this process captured, with its verdict.
722
+ *
723
+ * Exists so a maintainer can re-send the exact refused request from **inside**
724
+ * the host process: the same body, headers and token answered 200 from a separate
725
+ * process and 400 from the host, which leaves the sending process as the only
726
+ * remaining variable.
727
+ *
728
+ * Falls back to the capture log's last line, because the in-memory slot does not
729
+ * survive a plugin reload — and a reload is exactly when the experiment is
730
+ * needed.
731
+ */
732
+ function lastCapturedRequest() {
733
+ if (lastRequest !== void 0) return lastRequest;
734
+ const target = captureTarget();
735
+ if (target === void 0 || !existsSync(target)) return void 0;
736
+ try {
737
+ const last = readFileSync(target, "utf8").split("\n").filter((line) => line.trim() !== "").at(-1);
738
+ if (last === void 0) return void 0;
739
+ const record = JSON.parse(last);
740
+ if (typeof record.body !== "string") return void 0;
741
+ return {
742
+ body: record.body,
743
+ at: record.at ?? "",
744
+ status: record.status ?? 0,
745
+ headers: record.headers ?? {}
746
+ };
747
+ } catch {
748
+ return;
749
+ }
750
+ }
751
+ /** Set by {@link rememberChatRequest} and refreshed by {@link recordChatOutcome}. */
752
+ let lastRequest;
753
+ /**
754
+ * Append the pending request with its verdict.
755
+ *
756
+ * `status` 0 means a transport failure. A missing pending request (a caller that
757
+ * skips the hook) writes nothing rather than inventing a record.
758
+ */
759
+ function recordChatOutcome(status, message) {
760
+ const target = captureTarget();
761
+ const entry = pending;
762
+ pending = void 0;
763
+ if (entry !== void 0) lastRequest = {
764
+ body: entry.body,
765
+ at: entry.at,
766
+ status,
767
+ headers: entry.headers
768
+ };
769
+ if (target === void 0 || entry === void 0) return;
770
+ const line = JSON.stringify({
771
+ at: entry.at,
772
+ url: entry.url,
773
+ status,
774
+ ...message === void 0 ? {} : { message: message.slice(0, 400) },
775
+ tokenFingerprint: entry.token,
776
+ headers: entry.headers,
777
+ bodyLength: entry.body.length,
778
+ body: entry.body
779
+ });
780
+ try {
781
+ appendFileSync(target, `${line}\n`, { mode: 384 });
782
+ } catch {}
783
+ }
651
784
  //#endregion
652
785
  //#region src/llm/loopback.ts
653
786
  /**
@@ -797,8 +930,24 @@ const KIND_STATUS = {
797
930
  * guidance itself, echoed back into a later request, cannot re-arm the gate.
798
931
  * No status digits (see {@link failureMessage}) and no retry advice: retrying
799
932
  * the same poisoned conversation reproduces the refusal.
933
+ *
934
+ * The running version rides along (issue #30): the same symptom reaches users
935
+ * on two very different layers — an install older than the fix, and a residual
936
+ * trigger after it — and without the version the user cannot tell which layer
937
+ * they are on, so the first thing they check is their account.
938
+ */
939
+ function channelGateHint() {
940
+ return ` — 渠道校验拦截(11-128)。请新开会话重试;若持续出现,先更新插件(当前 ${WORKBUDDY_BRIDGE_VERSION}) / blocked by channel verification (11-128): start a new conversation, and update the plugin if it persists (running ${WORKBUDDY_BRIDGE_VERSION})`;
941
+ }
942
+ /**
943
+ * What to do when the account's plan excludes the requested model (issue #24).
944
+ *
945
+ * No retry advice, because no retry can help: the model is not on this tier,
946
+ * and only picking a different model succeeds. The upstream's own
947
+ * `displayMsg` ("Your account type does not support this model.") says what is
948
+ * wrong but not what to do, which is the gap this fills.
800
949
  */
801
- const CHANNEL_GATE_HINT = " — 渠道校验拦截(11-128)。请新开会话重试;若持续出现,先更新插件 / blocked by channel verification (11-128): start a new conversation, and update the plugin if it persists";
950
+ const PLAN_LIMIT_HINT = " — 当前账号套餐不包含这个模型(11158),请换用其他模型 / your plan does not include this model (11158): pick another one";
802
951
  function writeJson(res, status, body) {
803
952
  const payload = JSON.stringify(body);
804
953
  res.writeHead(status, {
@@ -828,17 +977,20 @@ function writeOpenAIError(res, status, kind, message) {
828
977
  * written for a human ("账号存在异常行为,已被限制使用"), while the body is a JSON
829
978
  * document whose `msg` is often a machine phrase such as `request illegal`.
830
979
  *
831
- * The channel-gate code (`11-128`) appends {@link CHANNEL_GATE_HINT}: its raw
980
+ * The channel-gate code (`11-128`) appends {@link channelGateHint}: its raw
832
981
  * body is `{"message":"请求被拦截,请重新发送","type":"client","code":"client"}`,
833
982
  * which on its own reads as a transient hiccup the user should simply retry —
834
983
  * the two actions that actually help (a new conversation; update the plugin)
835
- * are not guessable from it.
984
+ * are not guessable from it. A plan-limit refusal (`11158`) appends
985
+ * {@link PLAN_LIMIT_HINT} for the same reason: the fix is "pick another model",
986
+ * which the upstream copy does not say.
836
987
  */
837
988
  function failureMessage(result) {
838
989
  const reason = upstreamDisplayMessage(result.message) ?? result.message.slice(0, 400);
839
990
  const head = `workbuddy upstream ${result.kind}`;
840
991
  const line = result.kind === "session_dead" ? `${head} (http ${result.status}): ${reason}` : `${head}: ${reason}`;
841
- return isChannelGateBody(result.message) ? `${line}${CHANNEL_GATE_HINT}` : line;
992
+ const gated = isChannelGateBody(result.message) ? `${line}${channelGateHint()}` : line;
993
+ return isPlanLimitBody(result.message) ? `${gated}${PLAN_LIMIT_HINT}` : gated;
842
994
  }
843
995
  /** Read a request body with a size cap; over-limit bodies fail the request. */ function readBody$1(req) {
844
996
  return new Promise((resolve, reject) => {
@@ -1656,6 +1808,22 @@ async function workBuddyWebStatus(deps) {
1656
1808
  /** The status route's request handler, extracted so tests can mount it on a bare server. */
1657
1809
  function workBuddyStatusHandler(deps) {
1658
1810
  return async (req, res) => {
1811
+ if (req.method === "POST") {
1812
+ if (deps.replayCaptured === void 0) {
1813
+ json$2(res, 405, { error: "method not allowed" });
1814
+ return;
1815
+ }
1816
+ if (!loopbackRequest$1(req)) {
1817
+ json$2(res, 403, { error: "request-not-trusted" });
1818
+ return;
1819
+ }
1820
+ try {
1821
+ json$2(res, 200, await deps.replayCaptured());
1822
+ } catch (error) {
1823
+ json$2(res, 500, { error: safeMessage(error) });
1824
+ }
1825
+ return;
1826
+ }
1659
1827
  if (req.method !== "GET") {
1660
1828
  json$2(res, 405, { error: "method not allowed" });
1661
1829
  return;
@@ -2037,7 +2205,14 @@ function fallbackFor(variant) {
2037
2205
  }
2038
2206
  /** Build one variant's stores and probe state. */
2039
2207
  function createVariantRuntime(config, variant, identityOf, accountOf, creditIndex) {
2040
- const client = new WorkBuddyUpstreamClient();
2208
+ const client = new WorkBuddyUpstreamClient({
2209
+ onChatRequest: ({ url, headers, body }) => {
2210
+ rememberChatRequest(url, headers, body);
2211
+ },
2212
+ onChatOutcome: (outcome) => {
2213
+ recordChatOutcome(outcome.status, outcome.message);
2214
+ }
2215
+ });
2041
2216
  const configured = configuredAuthFile(config, variant);
2042
2217
  const store = new WorkBuddyCredentialStore({
2043
2218
  variant,
@@ -2318,6 +2493,19 @@ function apply(ctx, refs) {
2318
2493
  catalog: () => catalogSection(runtime),
2319
2494
  probe: () => probeSection(runtime, config().probeConsent),
2320
2495
  probeKey,
2496
+ replayCaptured: async () => {
2497
+ const captured = lastCapturedRequest();
2498
+ if (captured === void 0) return { error: "nothing captured" };
2499
+ const credential = await runtime.store.resolve();
2500
+ const result = await runtime.client.chatStream(credential, captured.body);
2501
+ return {
2502
+ ok: result.ok,
2503
+ replayedStatus: result.ok ? result.response.status : result.status,
2504
+ capturedAt: captured.at,
2505
+ capturedStatus: captured.status,
2506
+ bodyLength: captured.body.length
2507
+ };
2508
+ },
2321
2509
  visibility: () => {
2322
2510
  const account = runtime.account();
2323
2511
  return account === void 0 ? void 0 : {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-workbuddy-bridge",
3
3
  "displayName": "DSH WorkBuddy Bridge",
4
4
  "description": "将 WorkBuddy 桌面 App 包含的模型自动接入 DeepSeek Harness — bring WorkBuddy desktop app models into DeepSeek Harness with zero configuration.",
5
- "version": "0.5.5",
5
+ "version": "0.5.6",
6
6
  "author": "zlZayn",
7
7
  "keywords": [
8
8
  "dsh",