@eddyskywalker/dsh-chatgpt-subscription 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +64 -0
  3. package/lib/client.js +2176 -116
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +2211 -110
  6. package/lib/types/client/index.d.ts +2 -0
  7. package/lib/types/client/index.d.ts.map +1 -1
  8. package/lib/types/client/progress-updates/contracts.d.ts +125 -0
  9. package/lib/types/client/progress-updates/contracts.d.ts.map +1 -0
  10. package/lib/types/client/progress-updates/guide-artwork.d.ts +28 -0
  11. package/lib/types/client/progress-updates/guide-artwork.d.ts.map +1 -0
  12. package/lib/types/client/progress-updates/host-source.d.ts +128 -0
  13. package/lib/types/client/progress-updates/host-source.d.ts.map +1 -0
  14. package/lib/types/client/progress-updates/index.d.ts +91 -0
  15. package/lib/types/client/progress-updates/index.d.ts.map +1 -0
  16. package/lib/types/client/progress-updates/locales.d.ts +127 -0
  17. package/lib/types/client/progress-updates/locales.d.ts.map +1 -0
  18. package/lib/types/client/progress-updates/markdown-lite.d.ts +60 -0
  19. package/lib/types/client/progress-updates/markdown-lite.d.ts.map +1 -0
  20. package/lib/types/client/progress-updates/progress-row.d.ts +87 -0
  21. package/lib/types/client/progress-updates/progress-row.d.ts.map +1 -0
  22. package/lib/types/client/progress-updates/settings-binding.d.ts +71 -0
  23. package/lib/types/client/progress-updates/settings-binding.d.ts.map +1 -0
  24. package/lib/types/client/progress-updates/settings-section.d.ts +57 -0
  25. package/lib/types/client/progress-updates/settings-section.d.ts.map +1 -0
  26. package/lib/types/client/progress-updates/styles.d.ts +34 -0
  27. package/lib/types/client/progress-updates/styles.d.ts.map +1 -0
  28. package/lib/types/client/progress-updates/tool-name.d.ts +14 -0
  29. package/lib/types/client/progress-updates/tool-name.d.ts.map +1 -0
  30. package/lib/types/client/progress-updates/updates-panel.d.ts +113 -0
  31. package/lib/types/client/progress-updates/updates-panel.d.ts.map +1 -0
  32. package/lib/types/client/step-code/locales.d.ts +2 -2
  33. package/lib/types/client/workbuddy/WorkBuddySection.d.ts.map +1 -1
  34. package/lib/types/client/workbuddy/locales.d.ts +108 -0
  35. package/lib/types/client/workbuddy/locales.d.ts.map +1 -1
  36. package/lib/types/client/workbuddy/styles.d.ts.map +1 -1
  37. package/lib/types/client/workbuddy/useWorkBuddySection.d.ts +9 -1
  38. package/lib/types/client/workbuddy/useWorkBuddySection.d.ts.map +1 -1
  39. package/lib/types/host/progress-updates/index.d.ts +90 -0
  40. package/lib/types/host/progress-updates/index.d.ts.map +1 -0
  41. package/lib/types/host/progress-updates/nudge.d.ts +91 -0
  42. package/lib/types/host/progress-updates/nudge.d.ts.map +1 -0
  43. package/lib/types/host/progress-updates/prompt.d.ts +34 -0
  44. package/lib/types/host/progress-updates/prompt.d.ts.map +1 -0
  45. package/lib/types/host/progress-updates/routes.d.ts +39 -0
  46. package/lib/types/host/progress-updates/routes.d.ts.map +1 -0
  47. package/lib/types/host/progress-updates/store.d.ts +74 -0
  48. package/lib/types/host/progress-updates/store.d.ts.map +1 -0
  49. package/lib/types/host/progress-updates/tool.d.ts +42 -0
  50. package/lib/types/host/progress-updates/tool.d.ts.map +1 -0
  51. package/lib/types/host/reasoning-collapse-guard/index.d.ts +5 -0
  52. package/lib/types/host/reasoning-collapse-guard/index.d.ts.map +1 -1
  53. package/lib/types/host/web-provider-reconciler.d.ts +34 -0
  54. package/lib/types/host/web-provider-reconciler.d.ts.map +1 -0
  55. package/lib/types/host/workbuddy/account-pool.d.ts +64 -1
  56. package/lib/types/host/workbuddy/account-pool.d.ts.map +1 -1
  57. package/lib/types/host/workbuddy/adapter.d.ts +32 -0
  58. package/lib/types/host/workbuddy/adapter.d.ts.map +1 -1
  59. package/lib/types/host/workbuddy/atrest.d.ts +98 -0
  60. package/lib/types/host/workbuddy/atrest.d.ts.map +1 -0
  61. package/lib/types/host/workbuddy/checkin.d.ts +16 -0
  62. package/lib/types/host/workbuddy/checkin.d.ts.map +1 -1
  63. package/lib/types/host/workbuddy/client.d.ts +9 -0
  64. package/lib/types/host/workbuddy/client.d.ts.map +1 -1
  65. package/lib/types/host/workbuddy/dsml.d.ts +55 -0
  66. package/lib/types/host/workbuddy/dsml.d.ts.map +1 -0
  67. package/lib/types/host/workbuddy/guardrails.d.ts +111 -0
  68. package/lib/types/host/workbuddy/guardrails.d.ts.map +1 -0
  69. package/lib/types/host/workbuddy/mapper.d.ts +11 -0
  70. package/lib/types/host/workbuddy/mapper.d.ts.map +1 -1
  71. package/lib/types/host/workbuddy/model-catalog.d.ts +8 -1
  72. package/lib/types/host/workbuddy/model-catalog.d.ts.map +1 -1
  73. package/lib/types/host/workbuddy/outbound-identity.d.ts +66 -0
  74. package/lib/types/host/workbuddy/outbound-identity.d.ts.map +1 -0
  75. package/lib/types/host/workbuddy/routes.d.ts +8 -1
  76. package/lib/types/host/workbuddy/routes.d.ts.map +1 -1
  77. package/lib/types/host/workbuddy/token-store.d.ts +93 -0
  78. package/lib/types/host/workbuddy/token-store.d.ts.map +1 -1
  79. package/lib/types/index.d.ts +63 -5
  80. package/lib/types/index.d.ts.map +1 -1
  81. package/lib/types/shared/workbuddy-contracts.d.ts +54 -2
  82. package/lib/types/shared/workbuddy-contracts.d.ts.map +1 -1
  83. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import * as LlmModule from "@deepseek-ai/dsh-llm";
4
- import { HarnessError, LlmAdapter, LlmError, LlmError as LlmError$1, ProviderRequestId, ReasoningEffortId, attributionHeaders, errorChain, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
4
+ import { HarnessError, LlmAdapter, LlmError, LlmError as LlmError$1, ProviderRequestId, ReasoningEffortId, attributionHeaders, createUserMessage, errorChain, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
5
5
  import fs, { chmod, lstat, mkdir, open, readFile, rename, stat, unlink } from "node:fs/promises";
6
6
  import path, { basename, dirname, join, relative, resolve } from "node:path";
7
7
  import os, { homedir, platform, userInfo } from "node:os";
@@ -14,7 +14,7 @@ import { WebError } from "@deepseek-ai/dsh-web";
14
14
  import { lookup } from "node:dns/promises";
15
15
  import { isIP } from "node:net";
16
16
  import { MAX_TIMER_DELAY_MS, idleWatchdog, timeoutOf } from "@deepseek-ai/dsh-timeout";
17
- import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm/message";
17
+ import { boundContextSummary, createUserMessage as createUserMessage$1 } from "@deepseek-ai/dsh-llm/message";
18
18
  import { defineTool } from "@deepseek-ai/dsh-tools";
19
19
  import * as SettingsModule from "@deepseek-ai/dsh-settings";
20
20
  import { URL as URL$1, URLSearchParams as URLSearchParams$1, fileURLToPath } from "node:url";
@@ -1361,14 +1361,14 @@ const SHARD_PAYLOAD_SIZE = 7e3;
1361
1361
  */
1362
1362
  const MAX_SHARDS = 128;
1363
1363
  /** Property that marks a primary entry as the envelope of a chunked value. */
1364
- const ENVELOPE_KEY = "__dshChunked";
1364
+ const ENVELOPE_KEY$1 = "__dshChunked";
1365
1365
  /**
1366
1366
  * Read the envelope of a stored primary entry, or null when the entry holds a
1367
1367
  * plain value. A malformed envelope is invalid stored data, not a plain value.
1368
1368
  */
1369
1369
  function envelopeFrom(parsed) {
1370
1370
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
1371
- const marker = parsed[ENVELOPE_KEY];
1371
+ const marker = parsed[ENVELOPE_KEY$1];
1372
1372
  if (marker === void 0) return null;
1373
1373
  if (typeof marker !== "object" || marker === null) throw new Error(INVALID);
1374
1374
  const { gen, chunks, bytes } = marker;
@@ -1489,7 +1489,7 @@ var SecretServiceCredentialStore = class {
1489
1489
  "store",
1490
1490
  "--label=DSH Antigravity OAuth",
1491
1491
  ...this.attributes()
1492
- ], JSON.stringify({ [ENVELOPE_KEY]: envelope }))).code !== 0) {
1492
+ ], JSON.stringify({ [ENVELOPE_KEY$1]: envelope }))).code !== 0) {
1493
1493
  await this.clearShards(gen, chunks);
1494
1494
  throw new Error(UNAVAILABLE);
1495
1495
  }
@@ -4348,7 +4348,7 @@ function createCodexImageTool(oauth, attachments, options = {}) {
4348
4348
  ...image.name !== void 0 ? { name: image.name } : {}
4349
4349
  }
4350
4350
  };
4351
- if (exec.parent !== void 0) exec.deferContext(createUserMessage({
4351
+ if (exec.parent !== void 0) exec.deferContext(createUserMessage$1({
4352
4352
  content: [{
4353
4353
  type: "image",
4354
4354
  attachment: image
@@ -8293,7 +8293,7 @@ function createAntigravityImageTool(accountPool, store, attachments, options = {
8293
8293
  ...image.name !== void 0 ? { name: image.name } : {}
8294
8294
  }))
8295
8295
  };
8296
- if (exec.parent !== void 0) exec.deferContext(createUserMessage({
8296
+ if (exec.parent !== void 0) exec.deferContext(createUserMessage$1({
8297
8297
  content: saved.map((attachment) => ({
8298
8298
  type: "image",
8299
8299
  attachment
@@ -12256,6 +12256,24 @@ function runningProviderValue(value) {
12256
12256
  function providerId(value) {
12257
12257
  return typeof value === "string" && /^[a-zA-Z0-9_-]{1,80}$/.test(value) ? value : null;
12258
12258
  }
12259
+ /**
12260
+ * Start reconciling the web provider selection on a timer.
12261
+ *
12262
+ * @param options - the reconcile callback and an optional interval.
12263
+ * @returns the disposer that stops the timer.
12264
+ */
12265
+ function startWebProviderReconciler(options) {
12266
+ const intervalMs = options.intervalMs ?? 3e4;
12267
+ const timer = setInterval(() => {
12268
+ try {
12269
+ options.reconcile();
12270
+ } catch {}
12271
+ }, intervalMs);
12272
+ timer.unref?.();
12273
+ return () => {
12274
+ clearInterval(timer);
12275
+ };
12276
+ }
12259
12277
  //#endregion
12260
12278
  //#region src/host/common/quota-refresh.ts
12261
12279
  /**
@@ -26783,7 +26801,7 @@ function createKimiVideoTool(ctx, options = {}) {
26783
26801
  throw error;
26784
26802
  }
26785
26803
  const question = typeof args.question === "string" ? args.question.trim() : "";
26786
- exec.deferContext(createUserMessage({
26804
+ exec.deferContext(createUserMessage$1({
26787
26805
  content: [{
26788
26806
  type: "video",
26789
26807
  attachment: ref
@@ -31336,7 +31354,7 @@ function projectAccount$2(input) {
31336
31354
  * Takes the summary shape as well as the pool row, because both the card's list
31337
31355
  * and the routing read ask the same question about it.
31338
31356
  */
31339
- function isMarkedFailed(account) {
31357
+ function isMarkedFailed$1(account) {
31340
31358
  return account.authStatus !== void 0 && account.authStatus !== "ok";
31341
31359
  }
31342
31360
  /** The alias an account is labelled with when the user has not named it. */
@@ -31575,7 +31593,7 @@ var MinimaxCodeAccountPool = class extends AccountPoolCore {
31575
31593
  */
31576
31594
  async listAccounts() {
31577
31595
  const summaries = await super.listAccounts();
31578
- if (!summaries.some(isMarkedFailed)) return summaries;
31596
+ if (!summaries.some(isMarkedFailed$1)) return summaries;
31579
31597
  await this.healStaleAuthFailures().catch(() => void 0);
31580
31598
  return await super.listAccounts();
31581
31599
  }
@@ -31591,7 +31609,7 @@ var MinimaxCodeAccountPool = class extends AccountPoolCore {
31591
31609
  */
31592
31610
  async getEffectiveAccount(excludeIds, fetchFn = fetch) {
31593
31611
  const data = await this.read();
31594
- if (data.accounts.some(isMarkedFailed)) await this.healStaleAuthFailures(data).catch(() => void 0);
31612
+ if (data.accounts.some(isMarkedFailed$1)) await this.healStaleAuthFailures(data).catch(() => void 0);
31595
31613
  return await super.getEffectiveAccount(excludeIds, fetchFn);
31596
31614
  }
31597
31615
  /**
@@ -31610,7 +31628,7 @@ var MinimaxCodeAccountPool = class extends AccountPoolCore {
31610
31628
  * because that one genuinely needs a sign-in.
31611
31629
  */
31612
31630
  async healStaleAuthFailures(current) {
31613
- const failedAccounts = (current ?? await this.read()).accounts.filter(isMarkedFailed);
31631
+ const failedAccounts = (current ?? await this.read()).accounts.filter(isMarkedFailed$1);
31614
31632
  if (failedAccounts.length === 0) return false;
31615
31633
  const targets = new Map(failedAccounts.map((account) => [account.id, {
31616
31634
  credentials: account.credentials,
@@ -32720,8 +32738,17 @@ function parseWorkBuddyCreditMultiplier(raw) {
32720
32738
  if (!Number.isFinite(value) || value < 0) return null;
32721
32739
  return stripped;
32722
32740
  }
32723
- /** Every level this route can name, in escalating order (the card's sort key). */
32741
+ /**
32742
+ * Every level this route can name, in escalating order (the card's sort key).
32743
+ *
32744
+ * `none` is the one level that is not a rung of any ladder: it is the measured
32745
+ * spelling that turns the reasoning trace OFF upstream (`reasoning_effort:
32746
+ * "none"`, the value the sibling gateway sends on its own cheap calls). It is
32747
+ * only ever offered on a model the catalog marks `canDisableThinking`, so it
32748
+ * sorts below everything and is never used to converge a level onto a ladder.
32749
+ */
32724
32750
  const WORKBUDDY_REASONING_EFFORTS = [
32751
+ "none",
32725
32752
  "minimal",
32726
32753
  "low",
32727
32754
  "medium",
@@ -33853,9 +33880,18 @@ function modelsForRegion$1(region, catalog = FALLBACK_MODELS$1) {
33853
33880
  function workBuddyModelSupportsImage(id, catalog = FALLBACK_MODELS$1) {
33854
33881
  return resolveWorkBuddyModel(id, catalog).supportsImage;
33855
33882
  }
33856
- /** Reasoning ladder one model accepts. */
33883
+ /**
33884
+ * Reasoning ladder one model accepts, plus `none` when its trace can be turned off.
33885
+ *
33886
+ * The ladder in the catalog is what the model ACCEPTS, so `none` never appears
33887
+ * in it; `canDisableThinking` is the separate statement that switching thinking
33888
+ * off is allowed, and this is the single place that turns it into the one level
33889
+ * the picker can offer. Callers that need the ladder alone read the entry.
33890
+ */
33857
33891
  function workBuddyReasoningEfforts(id, catalog = FALLBACK_MODELS$1) {
33858
- return resolveWorkBuddyModel(id, catalog).reasoningEfforts;
33892
+ const entry = resolveWorkBuddyModel(id, catalog);
33893
+ if (!entry.canDisableThinking || entry.reasoningEfforts.length === 0) return entry.reasoningEfforts;
33894
+ return entry.reasoningEfforts.includes("none") ? entry.reasoningEfforts : ["none", ...entry.reasoningEfforts];
33859
33895
  }
33860
33896
  /** Output cap requested when the caller omits one. */
33861
33897
  function maxOutputTokensFor$3(id, catalog = FALLBACK_MODELS$1) {
@@ -33972,6 +34008,547 @@ function withResolvedIdentity(credentials) {
33972
34008
  return changed ? next : credentials;
33973
34009
  }
33974
34010
  //#endregion
34011
+ //#region src/host/workbuddy/guardrails.ts
34012
+ /**
34013
+ * Local quota guardrails for the WorkBuddy line.
34014
+ *
34015
+ * The subscription meters an account × model over a 24h window (429 `code
34016
+ * 6004`), but it only says so AFTER the window is gone — and on the shared
34017
+ * account that window is minutes of a large model or a day of a small one. These
34018
+ * guardrails stop serving BEFORE that happens, so an exhausted window never has
34019
+ * to be discovered by a user staring at a 429.
34020
+ *
34021
+ * Three of the four guardrails workbuddy2api-hub ships are reproduced here, and
34022
+ * all three are EXACT — they read numbers this plugin already has:
34023
+ *
34024
+ * reserveCredits stop the account when its remaining credits fall below N
34025
+ * (the billing snapshot already reports the remainder)
34026
+ * dailyTokens stop the account for the rest of the local day at N tokens
34027
+ * modelDailyTokens stop ONE model for the rest of the local day at N tokens
34028
+ *
34029
+ * The fourth one there — a daily CREDITS budget — is deliberately NOT
34030
+ * reproduced: a request's credit cost is not on the wire this route reads, and
34031
+ * deriving it from the catalog multiplier × tokens would invent a number that
34032
+ * silently diverges from the bill. A guardrail that lies is worse than none.
34033
+ *
34034
+ * `0` means OFF for every field, and the default is all-off: an install that
34035
+ * never opens this setting behaves exactly as before.
34036
+ */
34037
+ /** Shipped default: every guardrail off. */
34038
+ const DEFAULT_GUARDRAILS = {
34039
+ reserveCredits: 0,
34040
+ dailyTokens: 0,
34041
+ modelDailyTokens: 0
34042
+ };
34043
+ function positiveOrZero(value) {
34044
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return 0;
34045
+ return Math.floor(value);
34046
+ }
34047
+ /** Narrow a stored or patched value into guardrails; anything unusable means off. */
34048
+ function normalizeGuardrails(value) {
34049
+ if (typeof value !== "object" || value === null) return { ...DEFAULT_GUARDRAILS };
34050
+ const record = value;
34051
+ return {
34052
+ reserveCredits: positiveOrZero(record.reserveCredits),
34053
+ dailyTokens: positiveOrZero(record.dailyTokens),
34054
+ modelDailyTokens: positiveOrZero(record.modelDailyTokens)
34055
+ };
34056
+ }
34057
+ /** Whether any guardrail is armed; a no-op check must stay cheap. */
34058
+ function guardrailsArmed(guardrails) {
34059
+ return guardrails.reserveCredits > 0 || guardrails.dailyTokens > 0 || guardrails.modelDailyTokens > 0;
34060
+ }
34061
+ /**
34062
+ * The local calendar day a counter belongs to.
34063
+ *
34064
+ * Local, not UTC, because the reset the user reasons about is their own
34065
+ * midnight — the same choice this line makes for the CN check-in day.
34066
+ */
34067
+ function localDayKey(now = Date.now()) {
34068
+ const date = new Date(now);
34069
+ const month = String(date.getMonth() + 1).padStart(2, "0");
34070
+ const day = String(date.getDate()).padStart(2, "0");
34071
+ return `${date.getFullYear()}-${month}-${day}`;
34072
+ }
34073
+ /** Where the local usage counters live. */
34074
+ function usageLedgerPath() {
34075
+ return path.join(dshHomeDir(), "storages", "workbuddy-usage.json");
34076
+ }
34077
+ /**
34078
+ * Token counters per account per local day.
34079
+ *
34080
+ * Deliberately a small persisted document rather than an accounting system: it
34081
+ * exists to answer one question ("has this account / model used its day?") and a
34082
+ * restart must not forget it, or a guardrail would reset by restarting DSH.
34083
+ */
34084
+ var WorkBuddyUsageLedger = class {
34085
+ filePath;
34086
+ now;
34087
+ document = {
34088
+ version: 1,
34089
+ day: localDayKey(),
34090
+ accounts: {}
34091
+ };
34092
+ loaded = false;
34093
+ writing = Promise.resolve();
34094
+ constructor(filePath = usageLedgerPath(), now = () => Date.now()) {
34095
+ this.filePath = filePath;
34096
+ this.now = now;
34097
+ }
34098
+ /** Read the counters once; a missing or unreadable file starts a fresh day. */
34099
+ async load() {
34100
+ if (this.loaded) return;
34101
+ this.loaded = true;
34102
+ try {
34103
+ const raw = JSON.parse(await fs.readFile(this.filePath, "utf8"));
34104
+ if (typeof raw !== "object" || raw === null) return;
34105
+ const record = raw;
34106
+ const accounts = typeof record.accounts === "object" && record.accounts !== null ? record.accounts : {};
34107
+ const entries = {};
34108
+ for (const [accountId, value] of Object.entries(accounts)) {
34109
+ if (typeof value !== "object" || value === null) continue;
34110
+ const entry = value;
34111
+ const perModel = {};
34112
+ if (typeof entry.perModel === "object" && entry.perModel !== null) {
34113
+ for (const [model, tokens] of Object.entries(entry.perModel)) if (typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0) perModel[model] = tokens;
34114
+ }
34115
+ entries[accountId] = {
34116
+ tokens: typeof entry.tokens === "number" && Number.isFinite(entry.tokens) ? entry.tokens : 0,
34117
+ perModel
34118
+ };
34119
+ }
34120
+ this.document = {
34121
+ version: 1,
34122
+ day: typeof record.day === "string" ? record.day : localDayKey(this.now()),
34123
+ accounts: entries
34124
+ };
34125
+ this.rollIfNewDay();
34126
+ } catch {}
34127
+ }
34128
+ /** Drop yesterday's counters the moment a read notices the day changed. */
34129
+ rollIfNewDay() {
34130
+ const today = localDayKey(this.now());
34131
+ if (this.document.day === today) return;
34132
+ this.document = {
34133
+ version: 1,
34134
+ day: today,
34135
+ accounts: {}
34136
+ };
34137
+ }
34138
+ /** Tokens one account has used today, across models. */
34139
+ tokensToday(accountId) {
34140
+ this.rollIfNewDay();
34141
+ return this.document.accounts[accountId]?.tokens ?? 0;
34142
+ }
34143
+ /** Tokens one account has used today on one model. */
34144
+ modelTokensToday(accountId, model) {
34145
+ this.rollIfNewDay();
34146
+ return this.document.accounts[accountId]?.perModel[model] ?? 0;
34147
+ }
34148
+ /** Today's counters, for the settings card and for tests. */
34149
+ snapshot() {
34150
+ this.rollIfNewDay();
34151
+ return this.document.accounts;
34152
+ }
34153
+ /**
34154
+ * Add one request's tokens.
34155
+ *
34156
+ * Serialized through a promise chain: two concurrent requests on different
34157
+ * accounts would otherwise read the same document, add their own counts and
34158
+ * write, losing one of them.
34159
+ */
34160
+ async record(accountId, model, tokens) {
34161
+ if (!Number.isFinite(tokens) || tokens <= 0) return;
34162
+ this.rollIfNewDay();
34163
+ const entry = this.document.accounts[accountId] ?? {
34164
+ tokens: 0,
34165
+ perModel: {}
34166
+ };
34167
+ entry.tokens += Math.floor(tokens);
34168
+ entry.perModel[model] = (entry.perModel[model] ?? 0) + Math.floor(tokens);
34169
+ this.document.accounts[accountId] = entry;
34170
+ const write = this.writing.then(() => this.persist());
34171
+ this.writing = write.catch(() => void 0);
34172
+ await write.catch(() => void 0);
34173
+ }
34174
+ async persist() {
34175
+ await fs.mkdir(path.dirname(this.filePath), { recursive: true });
34176
+ const tmp = `${this.filePath}.tmp.${Date.now()}`;
34177
+ await fs.writeFile(tmp, JSON.stringify(this.document, null, 2), "utf8");
34178
+ await fs.rename(tmp, this.filePath);
34179
+ }
34180
+ };
34181
+ /**
34182
+ * Decide whether one account may serve one model right now.
34183
+ *
34184
+ * Pure on purpose: every number it reads is passed in, so the decision can be
34185
+ * tested without a pool, a clock or a network.
34186
+ */
34187
+ function evaluateGuardrails(input) {
34188
+ const { guardrails, tokensToday, modelTokensToday, remainingCredits } = input;
34189
+ if (guardrails.reserveCredits > 0 && typeof remainingCredits === "number" && Number.isFinite(remainingCredits) && remainingCredits < guardrails.reserveCredits) return {
34190
+ allowed: false,
34191
+ reason: "reserve-credits",
34192
+ detail: `${remainingCredits.toFixed(2)} credits left, below the reserved ${guardrails.reserveCredits}`
34193
+ };
34194
+ if (guardrails.dailyTokens > 0 && tokensToday >= guardrails.dailyTokens) return {
34195
+ allowed: false,
34196
+ reason: "daily-tokens",
34197
+ detail: `${tokensToday} tokens today, at the ${guardrails.dailyTokens} daily cap`
34198
+ };
34199
+ if (guardrails.modelDailyTokens > 0 && modelTokensToday >= guardrails.modelDailyTokens) return {
34200
+ allowed: false,
34201
+ reason: "model-daily-tokens",
34202
+ detail: `${modelTokensToday} tokens today on this model, at the ${guardrails.modelDailyTokens} cap`
34203
+ };
34204
+ return { allowed: true };
34205
+ }
34206
+ //#endregion
34207
+ //#region src/host/workbuddy/outbound-identity.ts
34208
+ /**
34209
+ * Outbound client identities this route can present.
34210
+ *
34211
+ * The subscription gateway gates on client identity, and it gates DIFFERENTLY
34212
+ * per surface: measured on this route, `CodeBuddyIDE` is rejected by
34213
+ * `/v3/config` with 400 `code 12403` ("check ua") and 401'd outright by the
34214
+ * international chat endpoint, which is why the CLI identity is what the line
34215
+ * ships and has always used.
34216
+ *
34217
+ * Two more identities exist on the same backend, and workbuddy2api-hub has
34218
+ * captured what the real clients send (their `wb_identity.py`, measured
34219
+ * 2026-10-16 with mitmproxy against the international desktop build 5.8.4):
34220
+ *
34221
+ * desktop `WorkBuddy/5.8.4 workbuddy-ai/5.8.4 CLI/2.161.0`
34222
+ * + `X-IDE-Type/Name: WorkBuddy`, `X-IDE-Version`, `X-Agent-Intent: craft`
34223
+ * + `X-Agent-Purpose: conversation`, `X-Agent-Type: main`, trace headers
34224
+ * vscode `VSCode/1.119.0 WorkBuddy/4.9.29177644`
34225
+ * + `X-IDE-Type/Name: VSCode`, `X-IDE-Version`, `X-Product-Version`, `X-Env-ID`
34226
+ * cli `CLI/<v> CodeBuddy/<v>` + `X-IDE-Type/Name: CLI`
34227
+ *
34228
+ * `cli` is deliberately kept byte-identical to what this line sends today
34229
+ * (including the `CodeBuddyIDE` IDE name, which is what was actually measured
34230
+ * to work here) so the default cannot regress; the other two are opt-in
34231
+ * attempts.
34232
+ *
34233
+ * Quota is NOT per identity: the subscription meters an account × model over a
34234
+ * 24h window (429 `code 6004`), so switching identity cannot create allowance.
34235
+ * What it can do is give each account a stable, plausible client — the same
34236
+ * reason the gateway projects one device per account.
34237
+ */
34238
+ /** Everything the settings card may offer, in display order. */
34239
+ const OUTBOUND_PRODUCTS = [
34240
+ "cli",
34241
+ "workbuddy",
34242
+ "vscode"
34243
+ ];
34244
+ /** Narrow a stored or patched value into a supported preference. */
34245
+ function normalizeOutboundSetting(value) {
34246
+ if (value === "auto") return "auto";
34247
+ return typeof value === "string" && OUTBOUND_PRODUCTS.includes(value) ? value : "cli";
34248
+ }
34249
+ /**
34250
+ * Which identity one account uses.
34251
+ *
34252
+ * `auto` assigns by hashing the account key, so an account keeps the same
34253
+ * client for its whole life — the one property that matters for a device
34254
+ * fingerprint — while a pool as a whole spreads over the available clients.
34255
+ */
34256
+ function resolveOutboundProduct(setting, accountKey) {
34257
+ if (setting !== "auto") return setting;
34258
+ const pick = (createHash("sha256").update(accountKey).digest()[0] ?? 0) % OUTBOUND_PRODUCTS.length;
34259
+ return OUTBOUND_PRODUCTS[pick];
34260
+ }
34261
+ const DESKTOP_VERSION_INTL = "5.8.4";
34262
+ const DESKTOP_VERSION_CN = "5.5.6";
34263
+ const DESKTOP_UA_INTL = `WorkBuddy/${DESKTOP_VERSION_INTL} workbuddy-ai/${DESKTOP_VERSION_INTL} CLI/2.161.0`;
34264
+ const DESKTOP_UA_CN = `WorkBuddy/${DESKTOP_VERSION_CN} WorkBuddy/${DESKTOP_VERSION_CN} CLI/2.137.1`;
34265
+ const VSCODE_IDE_VERSION = "1.119.0";
34266
+ const VSCODE_PRODUCT_VERSION = "4.9.29177644";
34267
+ const VSCODE_UA = `VSCode/${VSCODE_IDE_VERSION} WorkBuddy/${VSCODE_PRODUCT_VERSION}`;
34268
+ /**
34269
+ * The CLI identity this line has always presented.
34270
+ *
34271
+ * Kept as a literal rather than rebuilt, because it is the one string measured
34272
+ * to be accepted by chat, `/v3/config` and billing on both regions.
34273
+ */
34274
+ const CLI_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
34275
+ /**
34276
+ * A stable pseudo-device id for one account.
34277
+ *
34278
+ * Same derivation as the measured clients (`derive_id`: md5 of `salt:uid`
34279
+ * reshaped into a UUID), so the value is a well-formed v4-shaped machine id
34280
+ * that never changes for an account and never collides between accounts. A
34281
+ * per-PROCESS id would be worse than none here: it would present every account
34282
+ * in the pool as the same machine.
34283
+ */
34284
+ function deriveMachineId(uid) {
34285
+ const digest = createHash("md5").update(`machine:${uid.trim() === "" ? "anonymous" : uid}`).digest("hex");
34286
+ const shaped = `${digest.slice(0, 12)}4${digest.slice(13, 16)}a${digest.slice(17)}`;
34287
+ return [
34288
+ shaped.slice(0, 8),
34289
+ shaped.slice(8, 12),
34290
+ shaped.slice(12, 16),
34291
+ shaped.slice(16, 20),
34292
+ shaped.slice(20, 32)
34293
+ ].join("-");
34294
+ }
34295
+ function hexId() {
34296
+ return createHash("sha256").update(`${Date.now()}${Math.random()}`).digest("hex").slice(0, 32);
34297
+ }
34298
+ /** One conversation request's trace chain, as the desktop client emits it. */
34299
+ function traceHeaders() {
34300
+ const trace = hexId();
34301
+ const span = hexId().slice(0, 16);
34302
+ return {
34303
+ "X-Trace-ID": trace,
34304
+ traceparent: `00-${trace}-${span}-01`,
34305
+ b3: `${trace}-${span}-1`,
34306
+ "X-B3-TraceId": trace,
34307
+ "X-B3-SpanId": span,
34308
+ "X-B3-Sampled": "1"
34309
+ };
34310
+ }
34311
+ /** Resolve the identity block for one product on one region. */
34312
+ function outboundIdentity(product, region, uid) {
34313
+ if (product === "workbuddy") {
34314
+ const version = region === "cn" ? DESKTOP_VERSION_CN : DESKTOP_VERSION_INTL;
34315
+ return {
34316
+ product,
34317
+ userAgent: region === "cn" ? DESKTOP_UA_CN : DESKTOP_UA_INTL,
34318
+ headers: (conversation) => ({
34319
+ "X-Agent-Intent": "craft",
34320
+ "X-Agent-Purpose": "conversation",
34321
+ "X-Agent-Type": "main",
34322
+ "X-IDE-Name": "WorkBuddy",
34323
+ "X-IDE-Type": "WorkBuddy",
34324
+ "X-IDE-Version": version,
34325
+ "X-Private-Data": "true",
34326
+ "X-Machine-ID": deriveMachineId(uid),
34327
+ ...conversation["X-Conversation-Request-ID"] === void 0 ? {} : { "X-Root-Request-ID": conversation["X-Conversation-Request-ID"] },
34328
+ ...traceHeaders()
34329
+ })
34330
+ };
34331
+ }
34332
+ if (product === "vscode") return {
34333
+ product,
34334
+ userAgent: VSCODE_UA,
34335
+ headers: (conversation) => ({
34336
+ "X-Agent-Intent": "craft",
34337
+ "X-IDE-Name": "VSCode",
34338
+ "X-IDE-Type": "VSCode",
34339
+ "X-IDE-Version": VSCODE_IDE_VERSION,
34340
+ "X-Product-Version": VSCODE_PRODUCT_VERSION,
34341
+ "X-Env-ID": "production",
34342
+ "X-Machine-ID": deriveMachineId(uid),
34343
+ ...conversation["X-Conversation-Request-ID"] === void 0 ? {} : { "X-Root-Request-ID": conversation["X-Conversation-Request-ID"] }
34344
+ })
34345
+ };
34346
+ return {
34347
+ product: "cli",
34348
+ userAgent: CLI_UA,
34349
+ headers: () => ({})
34350
+ };
34351
+ }
34352
+ /** Framing kinds and the code each carries in the AAD. */
34353
+ const FRAMING = {
34354
+ file: {
34355
+ magic: "WBEF1",
34356
+ code: 1
34357
+ },
34358
+ field: {
34359
+ magic: "WBEV1",
34360
+ code: 2
34361
+ }
34362
+ };
34363
+ /** Suite number this build speaks; the envelope may override it. */
34364
+ const DEFAULT_SUITE = 1;
34365
+ /** Hash scheme marker inside the AAD. */
34366
+ const SCHEME = "sym-v1";
34367
+ /** Every failure the at-rest path can report to a user. */
34368
+ var AtRestError = class extends Error {
34369
+ constructor(message) {
34370
+ super(message);
34371
+ this.name = "AtRestError";
34372
+ }
34373
+ };
34374
+ function lengthPrefixed(text) {
34375
+ const raw = Buffer.from(text, "utf8");
34376
+ const prefix = Buffer.alloc(4);
34377
+ prefix.writeUInt32BE(raw.length, 0);
34378
+ return Buffer.concat([prefix, raw]);
34379
+ }
34380
+ function uint32(value) {
34381
+ const raw = Buffer.alloc(4);
34382
+ raw.writeUInt32BE(value, 0);
34383
+ return raw;
34384
+ }
34385
+ /** The GCM associated data, byte-identical to the client's. */
34386
+ function associatedData(keyId, suite, framing) {
34387
+ const spec = FRAMING[framing];
34388
+ return Buffer.concat([
34389
+ Buffer.from("WB-AAD\0", "utf8"),
34390
+ Buffer.from([1]),
34391
+ lengthPrefixed(spec.magic),
34392
+ lengthPrefixed(SCHEME),
34393
+ uint32(suite),
34394
+ lengthPrefixed(keyId),
34395
+ Buffer.from([
34396
+ spec.code,
34397
+ 0,
34398
+ 0
34399
+ ])
34400
+ ]);
34401
+ }
34402
+ /** `protectorKeyId` / `envelope.keyId`: the first 16 hex characters of sha256(key). */
34403
+ function deriveKeyId(key) {
34404
+ return createHash("sha256").update(key).digest("hex").slice(0, 16);
34405
+ }
34406
+ /**
34407
+ * Whether a value is a sealed envelope rather than plaintext.
34408
+ *
34409
+ * Older clients sometimes stored the whole envelope as a JSON STRING in the
34410
+ * field, so that shape is recognised too.
34411
+ */
34412
+ function isSealedValue(value) {
34413
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
34414
+ const record = value;
34415
+ return typeof record.envelope === "string" && "$wbEncrypted" in record;
34416
+ }
34417
+ if (typeof value === "string") {
34418
+ const trimmed = value.trimStart();
34419
+ return trimmed.startsWith("{") && trimmed.slice(0, 64).includes("$wbEncrypted");
34420
+ }
34421
+ return false;
34422
+ }
34423
+ function base64(value, label) {
34424
+ if (typeof value !== "string" || value === "") throw new AtRestError(`sealed envelope is missing ${label}`);
34425
+ return Buffer.from(value, "base64");
34426
+ }
34427
+ /** Split one envelope into its GCM parts. */
34428
+ function parseEnvelope(value) {
34429
+ const encoded = typeof value === "object" && value !== null ? value.envelope : value;
34430
+ if (typeof encoded !== "string") throw new AtRestError("sealed envelope is not a string");
34431
+ let parsed;
34432
+ try {
34433
+ parsed = JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
34434
+ } catch (error) {
34435
+ throw new AtRestError(`sealed envelope is not base64 JSON: ${error.message}`);
34436
+ }
34437
+ if (typeof parsed !== "object" || parsed === null) throw new AtRestError("sealed envelope is not an object");
34438
+ const record = parsed;
34439
+ return {
34440
+ suite: typeof record.suite === "number" && Number.isFinite(record.suite) ? record.suite : DEFAULT_SUITE,
34441
+ keyId: typeof record.keyId === "string" ? record.keyId : "",
34442
+ nonce: base64(record.nonce, "nonce"),
34443
+ authTag: base64(record.authTag, "authTag"),
34444
+ ciphertext: base64(record.ciphertext, "ciphertext")
34445
+ };
34446
+ }
34447
+ /** Open one envelope with the protector key, returning the plaintext bytes. */
34448
+ function openEnvelope(key, value, framing) {
34449
+ const envelope = parseEnvelope(value);
34450
+ const expected = deriveKeyId(key);
34451
+ if (envelope.keyId !== "" && envelope.keyId !== expected) throw new AtRestError(`sealed value was written by a different key (envelope ${envelope.keyId}, this key ${expected})`);
34452
+ const decipher = createDecipheriv("aes-256-gcm", key, envelope.nonce);
34453
+ decipher.setAAD(associatedData(envelope.keyId || expected, envelope.suite, framing));
34454
+ decipher.setAuthTag(envelope.authTag);
34455
+ try {
34456
+ return Buffer.concat([decipher.update(envelope.ciphertext), decipher.final()]);
34457
+ } catch {
34458
+ throw new AtRestError("sealed value could not be authenticated — the pasted key does not match this file");
34459
+ }
34460
+ }
34461
+ /** Decrypt a field that may be sealed, leaving plaintext values untouched. */
34462
+ function openSealedField(key, value) {
34463
+ if (value === null || value === void 0) return "";
34464
+ if (typeof value === "string" && !isSealedValue(value)) return value;
34465
+ if (typeof value !== "string" && !isSealedValue(value)) return "";
34466
+ return openEnvelope(key, value, "field").toString("utf8");
34467
+ }
34468
+ const HEX_KEY = /^[0-9a-f]{64}$/i;
34469
+ const BASE64_SECRET = /^[A-Za-z0-9+/]{43}=$/;
34470
+ /**
34471
+ * Normalise a pasted secret into the 32-byte protector key.
34472
+ *
34473
+ * Two spellings are accepted, neither requiring the user to compute anything:
34474
+ * a 64-character hex protector key (used as-is), or the 44-character base64
34475
+ * `atRestSecretKey` the client carries in its payload — that one is hashed as
34476
+ * the STRING, which is what produces the protector key.
34477
+ */
34478
+ function parseKeyText(text) {
34479
+ let raw = text.trim();
34480
+ if (raw.slice(0, 2).toLowerCase() === "0x") raw = raw.slice(2).trim();
34481
+ if (HEX_KEY.test(raw)) return Buffer.from(raw, "hex");
34482
+ if (BASE64_SECRET.test(raw)) return createHash("sha256").update(raw, "ascii").digest();
34483
+ throw new AtRestError("unrecognised key format: paste the 64-character hex protector key or the 44-character base64 atRestSecretKey");
34484
+ }
34485
+ /** Directories the desktop client may keep its `keyblob` in. */
34486
+ function configDirCandidates() {
34487
+ const override = process.env["WORKBUDDY_CONFIG_DIR"] ?? process.env["CODEBUDDY_CONFIG_DIR"];
34488
+ if (override !== void 0 && override.trim() !== "") return [path.resolve(override.trim())];
34489
+ const home = os.homedir();
34490
+ return [
34491
+ "CodeBuddy",
34492
+ "WorkBuddy",
34493
+ ".workbuddy-ai",
34494
+ ".codebuddy"
34495
+ ].map((name) => path.join(home, name));
34496
+ }
34497
+ /** Path of the client's keyblob, or null when the client is not installed here. */
34498
+ function keyBlobPath() {
34499
+ for (const dir of configDirCandidates()) {
34500
+ const candidate = path.join(dir, "keyblob");
34501
+ if (existsSync(candidate)) return candidate;
34502
+ }
34503
+ return null;
34504
+ }
34505
+ async function loadKeyBlob() {
34506
+ const file = keyBlobPath();
34507
+ if (file === null) return null;
34508
+ try {
34509
+ const parsed = JSON.parse(await readFile(file, "utf8"));
34510
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
34511
+ } catch {
34512
+ return null;
34513
+ }
34514
+ }
34515
+ /**
34516
+ * Validate a protector key against the client's own keyblob.
34517
+ *
34518
+ * This is the check that turns a typo into an error instead of a mystery: the
34519
+ * key must match the `static-v1` slot's `protectorKeyId` and must unwrap a
34520
+ * 32-byte master whose derived id matches the blob's own `keyId`. Returns
34521
+ * `false` when there is no keyblob to check against (the desktop client is not
34522
+ * installed on this machine) — absence of evidence, not evidence of a bad key.
34523
+ */
34524
+ async function verifyKeyAgainstKeyBlob(key) {
34525
+ const blob = await loadKeyBlob();
34526
+ if (blob === null) return false;
34527
+ const slots = Array.isArray(blob.slots) ? blob.slots : [];
34528
+ const expected = deriveKeyId(key);
34529
+ for (const slot of slots) {
34530
+ if (slot.type !== "static-v1" || slot.wrapped === void 0) continue;
34531
+ if ((typeof slot.protectorKeyId === "string" ? slot.protectorKeyId.toLowerCase() : "") !== expected) throw new AtRestError("this key does not match the desktop client keyblob (protectorKeyId differs)");
34532
+ const master = openEnvelope(key, slot.wrapped, "file");
34533
+ if (master.length !== 32 || deriveKeyId(master) !== String(blob.keyId ?? "")) throw new AtRestError("the keyblob did not unwrap to a valid master key");
34534
+ return true;
34535
+ }
34536
+ throw new AtRestError("the desktop client keyblob has no usable static-v1 slot");
34537
+ }
34538
+ let activeKey = null;
34539
+ /** Install the protector key for this process (already persisted by the caller). */
34540
+ function setActiveAtRestKey(key) {
34541
+ activeKey = key;
34542
+ }
34543
+ /** The protector key this process holds, if any. */
34544
+ function activeAtRestKey() {
34545
+ return activeKey;
34546
+ }
34547
+ /** Whether sealed credentials can be opened right now. */
34548
+ function hasAtRestKey() {
34549
+ return activeKey !== null;
34550
+ }
34551
+ //#endregion
33975
34552
  //#region src/host/workbuddy/token-store.ts
33976
34553
  const WORKBUDDY_PREFERENCES_NAMESPACE = "dsh-workbuddy";
33977
34554
  /** Runtime membership test for one stored effort value. */
@@ -34005,7 +34582,9 @@ function registerWorkBuddyPreferenceStore(settings, fallbackStore = new FileMode
34005
34582
  defaultReasoningEffort: null,
34006
34583
  selectedAccountId: null,
34007
34584
  hiddenAccountIds: [],
34008
- checkin: { ...DEFAULT_CHECKIN_SETTINGS }
34585
+ checkin: { ...DEFAULT_CHECKIN_SETTINGS },
34586
+ outboundIdentity: "cli",
34587
+ guardrails: { ...DEFAULT_GUARDRAILS }
34009
34588
  };
34010
34589
  const warmed = fallbackStore.read().then((stored) => {
34011
34590
  snapshot = stored;
@@ -34026,7 +34605,18 @@ function registerWorkBuddyPreferenceStore(settings, fallbackStore = new FileMode
34026
34605
  defaultReasoningEffort: z.union([...WORKBUDDY_REASONING_EFFORTS.map((effort) => z.const(effort)), z.const(null)]).default(null),
34027
34606
  selectedAccountId: z.union([z.string(), z.const(null)]).default(null),
34028
34607
  hiddenAccountIds: z.array(z.string()).default([]),
34029
- checkin: z.object({ enabled: z.boolean().default(true) }).default({ enabled: true })
34608
+ checkin: z.object({ enabled: z.boolean().default(true) }).default({ enabled: true }),
34609
+ outboundIdentity: z.union([
34610
+ z.const("auto"),
34611
+ z.const("cli"),
34612
+ z.const("workbuddy"),
34613
+ z.const("vscode")
34614
+ ]).default("cli"),
34615
+ guardrails: z.object({
34616
+ reserveCredits: z.number().min(0).default(0),
34617
+ dailyTokens: z.number().min(0).default(0),
34618
+ modelDailyTokens: z.number().min(0).default(0)
34619
+ }).default({ ...DEFAULT_GUARDRAILS })
34030
34620
  }));
34031
34621
  return {
34032
34622
  ready: async () => void 0,
@@ -34039,7 +34629,9 @@ function registerWorkBuddyPreferenceStore(settings, fallbackStore = new FileMode
34039
34629
  defaultReasoningEffort: value.defaultReasoningEffort,
34040
34630
  selectedAccountId: value.selectedAccountId,
34041
34631
  hiddenAccountIds: value.hiddenAccountIds,
34042
- checkin: normalizeCheckinSettings(value.checkin)
34632
+ checkin: normalizeCheckinSettings(value.checkin),
34633
+ outboundIdentity: normalizeOutboundSetting(value.outboundIdentity),
34634
+ guardrails: normalizeGuardrails(value.guardrails)
34043
34635
  };
34044
34636
  },
34045
34637
  update: async (patch) => {
@@ -34054,7 +34646,12 @@ function registerWorkBuddyPreferenceStore(settings, fallbackStore = new FileMode
34054
34646
  checkin: patch.checkin !== void 0 ? normalizeCheckinSettings({
34055
34647
  ...normalizeCheckinSettings(current.checkin),
34056
34648
  ...patch.checkin
34057
- }) : normalizeCheckinSettings(current.checkin)
34649
+ }) : normalizeCheckinSettings(current.checkin),
34650
+ outboundIdentity: patch.outboundIdentity !== void 0 ? normalizeOutboundSetting(patch.outboundIdentity) : normalizeOutboundSetting(current.outboundIdentity),
34651
+ guardrails: patch.guardrails !== void 0 ? normalizeGuardrails({
34652
+ ...normalizeGuardrails(current.guardrails),
34653
+ ...patch.guardrails
34654
+ }) : normalizeGuardrails(current.guardrails)
34058
34655
  };
34059
34656
  await scope.update(normalized);
34060
34657
  fallbackStore.updateSettings(patch).catch(() => void 0);
@@ -34089,6 +34686,85 @@ function modelSettingsPath$7() {
34089
34686
  function managedCredentialsPath() {
34090
34687
  return path.join(dshHomeDir(), "storages", "workbuddy-accounts.json");
34091
34688
  }
34689
+ /** Platform-encrypted storage for the pasted at-rest key (never plaintext on disk). */
34690
+ function atRestKeyPath() {
34691
+ return path.join(dshHomeDir(), "storages", "workbuddy-atrest-key.json");
34692
+ }
34693
+ function createAtRestKeyBackend(filePath) {
34694
+ const parse = (value) => {
34695
+ const record = isRecord$23(value) ? value : {};
34696
+ return {
34697
+ version: 1,
34698
+ keyHex: typeof record.keyHex === "string" ? record.keyHex : ""
34699
+ };
34700
+ };
34701
+ if (process.platform === "win32") return new WindowsDpapiCredentialStore(`${filePath}.dpapi`, parse);
34702
+ if (process.platform === "darwin") return new MacKeychainCredentialStore("dsh-workbuddy-atrest", managedCredentialAccount(filePath), parse);
34703
+ if (process.platform === "linux") return new SecretServiceCredentialStore("dsh-workbuddy-atrest", managedCredentialAccount(filePath), parse);
34704
+ throw new Error("WorkBuddy encrypted key storage requires Windows, macOS, or Linux.");
34705
+ }
34706
+ /**
34707
+ * Storage for the `$wbEncrypted` protector key.
34708
+ *
34709
+ * The key is the one secret that unlocks the desktop client's tokens, so it goes
34710
+ * through the same platform store the managed pool uses (DPAPI / Keychain /
34711
+ * Secret Service) and is installed into the process only after the client's own
34712
+ * keyblob accepts it. A pasted value that fails that check never displaces a
34713
+ * working key.
34714
+ */
34715
+ var WorkBuddyAtRestKeyStore = class {
34716
+ filePath;
34717
+ backend;
34718
+ cached = null;
34719
+ constructor(filePath = atRestKeyPath(), backend = createAtRestKeyBackend(filePath)) {
34720
+ this.filePath = filePath;
34721
+ this.backend = backend;
34722
+ }
34723
+ /** Human-readable storage description, mirroring the managed pool's. */
34724
+ path() {
34725
+ if (process.platform === "win32") return `${this.filePath}.dpapi`;
34726
+ return `${process.platform === "darwin" ? "Keychain" : "Secret Service"}: dsh-workbuddy-atrest/${managedCredentialAccount(this.filePath)}`;
34727
+ }
34728
+ /**
34729
+ * Load the stored key into the process.
34730
+ *
34731
+ * Called once at startup: the credential readers consult the module-level
34732
+ * slot, so a key that is only on disk would otherwise leave every sealed
34733
+ * account unreadable until something else triggered a load.
34734
+ */
34735
+ async load() {
34736
+ if (this.cached !== null) {
34737
+ setActiveAtRestKey(this.cached);
34738
+ return this.cached;
34739
+ }
34740
+ const hex = (await this.backend.load().catch(() => null))?.keyHex ?? "";
34741
+ if (!/^[0-9a-f]{64}$/i.test(hex)) {
34742
+ setActiveAtRestKey(null);
34743
+ return null;
34744
+ }
34745
+ this.cached = Buffer.from(hex, "hex");
34746
+ setActiveAtRestKey(this.cached);
34747
+ return this.cached;
34748
+ }
34749
+ /** Validate and persist a pasted secret; returns the key id for display. */
34750
+ async adopt(text) {
34751
+ const key = parseKeyText(text);
34752
+ await verifyKeyAgainstKeyBlob(key);
34753
+ await this.backend.save({
34754
+ version: 1,
34755
+ keyHex: key.toString("hex")
34756
+ });
34757
+ this.cached = key;
34758
+ setActiveAtRestKey(key);
34759
+ return deriveKeyId(key);
34760
+ }
34761
+ /** Forget the key, both on disk and in this process. */
34762
+ async clear() {
34763
+ this.cached = null;
34764
+ setActiveAtRestKey(null);
34765
+ await this.backend.clear().catch(() => void 0);
34766
+ }
34767
+ };
34092
34768
  function isRecord$23(value) {
34093
34769
  return typeof value === "object" && value !== null && !Array.isArray(value);
34094
34770
  }
@@ -34110,15 +34786,24 @@ function parseCredentialFile(value, sourceFile, sourceMtimeMs = 0) {
34110
34786
  if (!isRecord$23(value)) throw new Error("WorkBuddy credential file is not an object");
34111
34787
  const auth = isRecord$23(value.auth) ? value.auth : void 0;
34112
34788
  if (auth === void 0) throw new Error("WorkBuddy credential file has no auth block");
34113
- const accessToken = readString$2(auth, "accessToken");
34114
- if (accessToken === void 0) throw new Error("WorkBuddy credential file has no access token");
34789
+ const rawAccess = auth.accessToken;
34790
+ const sealedAccess = isSealedValue(rawAccess) ? rawAccess : void 0;
34791
+ const plainAccess = sealedAccess === void 0 ? readString$2(auth, "accessToken") : void 0;
34792
+ if (sealedAccess === void 0 && plainAccess === void 0) throw new Error("WorkBuddy credential file has no access token");
34793
+ const accessToken = plainAccess ?? "";
34794
+ const rawRefresh = auth.refreshToken;
34795
+ const sealedRefresh = isSealedValue(rawRefresh) ? rawRefresh : void 0;
34115
34796
  const account = isRecord$23(value.account) ? value.account : {};
34116
34797
  const domain = readString$2(auth, "domain") ?? "www.codebuddy.cn";
34117
34798
  const expiresAtRaw = auth.expiresAt;
34118
34799
  const expiresAt = typeof expiresAtRaw === "number" && Number.isFinite(expiresAtRaw) ? expiresAtRaw : 0;
34119
34800
  return withResolvedIdentity({
34120
34801
  accessToken,
34121
- refreshToken: readString$2(auth, "refreshToken") ?? "",
34802
+ refreshToken: sealedRefresh === void 0 ? readString$2(auth, "refreshToken") ?? "" : "",
34803
+ ...sealedAccess === void 0 && sealedRefresh === void 0 ? {} : { sealed: {
34804
+ ...sealedAccess === void 0 ? {} : { accessToken: sealedAccess },
34805
+ ...sealedRefresh === void 0 ? {} : { refreshToken: sealedRefresh }
34806
+ } },
34122
34807
  expiresAt,
34123
34808
  region: regionForDomain(domain),
34124
34809
  domain,
@@ -34208,16 +34893,56 @@ function parseManagedCredentials(value) {
34208
34893
  });
34209
34894
  });
34210
34895
  }
34896
+ let sealedDiagnostics = {
34897
+ sealedFiles: 0,
34898
+ opened: 0,
34899
+ missingKey: false,
34900
+ lastError: null
34901
+ };
34902
+ /** Snapshot of the last scan's sealed-credential observations. */
34903
+ function sealedCredentialDiagnostics() {
34904
+ return { ...sealedDiagnostics };
34905
+ }
34906
+ /** Start a fresh scan window; the settings card reads what the last scan left. */
34907
+ function resetSealedDiagnostics() {
34908
+ sealedDiagnostics = {
34909
+ sealedFiles: 0,
34910
+ opened: 0,
34911
+ missingKey: false,
34912
+ lastError: null
34913
+ };
34914
+ }
34211
34915
  /**
34212
- * Candidate ordering for the scanned credential files.
34916
+ * Resolve a credential's sealed tokens into usable plaintext.
34213
34917
  *
34214
- * A user's auth directory accumulates timestamped snapshots beside the live
34215
- * file, and the live one is not always the newest. Preference order is: the
34216
- * plain `workbuddy-desktop.info` / `codebuddy-desktop.info` files first, then
34217
- * the newest `expiresAt` among the timestamped snapshots. Picking purely by
34218
- * mtime selected a stale snapshot during development, so the token's own
34219
- * expiry is what decides.
34918
+ * Returns `null` when the credential is sealed and this process holds no key
34919
+ * that opens it: such a file is not an error the caller can act on per-file, it
34920
+ * is a missing key the settings card reports once.
34220
34921
  */
34922
+ function unsealCredentials(credentials) {
34923
+ if (credentials.sealed === void 0) return credentials;
34924
+ sealedDiagnostics.sealedFiles += 1;
34925
+ const key = activeAtRestKey();
34926
+ if (key === null) {
34927
+ sealedDiagnostics.missingKey = true;
34928
+ return null;
34929
+ }
34930
+ try {
34931
+ const accessToken = openSealedField(key, credentials.sealed.accessToken);
34932
+ if (accessToken === "") throw new AtRestError("sealed access token opened to an empty string");
34933
+ const refreshToken = credentials.sealed.refreshToken === void 0 ? credentials.refreshToken : openSealedField(key, credentials.sealed.refreshToken);
34934
+ sealedDiagnostics.opened += 1;
34935
+ return withResolvedIdentity({
34936
+ ...credentials,
34937
+ accessToken,
34938
+ refreshToken,
34939
+ sealed: void 0
34940
+ });
34941
+ } catch (error) {
34942
+ sealedDiagnostics.lastError = error instanceof Error ? error.message : String(error);
34943
+ return null;
34944
+ }
34945
+ }
34221
34946
  function isCanonicalFile(name) {
34222
34947
  return /^(workbuddy|codebuddy)-desktop(-\w+)?\.info$/i.test(name);
34223
34948
  }
@@ -34236,6 +34961,7 @@ async function scanCredentials(dir = codeBuddyAuthDir()) {
34236
34961
  if (error.code === "ENOENT") return [];
34237
34962
  throw new Error(`WorkBuddy credential directory could not be read: ${dir}`);
34238
34963
  }
34964
+ resetSealedDiagnostics();
34239
34965
  const found = [];
34240
34966
  for (const name of names) {
34241
34967
  if (!name.endsWith(".info")) continue;
@@ -34244,7 +34970,8 @@ async function scanCredentials(dir = codeBuddyAuthDir()) {
34244
34970
  const stats = await fs.stat(file);
34245
34971
  if (!stats.isFile()) continue;
34246
34972
  const raw = await fs.readFile(file, "utf8");
34247
- found.push(parseCredentialFile(JSON.parse(raw), file, stats.mtimeMs));
34973
+ const unsealed = unsealCredentials(parseCredentialFile(JSON.parse(raw), file, stats.mtimeMs));
34974
+ if (unsealed !== null) found.push(unsealed);
34248
34975
  } catch {
34249
34976
  continue;
34250
34977
  }
@@ -34423,7 +35150,7 @@ var FileCredentialStore$4 = class {
34423
35150
  const stats = await fs.stat(filePath);
34424
35151
  if (!stats.isFile()) return null;
34425
35152
  const raw = await fs.readFile(filePath, "utf8");
34426
- return parseCredentialFile(JSON.parse(raw), filePath, stats.mtimeMs);
35153
+ return unsealCredentials(parseCredentialFile(JSON.parse(raw), filePath, stats.mtimeMs));
34427
35154
  } catch {
34428
35155
  return null;
34429
35156
  }
@@ -34527,7 +35254,9 @@ var FileModelSettingsStore$4 = class {
34527
35254
  defaultReasoningEffort,
34528
35255
  selectedAccountId: typeof parsed.selectedAccountId === "string" && parsed.selectedAccountId !== "" ? parsed.selectedAccountId : null,
34529
35256
  hiddenAccountIds: Array.isArray(parsed.hiddenAccountIds) ? parsed.hiddenAccountIds.filter((id) => typeof id === "string") : [],
34530
- checkin: normalizeCheckinSettings(parsed.checkin)
35257
+ checkin: normalizeCheckinSettings(parsed.checkin),
35258
+ outboundIdentity: normalizeOutboundSetting(parsed.outboundIdentity),
35259
+ guardrails: normalizeGuardrails(parsed.guardrails)
34531
35260
  };
34532
35261
  }
34533
35262
  } catch {}
@@ -34538,7 +35267,9 @@ var FileModelSettingsStore$4 = class {
34538
35267
  defaultReasoningEffort: null,
34539
35268
  selectedAccountId: null,
34540
35269
  hiddenAccountIds: [],
34541
- checkin: { ...DEFAULT_CHECKIN_SETTINGS }
35270
+ checkin: { ...DEFAULT_CHECKIN_SETTINGS },
35271
+ outboundIdentity: "cli",
35272
+ guardrails: { ...DEFAULT_GUARDRAILS }
34542
35273
  };
34543
35274
  }
34544
35275
  async write(settings) {
@@ -34560,6 +35291,11 @@ var FileModelSettingsStore$4 = class {
34560
35291
  ...patch.checkin !== void 0 ? { checkin: normalizeCheckinSettings({
34561
35292
  ...current.checkin,
34562
35293
  ...patch.checkin
35294
+ }) } : {},
35295
+ ...patch.outboundIdentity !== void 0 ? { outboundIdentity: normalizeOutboundSetting(patch.outboundIdentity) } : {},
35296
+ ...patch.guardrails !== void 0 ? { guardrails: normalizeGuardrails({
35297
+ ...normalizeGuardrails(current.guardrails),
35298
+ ...patch.guardrails
34563
35299
  }) } : {}
34564
35300
  };
34565
35301
  await this.write(next);
@@ -34638,6 +35374,8 @@ function conversationHeaders(sessionId) {
34638
35374
  function workBuddyHeaders(credentials, extra = {}, channel) {
34639
35375
  const domain = credentials.domain || "www.codebuddy.cn";
34640
35376
  const intl = isIntlDomain(domain);
35377
+ const conversation = intl && channel !== void 0 ? conversationHeaders(channel.sessionId) : {};
35378
+ const identity = outboundIdentity(channel?.product ?? "cli", intl ? "intl" : "cn", credentials.uid ?? "");
34641
35379
  return {
34642
35380
  "content-type": "application/json",
34643
35381
  accept: "application/json",
@@ -34649,12 +35387,13 @@ function workBuddyHeaders(credentials, extra = {}, channel) {
34649
35387
  [HEADER_PRODUCT]: CLIENT_PRODUCT,
34650
35388
  [HEADER_IDE_NAME]: "CodeBuddyIDE",
34651
35389
  [HEADER_REQUESTED_WITH]: "XMLHttpRequest",
34652
- "user-agent": CLIENT_USER_AGENT,
35390
+ "user-agent": identity.userAgent,
34653
35391
  ...intl ? {
34654
35392
  origin: credentials.backend,
34655
35393
  referer: `${credentials.backend}/`
34656
35394
  } : {},
34657
- ...intl && channel !== void 0 ? conversationHeaders(channel.sessionId) : {},
35395
+ ...conversation,
35396
+ ...identity.headers(conversation),
34658
35397
  ...extra
34659
35398
  };
34660
35399
  }
@@ -35158,7 +35897,7 @@ function resetWebLogin() {
35158
35897
  function isRecord$21(value) {
35159
35898
  return typeof value === "object" && value !== null && !Array.isArray(value);
35160
35899
  }
35161
- function stringOf(record, key) {
35900
+ function stringOf$1(record, key) {
35162
35901
  const value = record[key];
35163
35902
  if (typeof value === "string" && value.trim() !== "") return value.trim();
35164
35903
  if (typeof value === "number" && Number.isFinite(value)) return String(value);
@@ -35207,9 +35946,9 @@ async function requestLoginState(region, fetchFn = fetch, signal) {
35207
35946
  } catch {
35208
35947
  root = void 0;
35209
35948
  }
35210
- if (!response.ok || root?.code !== 0 || !isRecord$21(root.data)) throw new Error(`${PROVIDER_NAME$3} 登录初始化失败(HTTP ${response.status}):${stringOf(root ?? {}, "msg") ?? text.slice(0, 160)}`);
35211
- const state = stringOf(root.data, "state");
35212
- const authUrl = stringOf(root.data, "authUrl");
35949
+ if (!response.ok || root?.code !== 0 || !isRecord$21(root.data)) throw new Error(`${PROVIDER_NAME$3} 登录初始化失败(HTTP ${response.status}):${stringOf$1(root ?? {}, "msg") ?? text.slice(0, 160)}`);
35950
+ const state = stringOf$1(root.data, "state");
35951
+ const authUrl = stringOf$1(root.data, "authUrl");
35213
35952
  if (!state || !authUrl) throw new Error(`${PROVIDER_NAME$3} 登录响应缺少 state 或 authUrl`);
35214
35953
  return {
35215
35954
  state,
@@ -35223,27 +35962,27 @@ function parseLoginCredential(payload, attempt) {
35223
35962
  if (!isRecord$21(payload)) throw new Error(`${PROVIDER_NAME$3} 登录轮询响应无效`);
35224
35963
  const code = numberOf(payload, "code");
35225
35964
  if (code === 11217) return null;
35226
- if (code !== 0) throw new Error(`${PROVIDER_NAME$3} 登录失败:${stringOf(payload, "msg") ?? `code ${String(code)}`}`);
35965
+ if (code !== 0) throw new Error(`${PROVIDER_NAME$3} 登录失败:${stringOf$1(payload, "msg") ?? `code ${String(code)}`}`);
35227
35966
  const data = isRecord$21(payload.data) ? payload.data : payload;
35228
35967
  const auth = isRecord$21(data.auth) ? data.auth : data;
35229
35968
  const account = isRecord$21(data.account) ? data.account : isRecord$21(auth.account) ? auth.account : isRecord$21(data.user) ? data.user : isRecord$21(auth.user) ? auth.user : {};
35230
- const accessToken = stringOf(auth, "accessToken") ?? stringOf(auth, "access_token") ?? stringOf(data, "token");
35969
+ const accessToken = stringOf$1(auth, "accessToken") ?? stringOf$1(auth, "access_token") ?? stringOf$1(data, "token");
35231
35970
  if (!accessToken) throw new Error(`${PROVIDER_NAME$3} 登录成功响应缺少 access token`);
35232
- const domain = stringOf(auth, "domain") ?? attempt.domain;
35971
+ const domain = stringOf$1(auth, "domain") ?? attempt.domain;
35233
35972
  const expiresIn = numberOf(auth, "expiresIn") ?? numberOf(auth, "expires_in");
35234
35973
  const expiresAt = numberOf(auth, "expiresAt") ?? numberOf(auth, "expires_at") ?? (expiresIn === void 0 ? Date.now() + 3600 * 1e3 : Date.now() + expiresIn * 1e3);
35235
35974
  return {
35236
35975
  accessToken,
35237
- refreshToken: stringOf(auth, "refreshToken") ?? stringOf(auth, "refresh_token") ?? stringOf(data, "refreshToken") ?? stringOf(data, "refresh_token") ?? "",
35976
+ refreshToken: stringOf$1(auth, "refreshToken") ?? stringOf$1(auth, "refresh_token") ?? stringOf$1(data, "refreshToken") ?? stringOf$1(data, "refresh_token") ?? "",
35238
35977
  expiresAt,
35239
35978
  region: regionForDomain(domain),
35240
35979
  domain,
35241
35980
  backend: backendForDomain(domain),
35242
- uid: stringOf(account, "uid") ?? stringOf(data, "uid"),
35243
- nickname: stringOf(account, "nickname") ?? stringOf(account, "name") ?? stringOf(data, "nickname"),
35244
- uin: stringOf(account, "uin") ?? stringOf(data, "uin"),
35245
- accountType: stringOf(account, "type") ?? stringOf(data, "accountType"),
35246
- enterpriseId: stringOf(account, "enterpriseId") ?? stringOf(data, "enterpriseId"),
35981
+ uid: stringOf$1(account, "uid") ?? stringOf$1(data, "uid"),
35982
+ nickname: stringOf$1(account, "nickname") ?? stringOf$1(account, "name") ?? stringOf$1(data, "nickname"),
35983
+ uin: stringOf$1(account, "uin") ?? stringOf$1(data, "uin"),
35984
+ accountType: stringOf$1(account, "type") ?? stringOf$1(data, "accountType"),
35985
+ enterpriseId: stringOf$1(account, "enterpriseId") ?? stringOf$1(data, "enterpriseId"),
35247
35986
  sourceFile: "",
35248
35987
  sourceMtimeMs: 0,
35249
35988
  source: "managed"
@@ -35452,6 +36191,7 @@ async function getWorkBuddyWebStatus(store, modelSettings, preferences, options
35452
36191
  accountId: settings.selectedAccountId,
35453
36192
  hiddenAccountIds: settings.hiddenAccountIds
35454
36193
  });
36194
+ const sealedDiagnostics = sealedCredentialDiagnostics();
35455
36195
  const enabled = settings.enabled !== false;
35456
36196
  const fetchFn = options.fetchFn ?? fetch;
35457
36197
  const live = credentials === null ? [] : await loadConfigCatalog(credentials, { fetchFn }).catch(() => []);
@@ -35491,7 +36231,12 @@ async function getWorkBuddyWebStatus(store, modelSettings, preferences, options
35491
36231
  })),
35492
36232
  activeAccountId: poolData?.activeAccountId,
35493
36233
  rotationStrategy: poolData?.rotationStrategy ?? "sequential",
35494
- checkin
36234
+ checkin,
36235
+ outboundIdentity: normalizeOutboundSetting(settings.outboundIdentity),
36236
+ guardrails: normalizeGuardrails(settings.guardrails),
36237
+ atRestKeySet: hasAtRestKey(),
36238
+ sealedAccounts: sealedDiagnostics.sealedFiles,
36239
+ sealedAccountsError: sealedDiagnostics.missingKey ? "The CodeBuddy desktop client seals its tokens; paste its at-rest key to read them." : sealedDiagnostics.lastError
35495
36240
  };
35496
36241
  }
35497
36242
  /** Register the WorkBuddy settings routes under `/workbuddy/api`. */
@@ -35507,6 +36252,7 @@ function registerWorkBuddyRoutes(ctx, store, modelSettings, preferences, options
35507
36252
  try {
35508
36253
  if (path === "" || path === "status") {
35509
36254
  if (method !== "GET") return sendMethodNotAllowed$3(response);
36255
+ await options.atRestKey?.load().catch(() => null);
35510
36256
  const settings = preferences ? preferences.status() : await modelSettings.read();
35511
36257
  await store.read({
35512
36258
  force: true,
@@ -35808,6 +36554,32 @@ function registerWorkBuddyRoutes(ctx, store, modelSettings, preferences, options
35808
36554
  const raw = body.checkin;
35809
36555
  if (typeof raw.enabled === "boolean") patch.checkin = { enabled: raw.enabled };
35810
36556
  }
36557
+ if (typeof body.guardrails === "object" && body.guardrails !== null) {
36558
+ const raw = body.guardrails;
36559
+ const guardrails = {};
36560
+ for (const field of [
36561
+ "reserveCredits",
36562
+ "dailyTokens",
36563
+ "modelDailyTokens"
36564
+ ]) {
36565
+ const value = raw[field];
36566
+ if (value === void 0) continue;
36567
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return sendJson$3(response, 400, {
36568
+ ok: false,
36569
+ error: `${field} must be a non-negative number.`
36570
+ });
36571
+ guardrails[field] = Math.floor(value);
36572
+ }
36573
+ patch.guardrails = guardrails;
36574
+ }
36575
+ if (body.outboundIdentity !== void 0) {
36576
+ const requested = normalizeOutboundSetting(body.outboundIdentity);
36577
+ if (requested !== body.outboundIdentity) return sendJson$3(response, 400, {
36578
+ ok: false,
36579
+ error: "Unsupported outbound identity."
36580
+ });
36581
+ patch.outboundIdentity = requested;
36582
+ }
35811
36583
  if (body.selectedAccountId !== void 0) {
35812
36584
  const selected = body.selectedAccountId;
35813
36585
  if (selected === null || typeof selected === "string") {
@@ -35860,6 +36632,53 @@ function registerWorkBuddyRoutes(ctx, store, modelSettings, preferences, options
35860
36632
  value: await getWorkBuddyWebStatus(store, modelSettings, preferences, options)
35861
36633
  });
35862
36634
  }
36635
+ if (path === "at-rest-key") {
36636
+ if (!isSameOriginMutation(request)) return sendJson$3(response, 403, {
36637
+ ok: false,
36638
+ error: "Cross-origin request rejected."
36639
+ });
36640
+ const keyStore = options.atRestKey;
36641
+ if (keyStore === void 0) return sendJson$3(response, 400, {
36642
+ ok: false,
36643
+ error: "At-rest key storage is not installed."
36644
+ });
36645
+ if (method === "DELETE") {
36646
+ await keyStore.clear();
36647
+ store.invalidate();
36648
+ clearCachedQuota$5();
36649
+ clearCachedCatalog$1();
36650
+ return sendJson$3(response, 200, {
36651
+ ok: true,
36652
+ value: await getWorkBuddyWebStatus(store, modelSettings, preferences, options)
36653
+ });
36654
+ }
36655
+ if (method !== "POST") return sendMethodNotAllowed$3(response);
36656
+ const body = await readRequestJson$3(request);
36657
+ const secret = typeof body.key === "string" ? body.key : "";
36658
+ if (secret.trim() === "") return sendJson$3(response, 400, {
36659
+ ok: false,
36660
+ error: "A key is required."
36661
+ });
36662
+ let keyId;
36663
+ try {
36664
+ keyId = await keyStore.adopt(secret);
36665
+ } catch (error) {
36666
+ return sendJson$3(response, 400, {
36667
+ ok: false,
36668
+ error: error instanceof Error ? error.message : String(error)
36669
+ });
36670
+ }
36671
+ store.invalidate();
36672
+ clearCachedQuota$5();
36673
+ clearCachedCatalog$1();
36674
+ return sendJson$3(response, 200, {
36675
+ ok: true,
36676
+ value: {
36677
+ ...await getWorkBuddyWebStatus(store, modelSettings, preferences, options),
36678
+ atRestKeyId: keyId
36679
+ }
36680
+ });
36681
+ }
35863
36682
  if (path === "accounts/strategy") {
35864
36683
  if (method !== "POST") return sendMethodNotAllowed$3(response);
35865
36684
  if (!isSameOriginMutation(request)) return sendJson$3(response, 403, {
@@ -35937,6 +36756,234 @@ function registerWorkBuddyRoutes(ctx, store, modelSettings, preferences, options
35937
36756
  };
35938
36757
  }
35939
36758
  //#endregion
36759
+ //#region src/host/workbuddy/dsml.ts
36760
+ /**
36761
+ * Recovery of tool calls the model PRINTED instead of emitting structurally.
36762
+ *
36763
+ * Measured upstream behaviour (and the reason workbuddy2api-hub has the same
36764
+ * recovery, `wb_proxy.py` "Printed tool calls on the Chat Completions path"):
36765
+ * a tools request sometimes answers with the call spelled out inside `content`
36766
+ * — either the full-width DSML form
36767
+ *
36768
+ * <||DSML|| calls><||DSML|| invoke name="run_code">
36769
+ * <||DSML|| parameter name="code">print(1)</||DSML|| parameter>
36770
+ * </||DSML|| invoke></||DSML|| calls>
36771
+ *
36772
+ * or a hybrid the model mixes on its own: a plain opening tag closed by DSML
36773
+ * tags, with the first parameter's close tag swallowed. Forwarded verbatim the
36774
+ * client sees prose, finds no call, and the turn simply ends — reported by
36775
+ * users as "it keeps stopping by itself".
36776
+ *
36777
+ * A structured `tool_calls` delta always wins; this only runs for text, which
36778
+ * is why the recovery is a pure function over the accumulated content.
36779
+ */
36780
+ const TAG_START = "<[^>]*DSML[^>]*";
36781
+ const CALLS_RE = new RegExp(`${TAG_START}calls>(.*?)</[^>]*DSML[^>]*calls>`, "s");
36782
+ const INVOKE_RE = new RegExp(`${TAG_START}invoke\\s+name=["']([^"']+)["']>(.*?)</[^>]*invoke>`, "s");
36783
+ const DSML_PARAM_RE = new RegExp(`${TAG_START}parameter\\s+name=["']([^"']+)["'][^>]*>(.*?)</[^>]*parameter>`, "s");
36784
+ const PARAM_TAGS = "parameter|param|arg|argument";
36785
+ const LEAK_PARAM_RE = new RegExp(`<\\s*(?:\\|\\s*DSML\\s*\\|\\s*)?(${PARAM_TAGS})\\b([^>]*)>(.*?)</\\s*(?:\\|\\s*DSML\\s*\\|\\s*)?(?:parameter|param|arg|argument)\\s*>`, "gis");
36786
+ const LEAK_TAG_RE = /<\s*\/?\s*(?:\|\s*DSML\s*\|\s*)?([A-Za-z_][A-Za-z0-9_]*)([^>]*)>/g;
36787
+ const LEAK_NAME_ATTR_RE = /name\s*=\s*["']([^"']*)["']/;
36788
+ const WRAPPER_TAGS = /* @__PURE__ */ new Set([
36789
+ "calls",
36790
+ "tool_calls",
36791
+ "function_calls",
36792
+ "function_call"
36793
+ ]);
36794
+ /** Fold the full-width spelling of the DSML token back to ASCII bars. */
36795
+ function normalizeDsmlBars(text) {
36796
+ return text.replace(/\uff5c+\s*DSML\s*\uff5c+/g, "|DSML|");
36797
+ }
36798
+ function callId() {
36799
+ return `call_${randomUUID().replace(/-/g, "").slice(0, 24)}`;
36800
+ }
36801
+ function argumentsOf(params) {
36802
+ return JSON.stringify(params);
36803
+ }
36804
+ function stripSpans(text, spans) {
36805
+ if (spans.length === 0) return text;
36806
+ const sorted = [...spans].sort((a, b) => a[0] - b[0]);
36807
+ const kept = [];
36808
+ let last = 0;
36809
+ for (const [start, end] of sorted) {
36810
+ if (start < last) continue;
36811
+ kept.push(text.slice(last, start));
36812
+ last = end;
36813
+ }
36814
+ kept.push(text.slice(last));
36815
+ return kept.join("").trim();
36816
+ }
36817
+ /** Recover the full-width `<||DSML|| …>` spelling. */
36818
+ function parseDsmlToolCalls(text) {
36819
+ if (text === "" || !text.includes("DSML")) return null;
36820
+ const block = CALLS_RE.exec(text);
36821
+ if (block === null) return null;
36822
+ const callsBlock = block[1] ?? "";
36823
+ const calls = [];
36824
+ for (const invoke of callsBlock.matchAll(new RegExp(INVOKE_RE.source, "gs"))) {
36825
+ const params = {};
36826
+ for (const param of (invoke[2] ?? "").matchAll(new RegExp(DSML_PARAM_RE.source, "gs"))) params[param[1] ?? ""] = (param[2] ?? "").trim();
36827
+ calls.push({
36828
+ id: callId(),
36829
+ name: invoke[1] ?? "",
36830
+ arguments: argumentsOf(params)
36831
+ });
36832
+ }
36833
+ if (calls.length === 0) return null;
36834
+ return {
36835
+ calls,
36836
+ clean: `${text.slice(0, block.index).trim()} ${text.slice(block.index + block[0].length).trim()}`.trim()
36837
+ };
36838
+ }
36839
+ /**
36840
+ * Recover the hybrid spelling, driven by the `<parameter>` elements.
36841
+ *
36842
+ * Parameter elements are spelled the same way in both shapes and a parameter
36843
+ * name is the one thing a call cannot do without, so they are the anchor; the
36844
+ * nearest preceding opening tag that is neither a wrapper nor a parameter names
36845
+ * the tool.
36846
+ */
36847
+ function parseLeakedToolCalls(text) {
36848
+ if (text === "" || !text.includes("<")) return null;
36849
+ const normalized = normalizeDsmlBars(text);
36850
+ const params = [];
36851
+ for (const match of normalized.matchAll(LEAK_PARAM_RE)) {
36852
+ const attrs = match[2] ?? "";
36853
+ const name = LEAK_NAME_ATTR_RE.exec(attrs)?.[1] ?? "";
36854
+ if (name === "" || match.index === void 0) continue;
36855
+ params.push({
36856
+ start: match.index,
36857
+ name,
36858
+ value: (match[3] ?? "").trim(),
36859
+ end: match.index + match[0].length
36860
+ });
36861
+ }
36862
+ if (params.length === 0) return null;
36863
+ const tags = [];
36864
+ for (const match of normalized.matchAll(LEAK_TAG_RE)) {
36865
+ if (match.index === void 0) continue;
36866
+ tags.push({
36867
+ start: match.index,
36868
+ end: match.index + match[0].length,
36869
+ name: (match[1] ?? "").toLowerCase(),
36870
+ closing: match[0].trimStart().startsWith("</"),
36871
+ attrs: match[2] ?? ""
36872
+ });
36873
+ }
36874
+ const groups = [];
36875
+ for (const site of params) {
36876
+ let owner = null;
36877
+ for (const tag of tags) {
36878
+ if (tag.start >= site.start) break;
36879
+ if (tag.closing) continue;
36880
+ if (WRAPPER_TAGS.has(tag.name)) continue;
36881
+ if (new RegExp(`^(${PARAM_TAGS})$`, "i").test(tag.name)) continue;
36882
+ owner = tag;
36883
+ }
36884
+ const nameAttr = owner === null ? null : LEAK_NAME_ATTR_RE.exec(owner.attrs);
36885
+ if ((owner === null ? "" : (nameAttr?.[1] ?? owner.name).trim()) === "") continue;
36886
+ const last = groups[groups.length - 1];
36887
+ if (last !== void 0 && owner !== null && last.owner !== null && last.owner.start === owner.start) {
36888
+ last.params[site.name] = site.value;
36889
+ last.spans.push([site.start, site.end]);
36890
+ } else groups.push({
36891
+ owner,
36892
+ params: { [site.name]: site.value },
36893
+ spans: [[site.start, site.end]]
36894
+ });
36895
+ }
36896
+ if (groups.length === 0) return null;
36897
+ const regions = [];
36898
+ const calls = [];
36899
+ for (const group of groups) {
36900
+ const first = group.owner?.start ?? Math.min(...group.spans.map((span) => span[0]));
36901
+ let last = Math.max(...group.spans.map((span) => span[1]));
36902
+ for (;;) {
36903
+ let next = last;
36904
+ while (next < normalized.length && " \r\n".includes(normalized[next] ?? "")) next += 1;
36905
+ const tag = /^<\s*\/\s*(?:\|\s*DSML\s*\|\s*)?[A-Za-z_][A-Za-z0-9_]*[^>]*>/i.exec(normalized.slice(next));
36906
+ if (tag === null) break;
36907
+ last = next + tag[0].length;
36908
+ }
36909
+ regions.push([first, last]);
36910
+ calls.push({
36911
+ id: callId(),
36912
+ name: (group.owner === null ? "" : LEAK_NAME_ATTR_RE.exec(group.owner.attrs)?.[1] ?? group.owner.name).trim(),
36913
+ arguments: argumentsOf(group.params)
36914
+ });
36915
+ }
36916
+ if (calls.length === 0) return null;
36917
+ let clean = stripSpans(normalized, regions);
36918
+ const leftover = [];
36919
+ for (const match of clean.matchAll(LEAK_TAG_RE)) {
36920
+ if (match.index === void 0) continue;
36921
+ const tagText = normalizeDsmlBars(match[0]).toUpperCase();
36922
+ const name = (match[1] ?? "").toLowerCase();
36923
+ if (tagText.includes("DSML") || WRAPPER_TAGS.has(name)) leftover.push([match.index, match.index + match[0].length]);
36924
+ }
36925
+ clean = stripSpans(clean, leftover);
36926
+ return {
36927
+ calls,
36928
+ clean
36929
+ };
36930
+ }
36931
+ /** Recover a printed call under either spelling. */
36932
+ function recoverPrintedToolCalls(text) {
36933
+ return parseLeakedToolCalls(text) ?? parseDsmlToolCalls(text);
36934
+ }
36935
+ const TAG_NAMES = "run_code|run_shell|invoke|parameter|param|arg|argument|calls|tool_calls|function_calls|function_call";
36936
+ const OPENER_RE = new RegExp(`^<\\s*/?\\s*(?:\\|\\s*DSML\\s*\\||(?:${TAG_NAMES})\\b)`, "i");
36937
+ const CLOSER_RE = new RegExp(`<\\s*/\\s*(?:\\|\\s*DSML\\s*\\|\\s*)?(?:${TAG_NAMES})(?:\\s+[^>]*)?>`, "i");
36938
+ const OPENERS = [
36939
+ "<run_code",
36940
+ "<run_shell",
36941
+ "<invoke",
36942
+ "<parameter",
36943
+ "<param",
36944
+ "<arg",
36945
+ "<argument",
36946
+ "<calls",
36947
+ "<tool_calls",
36948
+ "<function_calls",
36949
+ "<function_call",
36950
+ "</run_code",
36951
+ "</run_shell",
36952
+ "</invoke",
36953
+ "</parameter",
36954
+ "</param",
36955
+ "</arg",
36956
+ "</argument",
36957
+ "</calls",
36958
+ "</tool_calls",
36959
+ "</function_calls",
36960
+ "</function_call",
36961
+ "<|dsml|",
36962
+ "</|dsml|",
36963
+ "<||dsml",
36964
+ "</||dsml"
36965
+ ];
36966
+ /**
36967
+ * Index from which a streaming text buffer must be held back.
36968
+ *
36969
+ * The earliest `<` that either opens a recognisable tag or could still grow
36970
+ * into one (a delta may split `<parameter` across frames), or -1 when the
36971
+ * whole buffer is safe to emit as prose.
36972
+ */
36973
+ function printedCallHoldIndex(text) {
36974
+ for (let index = text.indexOf("<"); index !== -1; index = text.indexOf("<", index + 1)) {
36975
+ const rest = text.slice(index);
36976
+ if (OPENER_RE.test(rest)) return index;
36977
+ const head = rest.replace(/\s+/g, "").toLowerCase().slice(0, 18);
36978
+ if (OPENERS.some((opener) => opener.startsWith(head))) return index;
36979
+ }
36980
+ return -1;
36981
+ }
36982
+ /** Whether a held buffer has already reached a closing tag. */
36983
+ function printedCallLooksComplete(text) {
36984
+ return CLOSER_RE.test(text);
36985
+ }
36986
+ //#endregion
35940
36987
  //#region src/host/workbuddy/mapper.ts
35941
36988
  /**
35942
36989
  * Wire mapping for the WorkBuddy / CodeBuddy subscription chat endpoint.
@@ -36283,6 +37330,121 @@ function openAIAssistantContent$2(message) {
36283
37330
  toolCalls
36284
37331
  };
36285
37332
  }
37333
+ /** Whether one wire message is an assistant turn. */
37334
+ function isAssistantTurn(message) {
37335
+ return message.role === "assistant";
37336
+ }
37337
+ /**
37338
+ * The text of one assistant turn, or `undefined` when the message is not an
37339
+ * assistant turn or does not carry plain text.
37340
+ */
37341
+ function assistantTextOf(message) {
37342
+ if (!isAssistantTurn(message)) return void 0;
37343
+ return typeof message.content === "string" ? message.content : void 0;
37344
+ }
37345
+ /** The tool calls one wire message declares, which may be none. */
37346
+ function toolCallsOf(message) {
37347
+ return Array.isArray(message.tool_calls) ? message.tool_calls : [];
37348
+ }
37349
+ /**
37350
+ * Concatenate one assistant turn's text onto another.
37351
+ *
37352
+ * A concatenation that would merge two words is separated by a single space: a
37353
+ * batch split across turns ends its first turn mid-sentence, so `"let me"` +
37354
+ * `"look"` has to leave as `"let me look"` and not `"let melook"`.
37355
+ */
37356
+ function appendAssistantText(base, extra) {
37357
+ if (extra === "") return base;
37358
+ if (base === "") return extra;
37359
+ if (/\s$/.test(base) || /^\s/.test(extra) || /[\u3000-\u9fff\uff00-\uff9f]/.test(base + extra)) return base + extra;
37360
+ return `${base} ${extra}`;
37361
+ }
37362
+ /**
37363
+ * Fold adjacent assistant turns that each carry tool calls into one turn.
37364
+ *
37365
+ * A parallel batch the harness serialized as two assistant messages is one
37366
+ * batch to the upstream, and only one assistant turn may declare it: a gateway
37367
+ * that sees the batch split rejects every later turn of that conversation. Text
37368
+ * is concatenated and the calls are appended in order, ids untouched. A tool
37369
+ * result between two assistant turns is what proves they are separate turns,
37370
+ * so only the immediately adjacent run is merged — the following tool results
37371
+ * keep their own order.
37372
+ */
37373
+ function mergeSplitToolCallBatches(messages) {
37374
+ const repaired = [];
37375
+ for (const message of messages) {
37376
+ const text = assistantTextOf(message);
37377
+ const calls = toolCallsOf(message);
37378
+ const previous = repaired.length > 0 ? repaired[repaired.length - 1] : void 0;
37379
+ const previousText = previous === void 0 ? void 0 : assistantTextOf(previous);
37380
+ if (text !== void 0 && previous !== void 0 && previousText !== void 0 && (calls.length > 0 || toolCallsOf(previous).length > 0)) {
37381
+ repaired[repaired.length - 1] = {
37382
+ ...previous,
37383
+ content: appendAssistantText(previousText, text),
37384
+ tool_calls: [...toolCallsOf(previous), ...calls]
37385
+ };
37386
+ continue;
37387
+ }
37388
+ repaired.push(message);
37389
+ }
37390
+ return repaired;
37391
+ }
37392
+ /**
37393
+ * The text an assistant tool call is answered with when its result never came.
37394
+ *
37395
+ * This text is invented on purpose, exactly as the sibling gateway invents it:
37396
+ * an interrupted run leaves the call declared with no `role: "tool"` reply, and
37397
+ * the upstream rejects the whole turn rather than the missing result. Sending a
37398
+ * sentence the model can read beats both alternatives — dropping the call
37399
+ * silently rewrites history the model already saw (and leaves the *next*
37400
+ * assistant turn referring to a call that is no longer there), while dropping
37401
+ * the turn throws away the rest of the conversation's context. The wording is
37402
+ * deliberately unlike a real tool output: it is short, says it failed, and
37403
+ * carries no content that could be mistaken for a result.
37404
+ */
37405
+ const NO_TOOL_RESULT_TEXT = "[no result: the tool call did not complete]";
37406
+ /**
37407
+ * Answer every declared tool call that has no `role: "tool"` message.
37408
+ *
37409
+ * The window is the stretch before the next assistant turn: call ids are only
37410
+ * meaningful inside one turn's batch, and a later turn's results may reuse none
37411
+ * of them. Calls that were answered are left alone, and the invented replies go
37412
+ * after the results that did arrive so the real ones keep their position.
37413
+ */
37414
+ function fillMissingToolResults(messages) {
37415
+ const repaired = [];
37416
+ let index = 0;
37417
+ while (index < messages.length) {
37418
+ const message = messages[index];
37419
+ repaired.push(message);
37420
+ index += 1;
37421
+ if (!isAssistantTurn(message)) continue;
37422
+ const calls = toolCallsOf(message);
37423
+ if (calls.length === 0) continue;
37424
+ const answered = /* @__PURE__ */ new Set();
37425
+ const results = [];
37426
+ while (index < messages.length && !isAssistantTurn(messages[index])) {
37427
+ const result = messages[index];
37428
+ if (result.role === "tool" && typeof result.tool_call_id === "string") answered.add(result.tool_call_id);
37429
+ results.push(result);
37430
+ index += 1;
37431
+ }
37432
+ const invented = [];
37433
+ for (const call of calls) {
37434
+ const id = typeof call.id === "string" ? call.id : "";
37435
+ if (id === "" || answered.has(id)) continue;
37436
+ invented.push({
37437
+ role: "tool",
37438
+ tool_call_id: id,
37439
+ content: NO_TOOL_RESULT_TEXT
37440
+ });
37441
+ }
37442
+ let insertAt = results.length;
37443
+ while (insertAt > 0 && results[insertAt - 1].role === "user") insertAt -= 1;
37444
+ repaired.push(...results.slice(0, insertAt), ...invented, ...results.slice(insertAt));
37445
+ }
37446
+ return repaired;
37447
+ }
36286
37448
  /**
36287
37449
  * Build one `/v2/chat/completions` body.
36288
37450
  *
@@ -36337,12 +37499,13 @@ function buildChatRequest$2(options, images = NO_RESOLVED_IMAGES$2) {
36337
37499
  });
36338
37500
  }
36339
37501
  const effort = options.reasoningEffort === void 0 ? void 0 : String(options.reasoningEffort);
37502
+ const repairedMessages = fillMissingToolResults(mergeSplitToolCallBatches(messages));
36340
37503
  return {
36341
37504
  model: options.model,
36342
- messages,
37505
+ messages: repairedMessages,
36343
37506
  stream: true,
36344
37507
  stream_options: { include_usage: true },
36345
- max_tokens: options.maxTokens ?? maxOutputTokensFor$3(options.model),
37508
+ max_tokens: clampMinimumOutput(options.model, options.maxTokens ?? maxOutputTokensFor$3(options.model)),
36346
37509
  ...options.temperature === void 0 ? {} : { temperature: options.temperature },
36347
37510
  ...options.stop && options.stop.length > 0 ? { stop: options.stop } : {},
36348
37511
  ...options.tools && options.tools.length > 0 ? {
@@ -36374,9 +37537,122 @@ function createStreamState$4() {
36374
37537
  cacheReadTokens: 0,
36375
37538
  cacheWriteTokens: 0,
36376
37539
  reasoningTokens: 0,
36377
- sawUsage: false
37540
+ sawUsage: false,
37541
+ printed: {
37542
+ pending: "",
37543
+ holding: false
37544
+ }
36378
37545
  };
36379
37546
  }
37547
+ /**
37548
+ * Smallest output request the GPT-family upstreams accept.
37549
+ *
37550
+ * Measured upstream behaviour (also pinned by workbuddy2api-hub as #364/#252):
37551
+ * a `gpt-*` model rejects `max_tokens` below 16 with 400
37552
+ * `integer_below_min_value`, while every other family accepts a small value.
37553
+ * This route only ever asks for a small cap on purpose — the connection probe and
37554
+ * an output-limit probe both do — so the floor is applied here rather than left
37555
+ * for each caller to remember.
37556
+ */
37557
+ const MINIMUM_GPT_MAX_TOKENS = 16;
37558
+ /** Raise a GPT-family output request to the floor that family enforces. */
37559
+ function clampMinimumOutput(model, maxTokens) {
37560
+ if (!model.trimStart().toLowerCase().startsWith("gpt-")) return maxTokens;
37561
+ return Math.max(maxTokens, MINIMUM_GPT_MAX_TOKENS);
37562
+ }
37563
+ /** Longest printed-call region held before it is written out as prose anyway. */
37564
+ const PRINTED_CALL_LIMIT = 64 * 1024;
37565
+ /** Append prose to the open text block, opening one when needed. */
37566
+ function emitProse(state, text) {
37567
+ if (text === "") return [];
37568
+ const out = closeToolCalls$2(state);
37569
+ if (state.current === null || state.current.type !== "text") out.push(...openTextBlock$2(state, "text"));
37570
+ state.current.text += text;
37571
+ state.hasContent = true;
37572
+ out.push({
37573
+ type: "text-delta",
37574
+ index: state.current.index,
37575
+ text
37576
+ });
37577
+ return out;
37578
+ }
37579
+ /** Emit recovered calls as settled tool-call blocks. */
37580
+ function emitPrintedCalls(state, calls) {
37581
+ const out = closeCurrent$2(state);
37582
+ for (const call of calls) {
37583
+ const index = state.blocks.length;
37584
+ const id = toToolCallId(call.id);
37585
+ const block = {
37586
+ type: "tool-call",
37587
+ id,
37588
+ name: call.name,
37589
+ arguments: call.arguments
37590
+ };
37591
+ state.blocks.push(block);
37592
+ out.push({
37593
+ type: "block-start",
37594
+ index,
37595
+ blockType: "tool-call"
37596
+ });
37597
+ out.push({
37598
+ type: "tool-call-delta",
37599
+ index,
37600
+ id,
37601
+ name: call.name,
37602
+ argumentsDelta: call.arguments
37603
+ });
37604
+ out.push({
37605
+ type: "block-end",
37606
+ index,
37607
+ block
37608
+ });
37609
+ state.hasToolCall = true;
37610
+ state.hasContent = true;
37611
+ }
37612
+ return out;
37613
+ }
37614
+ /**
37615
+ * Release held text, resolving it into prose or calls.
37616
+ *
37617
+ * `force` is what the end of a turn passes: whatever is still held must be
37618
+ * written out rather than dropped, even when it never closed.
37619
+ */
37620
+ function drainPrinted(state, force) {
37621
+ const out = [];
37622
+ for (;;) {
37623
+ const pending = state.printed.pending;
37624
+ if (pending === "") return out;
37625
+ if (!state.printed.holding) {
37626
+ const hold = printedCallHoldIndex(pending);
37627
+ if (hold === -1) {
37628
+ out.push(...emitProse(state, pending));
37629
+ state.printed.pending = "";
37630
+ return out;
37631
+ }
37632
+ if (hold > 0) out.push(...emitProse(state, pending.slice(0, hold)));
37633
+ state.printed.pending = pending.slice(hold);
37634
+ state.printed.holding = true;
37635
+ }
37636
+ const buffer = state.printed.pending;
37637
+ if (!force && !printedCallLooksComplete(buffer) && buffer.length < PRINTED_CALL_LIMIT) return out;
37638
+ state.printed.holding = false;
37639
+ state.printed.pending = "";
37640
+ const recovered = recoverPrintedToolCalls(buffer);
37641
+ if (recovered === null) {
37642
+ out.push(...emitProse(state, buffer));
37643
+ return out;
37644
+ }
37645
+ if (recovered.clean !== "") out.push(...emitProse(state, recovered.clean));
37646
+ out.push(...emitPrintedCalls(state, recovered.calls));
37647
+ return out;
37648
+ }
37649
+ }
37650
+ /** Append chat text, recovering a call the model printed instead of emitting. */
37651
+ function feedText(state, text) {
37652
+ if (text === "") return [];
37653
+ state.printed.pending += text;
37654
+ return drainPrinted(state, false);
37655
+ }
36380
37656
  function closeCurrent$2(state) {
36381
37657
  if (state.current === null) return [];
36382
37658
  const { index, type, text } = state.current;
@@ -36478,17 +37754,7 @@ function processStreamLine$3(line, state) {
36478
37754
  });
36479
37755
  }
36480
37756
  const content = asString$11(delta.content);
36481
- if (content !== void 0 && content !== "") {
36482
- out.push(...closeToolCalls$2(state));
36483
- if (state.current === null || state.current.type !== "text") out.push(...openTextBlock$2(state, "text"));
36484
- state.current.text += sanitizeText$4(content);
36485
- state.hasContent = true;
36486
- out.push({
36487
- type: "text-delta",
36488
- index: state.current.index,
36489
- text: sanitizeText$4(content)
36490
- });
36491
- }
37757
+ if (content !== void 0 && content !== "") out.push(...feedText(state, sanitizeText$4(content)));
36492
37758
  const toolDeltas = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
36493
37759
  for (const entry of toolDeltas) {
36494
37760
  if (!isRecord$20(entry)) continue;
@@ -36498,6 +37764,7 @@ function processStreamLine$3(line, state) {
36498
37764
  const finish = asString$11(choice?.finish_reason);
36499
37765
  if (finish !== void 0 && finish !== "") {
36500
37766
  state.finishReason = finish;
37767
+ out.push(...drainPrinted(state, true));
36501
37768
  out.push(...closeCurrent$2(state));
36502
37769
  out.push(...closeToolCalls$2(state));
36503
37770
  }
@@ -36572,7 +37839,11 @@ function finishReasonFor$4(state) {
36572
37839
  function closeStream$3(state) {
36573
37840
  if (state.finished) return [];
36574
37841
  state.finished = true;
36575
- const out = [...closeCurrent$2(state), ...closeToolCalls$2(state)];
37842
+ const out = [
37843
+ ...drainPrinted(state, true),
37844
+ ...closeCurrent$2(state),
37845
+ ...closeToolCalls$2(state)
37846
+ ];
36576
37847
  if (state.sawUsage) out.push({
36577
37848
  type: "usage",
36578
37849
  usage: tokenUsage$3(state)
@@ -36758,6 +38029,27 @@ var WorkBuddyAdapter = class extends LlmAdapter {
36758
38029
  * A candidate the model does not accept is discarded rather than sent, since
36759
38030
  * the endpoint rejects an unsupported level with `code 11150`.
36760
38031
  */
38032
+ /**
38033
+ * Accounts the LOCAL guardrails refuse for this model right now.
38034
+ *
38035
+ * Reads only numbers this plugin already owns — the billing snapshot's
38036
+ * remaining credits and this line's own token counters — so it never becomes a
38037
+ * second, disagreeing source of truth about the upstream allowance.
38038
+ */
38039
+ async guardrailBlockedAccounts(settings, model) {
38040
+ const blocked = /* @__PURE__ */ new Set();
38041
+ const ledger = this.options.usageLedger;
38042
+ const pool = this.accountPool;
38043
+ if (pool === null) return blocked;
38044
+ if (ledger === void 0 || !guardrailsArmed(settings.guardrails)) return blocked;
38045
+ for (const account of await pool.listAccounts()) if (!evaluateGuardrails({
38046
+ guardrails: settings.guardrails,
38047
+ tokensToday: ledger.tokensToday(account.id),
38048
+ modelTokensToday: ledger.modelTokensToday(account.id, model),
38049
+ remainingCredits: getCachedQuotaFor$1(account.id)?.remainingCredits
38050
+ }).allowed) blocked.add(account.id);
38051
+ return blocked;
38052
+ }
36761
38053
  async effortFor(options) {
36762
38054
  const settings = await this.settings();
36763
38055
  const credentials = await this.credentials(settings);
@@ -36784,7 +38076,9 @@ var WorkBuddyAdapter = class extends LlmAdapter {
36784
38076
  const pool = this.accountPool;
36785
38077
  const tried = /* @__PURE__ */ new Set();
36786
38078
  let response;
36787
- while (true) {
38079
+ let servedAccountId;
38080
+ if (pool !== null) for (const blockedId of await this.guardrailBlockedAccounts(settings, options.model)) tried.add(blockedId);
38081
+ rotation: while (true) {
36788
38082
  let credentials;
36789
38083
  let accountId;
36790
38084
  if (pool === null) {
@@ -36795,36 +38089,58 @@ var WorkBuddyAdapter = class extends LlmAdapter {
36795
38089
  } else {
36796
38090
  let effective;
36797
38091
  try {
36798
- effective = await pool.getEffectiveCredential(tried, fetchFn);
38092
+ effective = await pool.getEffectiveCredential(tried, fetchFn, options.model);
36799
38093
  } catch (error) {
36800
38094
  if (error instanceof LlmError) throw error;
36801
38095
  throw new LlmError(error instanceof Error ? error.message : `Not signed in to ${PROVIDER_NAME$3}.`, "MISSING_CREDENTIAL", { cause: error });
36802
38096
  }
36803
38097
  accountId = effective.account.id;
38098
+ servedAccountId = accountId;
36804
38099
  tried.add(accountId);
36805
38100
  credentials = effective.credentials;
36806
38101
  }
36807
- try {
36808
- response = await fetchWithContextBudgetRecovery(fetchFn)(`${credentials.backend}${CHAT_PATH}`, {
36809
- method: "POST",
36810
- headers: workBuddyHeaders(credentials, { accept: "text/event-stream" }, { ...requestOptions.sessionId === void 0 ? {} : { sessionId: String(requestOptions.sessionId) } }),
36811
- body,
36812
- signal
36813
- });
36814
- } catch (error) {
36815
- if (signal.aborted) throw new LlmError("WorkBuddy request aborted", "ABORTED", { cause: error });
36816
- throw new LlmError(`WorkBuddy request failed: ${error instanceof Error ? error.message : String(error)}`, "TRANSPORT", { cause: error });
38102
+ let renewed = false;
38103
+ let refusedDetail = "";
38104
+ while (true) {
38105
+ try {
38106
+ response = await fetchWithContextBudgetRecovery(fetchFn)(`${credentials.backend}${CHAT_PATH}`, {
38107
+ method: "POST",
38108
+ headers: workBuddyHeaders(credentials, { accept: "text/event-stream" }, {
38109
+ ...requestOptions.sessionId === void 0 ? {} : { sessionId: String(requestOptions.sessionId) },
38110
+ product: resolveOutboundProduct(settings.outboundIdentity, accountId ?? credentials.uid ?? "")
38111
+ }),
38112
+ body,
38113
+ signal
38114
+ });
38115
+ } catch (error) {
38116
+ if (signal.aborted) throw new LlmError("WorkBuddy request aborted", "ABORTED", { cause: error });
38117
+ throw new LlmError(`WorkBuddy request failed: ${error instanceof Error ? error.message : String(error)}`, "TRANSPORT", { cause: error });
38118
+ }
38119
+ if (response.ok) break;
38120
+ refusedDetail = (await response.text().catch(() => "")).slice(0, 600);
38121
+ if (renewed || response.status !== 401 || pool === null || accountId === void 0) break;
38122
+ renewed = true;
38123
+ const refusedAccount = accountId;
38124
+ try {
38125
+ credentials = await pool.renewCredential(refusedAccount, fetchFn, credentials);
38126
+ } catch (error) {
38127
+ const verdict = error instanceof LlmError ? error : new LlmError(`${PROVIDER_NAME$3} credential could not be renewed after the service refused it: ${error instanceof Error ? error.message : String(error)}`, "INVALID_CREDENTIAL", { cause: error });
38128
+ if (await pool.hasAnotherAvailableAccount(tried)) continue rotation;
38129
+ throw verdict;
38130
+ }
36817
38131
  }
36818
38132
  if (response.ok) break;
36819
- const detail = (await response.text().catch(() => "")).slice(0, 600);
36820
- const failure = classifyFailure$1(response.status, detail, response.headers);
38133
+ const failure = classifyFailure$1(response.status, refusedDetail, response.headers);
36821
38134
  if (pool !== null && accountId !== void 0) {
36822
- const after = retryAfterMs$1(response.headers);
36823
- if (failure.code === "RATE_LIMIT") {
38135
+ const after = failure.failure.providerRetryAfterMs ?? retryAfterMs$1(response.headers);
38136
+ if (failure.code === "RATE_LIMIT") if (after !== void 0 && after > 3e5) {
38137
+ if (accountId !== void 0) pool.markModelCooldown(accountId, options.model, after);
38138
+ if (await pool.hasAnotherAvailableAccountFor(tried, options.model)) continue;
38139
+ } else {
36824
38140
  await pool.markCooldown(accountId, after ?? 3e5, `${PROVIDER_NAME$3} 429`).catch(() => void 0);
36825
38141
  if (await pool.hasAnotherAvailableAccount(tried)) continue;
36826
- } else if (failure.code === "INVALID_CREDENTIAL") {
36827
- await pool.markAuthFailed(accountId, failure.message).catch(() => void 0);
38142
+ }
38143
+ else if (failure.code === "INVALID_CREDENTIAL") {
36828
38144
  if (await pool.hasAnotherAvailableAccount(tried)) continue;
36829
38145
  }
36830
38146
  }
@@ -36836,6 +38152,7 @@ var WorkBuddyAdapter = class extends LlmAdapter {
36836
38152
  const decoder = new TextDecoder();
36837
38153
  const state = createStreamState$4();
36838
38154
  let buffer = "";
38155
+ let recordedTokens = 0;
36839
38156
  try {
36840
38157
  while (true) {
36841
38158
  const { done, value } = await reader.read();
@@ -36846,16 +38163,23 @@ var WorkBuddyAdapter = class extends LlmAdapter {
36846
38163
  buffer = lines.pop() ?? "";
36847
38164
  for (const line of lines) {
36848
38165
  activity?.pulse();
36849
- for (const chunk of processStreamLine$3(line, state)) yield chunk;
38166
+ for (const chunk of processStreamLine$3(line, state)) {
38167
+ if (chunk.type === "usage") recordedTokens = chunk.usage.inputTokens + chunk.usage.outputTokens + (chunk.usage.cacheReadTokens ?? 0) + (chunk.usage.cacheWriteTokens ?? 0);
38168
+ yield chunk;
38169
+ }
36850
38170
  if (state.finished) return;
36851
38171
  }
36852
38172
  }
36853
38173
  buffer += decoder.decode();
36854
- if (buffer.trim() !== "") for (const line of buffer.split("\n")) for (const chunk of processStreamLine$3(line, state)) yield chunk;
38174
+ if (buffer.trim() !== "") for (const line of buffer.split("\n")) for (const chunk of processStreamLine$3(line, state)) {
38175
+ if (chunk.type === "usage") recordedTokens = chunk.usage.inputTokens + chunk.usage.outputTokens + (chunk.usage.cacheReadTokens ?? 0) + (chunk.usage.cacheWriteTokens ?? 0);
38176
+ yield chunk;
38177
+ }
36855
38178
  if (state.finished) return;
36856
38179
  assertStreamComplete$4(state);
36857
38180
  for (const chunk of closeStream$3(state)) yield chunk;
36858
38181
  } finally {
38182
+ if (servedAccountId !== void 0 && recordedTokens > 0) this.options.usageLedger?.record(servedAccountId, options.model, recordedTokens);
36859
38183
  reader.cancel().catch(() => void 0);
36860
38184
  }
36861
38185
  }
@@ -36875,7 +38199,7 @@ function classifyFailure$1(status, detail, headers) {
36875
38199
  return new LlmError(`${PROVIDER_NAME$3} rejected the stored credential (${status}). Sign in again with the CodeBuddy desktop client.${detail ? ` ${detail}` : ""}`, "INVALID_CREDENTIAL", { status });
36876
38200
  }
36877
38201
  if (status === 429 || code === 6004 || code === 14003) {
36878
- const after = headers ? retryAfterMs$1(headers) : void 0;
38202
+ const after = (headers ? retryAfterMs$1(headers) : void 0) ?? readRateLimitReset(detail);
36879
38203
  return new LlmError(`${PROVIDER_NAME$3} rate limit or plan quota reached. Check the quota card in Settings > WorkBuddy.${detail ? ` ${detail}` : ""}`, "RATE_LIMIT", {
36880
38204
  status: status === 429 ? 429 : void 0,
36881
38205
  ...after === void 0 ? {} : { providerRetryAfterMs: after }
@@ -36931,6 +38255,45 @@ function refusalIsNotCredential(detail) {
36931
38255
  const type = inner.type ?? envelope.type;
36932
38256
  return typeof type === "string" && type === "server_error" && hasMessage;
36933
38257
  }
38258
+ /** How far out a reset instant may be and still be treated as one. */
38259
+ const MAX_RESET_HORIZON_MS = 10080 * 60 * 1e3;
38260
+ const RESET_STAMP_RE = /(?:resets?\s+at|reset\s+at|将在)\s*(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/;
38261
+ const RESET_TZ_RE = /UTC([+-])(\d{1,2})(?::?(\d{2}))?/;
38262
+ /**
38263
+ * The reset instant a quota refusal names, as a delay from now.
38264
+ *
38265
+ * A 24h allowance that is exhausted answers 429 / `code 6004` with the wall
38266
+ * clock it lifts — but only in the MESSAGE, never in a header, and in two
38267
+ * realm-specific wordings (measured: intl "… resets at 2026-09-20 10:50:48
38268
+ * UTC+8", cn "… 将在 2026-10-09 14:44:59 UTC+8 重置"). Reading the status alone
38269
+ * therefore loses the only fact that says how long the account is out, and the
38270
+ * pool parks a 24-hour limit for five minutes before trying it again.
38271
+ *
38272
+ * Deliberately tolerant, in the same direction as their gateway: an unparseable
38273
+ * body yields `undefined` (callers keep their default cooldown) rather than an
38274
+ * error, because this runs on a failure path. An absent `UTC±h` marker means
38275
+ * UTC, which is how the sibling implementation reads it too.
38276
+ */
38277
+ function readRateLimitReset(detail, now = Date.now()) {
38278
+ const stamp = RESET_STAMP_RE.exec(detail);
38279
+ if (stamp === null) return void 0;
38280
+ const [, date, clock] = stamp;
38281
+ const parts = `${date} ${clock}`.split(/[- :]/).map((part) => Number(part));
38282
+ const [year, month, day, hour, minute, second] = parts;
38283
+ if (parts.length !== 6 || parts.some((part) => !Number.isFinite(part))) return void 0;
38284
+ const zone = RESET_TZ_RE.exec(detail);
38285
+ let offsetMs = 0;
38286
+ if (zone !== null) {
38287
+ const hours = Number(zone[2] ?? "0");
38288
+ const minutes = Number(zone[3] ?? "0");
38289
+ offsetMs = (zone[1] === "-" ? -1 : 1) * (hours * 60 + minutes) * 6e4;
38290
+ }
38291
+ const at = Date.UTC(year ?? 0, (month ?? 1) - 1, day ?? 1, hour ?? 0, minute ?? 0, second ?? 0) - offsetMs;
38292
+ if (!Number.isFinite(at)) return void 0;
38293
+ const delay = at - now;
38294
+ if (delay <= 0 || delay > MAX_RESET_HORIZON_MS) return void 0;
38295
+ return delay;
38296
+ }
36934
38297
  /** Read the subscription's own numeric error code out of a response body. */
36935
38298
  function readErrorCode(detail) {
36936
38299
  const matched = /"code"\s*:\s*(\d+)/.exec(detail);
@@ -37145,6 +38508,17 @@ var WorkBuddyAccountPool = class extends AccountPoolCore {
37145
38508
  * decision goes through, so the mapping is never stale when it is consulted.
37146
38509
  */
37147
38510
  legacyIds = /* @__PURE__ */ new Map();
38511
+ /**
38512
+ * Per-model parks, in memory only: account id → model → the instant it lifts.
38513
+ *
38514
+ * The subscription meters an account × MODEL over a 24h window (429 `code
38515
+ * 6004` names when it lifts), so cooling the whole ACCOUNT for one exhausted
38516
+ * model throws away every other model that account can still serve. Process
38517
+ * local on purpose, like the sibling gateway's model cooldowns: the window is
38518
+ * short, and a restart re-measures it with the first request rather than
38519
+ * trusting a stale timestamp.
38520
+ */
38521
+ modelCooldowns = /* @__PURE__ */ new Map();
37148
38522
  constructor(options = {}) {
37149
38523
  const store = options.store ?? new FileCredentialStore$4();
37150
38524
  const hooks = {
@@ -37262,14 +38636,74 @@ var WorkBuddyAccountPool = class extends AccountPoolCore {
37262
38636
  return this.listAccounts();
37263
38637
  }
37264
38638
  /**
38639
+ * A stale `authStatus` is cleared once the credential in force has moved on.
38640
+ *
38641
+ * The marker records that ONE token was refused; it does not record that the
38642
+ * sign-in is gone. This line can still produce it from a token-endpoint
38643
+ * refusal, and the copy that refusal was about can be replaced afterwards by
38644
+ * whoever owns it — the IDE rotates a desktop account's token on its own
38645
+ * schedule, and the daily check-in renews a managed one and writes it back to
38646
+ * this plugin's store. Nothing else in the system ever revisits an
38647
+ * `authStatus`, so without this a row stays out of rotation for good and the
38648
+ * card keeps saying "需要重新登录" for an account that is signed in.
38649
+ *
38650
+ * The verdict is re-checked against what the store holds NOW: a row whose
38651
+ * account has a DIFFERENT, unexpired token is routable again and adopts it,
38652
+ * while a row still holding the refused token keeps its marker — that one
38653
+ * really does need a sign-in.
38654
+ */
38655
+ async healStaleAuthFailures(current) {
38656
+ if (!(current ?? await this.read()).accounts.some(isMarkedFailed)) return false;
38657
+ const live = await this.store.list().catch(() => []);
38658
+ if (live.length === 0) return false;
38659
+ const byId = /* @__PURE__ */ new Map();
38660
+ for (const credentials of live) byId.set(workBuddyAccountId(credentials), credentials);
38661
+ return await this.updatePool((draft) => {
38662
+ let changed = false;
38663
+ for (const account of draft.accounts) {
38664
+ if (!isMarkedFailed(account)) continue;
38665
+ const candidate = byId.get(account.id);
38666
+ if (candidate === void 0) continue;
38667
+ if (candidate.accessToken === account.credentials.accessToken) continue;
38668
+ if (isExpired(candidate)) continue;
38669
+ account.authStatus = void 0;
38670
+ account.authFailedReason = void 0;
38671
+ account.credentials = candidate;
38672
+ changed = true;
38673
+ }
38674
+ return changed;
38675
+ });
38676
+ }
38677
+ /** Account summaries, with a marker the credentials have outlived cleared. */
38678
+ async listAccounts() {
38679
+ const summaries = await super.listAccounts();
38680
+ if (!summaries.some(isMarkedFailed)) return summaries;
38681
+ await this.healStaleAuthFailures().catch(() => void 0);
38682
+ return await super.listAccounts();
38683
+ }
38684
+ /**
38685
+ * Pick an account, healing a stale marker first.
38686
+ *
38687
+ * The healing belongs on the request path, not only on the card: an account
38688
+ * marked expired is excluded by the core's eligibility rule, so until the
38689
+ * marker is gone the next request is answered with "全部 N 个账号都需要重新登录"
38690
+ * for a credential the store has already replaced.
38691
+ */
38692
+ async getEffectiveAccount(excludeIds, fetchFn = fetch, forceRefresh = false) {
38693
+ const data = await this.read();
38694
+ if (data.accounts.some(isMarkedFailed)) await this.healStaleAuthFailures(data).catch(() => void 0);
38695
+ return await super.getEffectiveAccount(excludeIds, fetchFn, forceRefresh);
38696
+ }
38697
+ /**
37265
38698
  * Pick the account for the next request.
37266
38699
  *
37267
38700
  * A desktop account's credential is re-read from the IDE's file first: the IDE
37268
38701
  * refreshes its own token, so the copy the pool adopted can be older than what
37269
38702
  * is on disk.
37270
38703
  */
37271
- async getEffectiveCredential(excludeIds, fetchFn = fetch) {
37272
- const effective = await this.getEffectiveAccount(excludeIds, fetchFn);
38704
+ async getEffectiveCredential(excludeIds, fetchFn = fetch, model) {
38705
+ const parked = model === void 0 ? /* @__PURE__ */ new Set() : this.parkedAccountIds(model);
38706
+ const effective = await this.getEffectiveAccount(parked.size === 0 ? excludeIds : /* @__PURE__ */ new Set([...excludeIds ?? [], ...parked]), fetchFn);
37273
38707
  if (effective.account.source !== "desktop" || effective.account.sourceFile === void 0) return effective;
37274
38708
  const live = await this.store.readDesktopFile(effective.account.sourceFile);
37275
38709
  if (live === null) return effective;
@@ -37287,6 +38721,52 @@ var WorkBuddyAccountPool = class extends AccountPoolCore {
37287
38721
  };
37288
38722
  }
37289
38723
  /**
38724
+ * Park ONE model on one account; every other model keeps serving.
38725
+ *
38726
+ * Returns the instant the park lifts, or undefined for a non-positive
38727
+ * duration (which clears any existing park rather than extending it).
38728
+ */
38729
+ markModelCooldown(accountId, model, durationMs) {
38730
+ if (!Number.isFinite(durationMs) || durationMs <= 0) {
38731
+ this.modelCooldowns.get(accountId)?.delete(model);
38732
+ return;
38733
+ }
38734
+ const until = Date.now() + durationMs;
38735
+ const forAccount = this.modelCooldowns.get(accountId) ?? /* @__PURE__ */ new Map();
38736
+ forAccount.set(model, until);
38737
+ this.modelCooldowns.set(accountId, forAccount);
38738
+ return until;
38739
+ }
38740
+ /** When this account's park on this model lifts, or undefined when it may serve. */
38741
+ modelCooldownUntil(accountId, model, now = Date.now()) {
38742
+ const until = this.modelCooldowns.get(accountId)?.get(model);
38743
+ return until !== void 0 && until > now ? until : void 0;
38744
+ }
38745
+ /** Accounts currently parked on one model, with lifted entries pruned on the way. */
38746
+ parkedAccountIds(model, now = Date.now()) {
38747
+ const parked = /* @__PURE__ */ new Set();
38748
+ for (const [accountId, models] of this.modelCooldowns) {
38749
+ const until = models.get(model);
38750
+ if (until === void 0) continue;
38751
+ if (until > now) parked.add(accountId);
38752
+ else models.delete(model);
38753
+ }
38754
+ return parked;
38755
+ }
38756
+ /**
38757
+ * Whether another account besides `triedAccountIds` can serve THIS model.
38758
+ *
38759
+ * The plain {@link hasAnotherAvailableAccount} answers for any model, so a
38760
+ * rotation loop that consulted it would keep trying while every remaining
38761
+ * account is parked on the model it is asking for — turning one exhausted
38762
+ * window into a burst of refused requests.
38763
+ */
38764
+ async hasAnotherAvailableAccountFor(triedAccountIds, model) {
38765
+ const parked = this.parkedAccountIds(model);
38766
+ if (parked.size === 0) return super.hasAnotherAvailableAccount(triedAccountIds);
38767
+ return super.hasAnotherAvailableAccount(/* @__PURE__ */ new Set([...triedAccountIds, ...parked]));
38768
+ }
38769
+ /**
37290
38770
  * Whether an account may serve a request.
37291
38771
  *
37292
38772
  * A hidden account keeps its place, its alias and its history, but the user
@@ -37328,6 +38808,15 @@ var WorkBuddyAccountPool = class extends AccountPoolCore {
37328
38808
  super.deleteAccountFromPool(data, accountId);
37329
38809
  }
37330
38810
  };
38811
+ /**
38812
+ * Whether a row's credential was reported as refused and never cleared.
38813
+ *
38814
+ * Mirrors the core's own eligibility predicate (`authStatus` present and not
38815
+ * `ok`), so the heal agrees with the rule that excluded the row.
38816
+ */
38817
+ function isMarkedFailed(account) {
38818
+ return account.authStatus !== void 0 && account.authStatus !== "ok";
38819
+ }
37331
38820
  //#endregion
37332
38821
  //#region src/host/claude/types.ts
37333
38822
  /**
@@ -42055,7 +43544,7 @@ function sleep$1(ms, signal) {
42055
43544
  * so that spelling is accepted too — but only for the 127/8 block, never for a
42056
43545
  * v4-mapped public address.
42057
43546
  */
42058
- function isLoopbackAddress(address) {
43547
+ function isLoopbackAddress$1(address) {
42059
43548
  if (address === void 0) return false;
42060
43549
  if (address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1") return true;
42061
43550
  const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(address);
@@ -42627,7 +44116,7 @@ const SUCCESS_PAGE = "Claude sign-in completed. You can close this window.";
42627
44116
  */
42628
44117
  function callbackHandler(request, response) {
42629
44118
  (async () => {
42630
- if (!isLoopbackAddress(request.socket.remoteAddress)) {
44119
+ if (!isLoopbackAddress$1(request.socket.remoteAddress)) {
42631
44120
  await writeHtml(response, 403, "This callback only answers on this machine.");
42632
44121
  return;
42633
44122
  }
@@ -56721,6 +58210,16 @@ const CHECKIN_PATH = "/billing/meter/daily-checkin";
56721
58210
  */
56722
58211
  const CHECKIN_TICK_MS$1 = 600 * 1e3;
56723
58212
  const CHECKIN_TIMEOUT_MS$1 = 2e4;
58213
+ /**
58214
+ * Random pause between two accounts in one pass.
58215
+ *
58216
+ * Sequential accounts already serialise their requests, but they still arrive as
58217
+ * one tight cluster from one outbound IP; a dozen-account pool doing that at the
58218
+ * same minute is the burst pattern the sibling gateway added jitter for after
58219
+ * seeing upstream rate-limit whole pools.
58220
+ */
58221
+ const CHECKIN_JITTER_MIN_MS = 2e3;
58222
+ const CHECKIN_JITTER_MAX_MS = 5e3;
56724
58223
  /** State document the scheduler keeps beside the other plugin stores. */
56725
58224
  function checkinStatePath$1() {
56726
58225
  return path.join(dshHomeDir(), "storages", "workbuddy-checkin.json");
@@ -56811,6 +58310,8 @@ var WorkBuddyCheckinService = class {
56811
58310
  fetchFn;
56812
58311
  statePath;
56813
58312
  now;
58313
+ pause;
58314
+ random;
56814
58315
  logger;
56815
58316
  state = {};
56816
58317
  loaded = false;
@@ -56827,6 +58328,10 @@ var WorkBuddyCheckinService = class {
56827
58328
  this.fetchFn = options.fetchFn ?? fetch;
56828
58329
  this.statePath = options.statePath ?? checkinStatePath$1();
56829
58330
  this.now = options.now ?? (() => Date.now());
58331
+ this.pause = options.pause ?? ((ms) => new Promise((resolve) => {
58332
+ setTimeout(resolve, ms);
58333
+ }));
58334
+ this.random = options.random ?? Math.random;
56830
58335
  this.logger = options.logger;
56831
58336
  }
56832
58337
  /**
@@ -56879,6 +58384,7 @@ var WorkBuddyCheckinService = class {
56879
58384
  this.logger?.warn(`[workbuddy-checkin] account scan failed: ${error instanceof Error ? error.message : String(error)}`);
56880
58385
  return;
56881
58386
  }
58387
+ let ranOne = false;
56882
58388
  for (const credentials of accounts) {
56883
58389
  if (isIntlDomain(credentials.domain)) continue;
56884
58390
  const id = workBuddyAccountId(credentials);
@@ -56887,7 +58393,9 @@ var WorkBuddyCheckinService = class {
56887
58393
  if (sameDay && existing.done) continue;
56888
58394
  if (!manual && sameDay && existing.attempts >= 3) continue;
56889
58395
  if (!manual && sameDay && existing.inactive === true && nowMs - (existing.inactiveAt ?? 0) < 36e5) continue;
58396
+ if (ranOne) await this.pause(CHECKIN_JITTER_MIN_MS + Math.floor(this.random() * (CHECKIN_JITTER_MAX_MS - CHECKIN_JITTER_MIN_MS)));
56890
58397
  await this.checkinOne(id, credentials, today, nowMs);
58398
+ ranOne = true;
56891
58399
  }
56892
58400
  this.lastRunAt = nowMs;
56893
58401
  await this.save();
@@ -59875,6 +61383,11 @@ function installRelayProbe(ctx, options) {
59875
61383
  * already existed when the guard installed was never reported at all. Both
59876
61384
  * cases cut the runaway and then resumed nothing, which reads to the user as a
59877
61385
  * conversation that simply stops mid-answer.
61386
+ *
61387
+ * Status: **switched off in this line by default**. Everything here still
61388
+ * works and is still tested; what changed is the wiring in `src/index.ts`,
61389
+ * whose `reasoningCollapseGuard` config now defaults to `false` instead of
61390
+ * `{}`. A deployment that wants the break back opts in with an object.
59878
61391
  */
59879
61392
  const DEFAULT_GUARD_OPTIONS = {
59880
61393
  windowChars: 4096,
@@ -60075,7 +61588,7 @@ function createBreakerState() {
60075
61588
  * instead of impersonating a human turn.
60076
61589
  */
60077
61590
  function createResumeMessage$1(text) {
60078
- return createUserMessage({
61591
+ return createUserMessage$1({
60079
61592
  content: [{
60080
61593
  type: "text",
60081
61594
  text
@@ -60379,7 +61892,7 @@ function resolveNoAnswerResumeOptions(options = {}) {
60379
61892
  }
60380
61893
  /** The message a resume carries, with this package's own provenance. */
60381
61894
  function createResumeMessage(text) {
60382
- return createUserMessage({
61895
+ return createUserMessage$1({
60383
61896
  content: [{
60384
61897
  type: "text",
60385
61898
  text
@@ -60595,6 +62108,579 @@ function releaseListener(handle) {
60595
62108
  }
60596
62109
  handle?.dispose?.();
60597
62110
  }
62111
+ /**
62112
+ * 判断是否该催促。达到阈值即触发,注入之后调用方把计数清零。
62113
+ * @param rounds - 自上次进展更新以来的连续轮次数。
62114
+ * @returns 是否达到 {@link NUDGE_THRESHOLD}。
62115
+ */
62116
+ function shouldNudge(rounds) {
62117
+ return rounds >= 8;
62118
+ }
62119
+ /**
62120
+ * 把进展列表截断到环形缓冲上限,保留最新的一批。
62121
+ * @param list - 任意长度的进展列表(不会被就地修改)。
62122
+ * @returns 截断后的新数组。
62123
+ */
62124
+ function capUpdates(list) {
62125
+ return list.length <= 50 ? [...list] : list.slice(list.length - 50);
62126
+ }
62127
+ /** 进程内自增序号,只用于拼出稳定 id;不含任何运行时依赖。 */
62128
+ let sequence = 0;
62129
+ /**
62130
+ * 生成一条进展的稳定 id。
62131
+ * @param at - 产生时刻(Unix 毫秒)。
62132
+ * @returns 形如 `pu-<时间戳>-<序号>` 的字符串。
62133
+ */
62134
+ function nextUpdateId(at) {
62135
+ sequence += 1;
62136
+ return `pu-${at}-${sequence}`;
62137
+ }
62138
+ /**
62139
+ * 创建一个空的进展更新 store。
62140
+ * @returns 新的会话维度 store。
62141
+ */
62142
+ function createProgressUpdateStore() {
62143
+ let current = {
62144
+ updates: [],
62145
+ roundsSinceUpdate: 0,
62146
+ nudges: 0
62147
+ };
62148
+ return {
62149
+ state: () => current,
62150
+ push: (update) => {
62151
+ current = {
62152
+ ...current,
62153
+ updates: capUpdates([...current.updates, update])
62154
+ };
62155
+ return current;
62156
+ },
62157
+ bumpRound: () => {
62158
+ current = {
62159
+ ...current,
62160
+ roundsSinceUpdate: current.roundsSinceUpdate + 1
62161
+ };
62162
+ return current.roundsSinceUpdate;
62163
+ },
62164
+ markUpdated: () => {
62165
+ current = {
62166
+ ...current,
62167
+ roundsSinceUpdate: 0
62168
+ };
62169
+ return current;
62170
+ },
62171
+ markNudged: () => {
62172
+ current = {
62173
+ ...current,
62174
+ roundsSinceUpdate: 0,
62175
+ nudges: current.nudges + 1
62176
+ };
62177
+ return current;
62178
+ },
62179
+ reset: () => {
62180
+ current = {
62181
+ updates: [],
62182
+ roundsSinceUpdate: 0,
62183
+ nudges: 0
62184
+ };
62185
+ return current;
62186
+ }
62187
+ };
62188
+ }
62189
+ //#endregion
62190
+ //#region src/host/progress-updates/tool.ts
62191
+ /** 注册的工具名。冻结契约的一部分,不要改动。 */
62192
+ const NOTIFY_USER_TOOL = "progress_update";
62193
+ /** 工具关闭时返回给模型的文本(canonical 输出的 render,而不是抛错)。 */
62194
+ const DISABLED_OUTPUT_TEXT = "Progress updates are disabled; the update was not displayed.";
62195
+ /** 工具描述的最终全文。 */
62196
+ const PROGRESS_UPDATE_DESCRIPTION = [
62197
+ "Report progress to the user without ending your turn.",
62198
+ "Use it during multi-step work so the user can follow along and correct course early:",
62199
+ "state your plan near the beginning of the task (what you will do, and in what order);",
62200
+ "report a meaningful conclusion the moment you have it (a key fact, the root cause, a verified assumption) rather than narrating every step;",
62201
+ "give a stage judgement when one phase ends and the next begins (where things stand, what happens next);",
62202
+ "explain what you are waiting for before a long wait such as a test run, an install, or a build;",
62203
+ "and say what blocks you — and what you will try next, or what you need from the user — when you are stuck.",
62204
+ "Each update has one short title line (plain text, no Markdown, no trailing period) with the details in the message body.",
62205
+ "Do not repeat a status that has not changed.",
62206
+ "When you run as a subagent, describe only your own subtask: finishing your subtask is not the same as finishing the overall task."
62207
+ ].join(" ");
62208
+ /**
62209
+ * 构造 progress_update 工具。
62210
+ * @param deps - 依赖:当前开关与进展接收端。
62211
+ * @returns 可注册的工具定义。
62212
+ */
62213
+ function createProgressUpdateTool(deps) {
62214
+ return defineTool({
62215
+ name: NOTIFY_USER_TOOL,
62216
+ description: PROGRESS_UPDATE_DESCRIPTION,
62217
+ parameters: {
62218
+ title: {
62219
+ type: "string",
62220
+ description: "One line, about 60 characters at most, in the user's language: plain text, no Markdown, no trailing period."
62221
+ },
62222
+ message: {
62223
+ type: "string",
62224
+ required: true,
62225
+ description: "The details, about 1000 characters at most, in light Markdown: one paragraph of prose plus a few bullets."
62226
+ }
62227
+ },
62228
+ output: {
62229
+ schema: {
62230
+ type: "object",
62231
+ additionalProperties: false,
62232
+ properties: { displayed: {
62233
+ type: "boolean",
62234
+ description: "Whether the update was actually shown to the user."
62235
+ } }
62236
+ },
62237
+ render(_args, value) {
62238
+ return [{
62239
+ type: "text",
62240
+ text: value.displayed ? "Progress update displayed." : DISABLED_OUTPUT_TEXT
62241
+ }];
62242
+ }
62243
+ },
62244
+ async execute(args, exec) {
62245
+ if (!deps.isEnabled()) return { displayed: false };
62246
+ const sink = deps.sink?.();
62247
+ if (sink === void 0) return { displayed: false };
62248
+ const at = Date.now();
62249
+ const source = deps.sourceOf?.(exec);
62250
+ const update = {
62251
+ id: nextUpdateId(at),
62252
+ message: args.message,
62253
+ at,
62254
+ ...args.title === void 0 || args.title.trim() === "" ? {} : { title: args.title.trim() },
62255
+ ...source === void 0 ? {} : { source }
62256
+ };
62257
+ return { displayed: sink.push(update) };
62258
+ }
62259
+ });
62260
+ }
62261
+ //#endregion
62262
+ //#region src/host/progress-updates/nudge.ts
62263
+ /** 注入到模型上下文里的催促正文。 */
62264
+ const NUDGE_TEXT = [
62265
+ "You have made a run of tool calls without reporting progress to the user.",
62266
+ "If you are still in the middle of multi-step work, send one progress update now with the progress_update tool:",
62267
+ "one short title line and the details in the body — what you have established so far, where things stand, and what you are doing next.",
62268
+ "Skip it when there is genuinely nothing new to report."
62269
+ ].join(" ");
62270
+ /** 催促上下文在消息来源上的 kind(供客户端与日志区分这类注入)。 */
62271
+ const NUDGE_SOURCE_KIND = "progress-updates-nudge";
62272
+ /**
62273
+ * 造一条催促上下文。
62274
+ * @param sequence - 本会话第几次催促,写进 notice 摘要便于日志辨认。
62275
+ * @returns 可直接放进 `additionalContexts` 的用户角色消息。
62276
+ */
62277
+ function createNudgeContext(sequence) {
62278
+ return createUserMessage({
62279
+ content: [{
62280
+ type: "text",
62281
+ text: NUDGE_TEXT
62282
+ }],
62283
+ source: {
62284
+ kind: NUDGE_SOURCE_KIND,
62285
+ form: "notice",
62286
+ summary: `progress update nudge #${sequence}`
62287
+ }
62288
+ });
62289
+ }
62290
+ /**
62291
+ * 找出一次工具调用所属的**计数会话**:子会话归并到最近的顶层祖先,整棵委派
62292
+ * 树共用一个计数器。
62293
+ * @param agent - 事件里的调用方 agent。
62294
+ * @param sessions - 会话查找表,缺省时退化为只认调用方自己的会话。
62295
+ * @returns 计数用的会话 id,无法判定时返回 undefined。
62296
+ */
62297
+ function counterSessionIdOf(agent, sessions) {
62298
+ const own = stringOf(agent?.session?.header?.id) ?? stringOf(agent?.id);
62299
+ if (own === void 0) return void 0;
62300
+ if (sessions === void 0) return own;
62301
+ let current = own;
62302
+ let header = agent?.session?.header;
62303
+ for (let depth = 0; depth < 16 && current !== void 0; depth += 1) {
62304
+ const resolved = header ?? sessions.get(current)?.header;
62305
+ if (resolved === void 0) return current;
62306
+ const parentId = stringOf(resolved.parentSession);
62307
+ if (parentId === void 0) return current;
62308
+ const parent = sessions.get(parentId);
62309
+ if (parent === void 0) return current;
62310
+ header = parent.header;
62311
+ current = parentId;
62312
+ }
62313
+ return current;
62314
+ }
62315
+ /**
62316
+ * 读一次工具调用所属的「步」标识,用于把同一个模型步里的多条工具结果合并成一轮。
62317
+ *
62318
+ * 拿不到 turnBoundary(没有 agent-loop,或宿主不是真实会话)时返回 undefined,
62319
+ * 调用方退化为按工具结果计数——宁可多计,也不要漏掉催促。
62320
+ * @param agent - 事件里的调用方 agent。
62321
+ * @param projections - 会话投影服务,可选。
62322
+ * @returns 该步的唯一键,或 undefined。
62323
+ */
62324
+ function stepKeyOf(agent, projections) {
62325
+ if (projections === void 0) return void 0;
62326
+ const session = agent?.session;
62327
+ if (session === void 0) return void 0;
62328
+ try {
62329
+ const seq = projections.stateOf(session, "turnBoundary")?.lastStepStartSeq;
62330
+ return typeof seq === "number" ? `step:${seq}` : void 0;
62331
+ } catch {
62332
+ return;
62333
+ }
62334
+ }
62335
+ /**
62336
+ * 读一个未知字段为字符串。
62337
+ * @param value - 候选值。
62338
+ * @returns 字符串,或任何其它类型的 undefined。
62339
+ */
62340
+ function stringOf(value) {
62341
+ return typeof value === "string" && value !== "" ? value : void 0;
62342
+ }
62343
+ //#endregion
62344
+ //#region src/host/progress-updates/prompt.ts
62345
+ /**
62346
+ * 系统提示词引导:让模型知道「多步任务里要主动汇报进展」这件事该怎么用。
62347
+ *
62348
+ * 文本用函数形式提供(而不是静态字符串):开关关闭时返回空串,装配期就地
62349
+ * 抹掉引导,不需要重启会话、也不需要重挂插件行。
62350
+ *
62351
+ * @module dsh-chatgpt-subscription/host/progress-updates/prompt
62352
+ */
62353
+ /**
62354
+ * 本模块提示词分节的名称,全局唯一(重复注册会抛错)。
62355
+ *
62356
+ * 保留原插件的 `plugin:progress-updates` 拼写:会话里已有的分节名不能因为搬家
62357
+ * 而变成第二个分节。
62358
+ */
62359
+ const PROGRESS_SECTION_NAME = "plugin:progress-updates";
62360
+ /**
62361
+ * 引导词正文。写法对齐 Kimi 的「进展更新」:先说清多步任务早期的思路,再报
62362
+ * 有意义的结论、阶段判断、长等待与阻塞;每条给一行标题,细节放正文;不重复
62363
+ * 没有变化的状态。
62364
+ */
62365
+ const PROGRESS_GUIDANCE = [
62366
+ "## 进展更新",
62367
+ "",
62368
+ "在多步任务里,你可以调用 `progress_update` 向用户主动汇报进展。它不会结束你的回合,也不消耗一次问答:报告完继续干活即可。",
62369
+ "",
62370
+ "什么时候用:",
62371
+ "- **任务早期**:先说清你打算怎么做(拆成哪几步、先动哪里),让用户在你埋头执行之前就能纠正方向。",
62372
+ "- **有意义的结论**:查到关键事实、定位到根因、验证了一个假设——报结论本身,不要报「我刚读了一个文件」。",
62373
+ "- **阶段判断**:一步做完、进入下一步之前,用一句话说明现在的状态和接下来要做什么。",
62374
+ "- **长等待之前**:跑测试、装依赖、等构建这类要花上一阵子的操作,先说清你在等什么、大概要等多久。",
62375
+ "- **被阻塞时**:缺信息、缺权限、外部依赖失败——说清卡在哪、你打算怎么绕过去,或者需要用户做什么。",
62376
+ "",
62377
+ "怎么写:",
62378
+ "- 每条给一行短标题(title,纯文本,不带 Markdown,不加结尾句号),细节放进正文(message)。",
62379
+ "- 正文用轻 Markdown:一段话说清发生了什么,必要时跟少量 bullet 列出要点。",
62380
+ "- 不要重复没有变化的状态:上一条报过的还在进行中,就不用再报一遍。",
62381
+ "- 进展更新是给用户看的,不是给日志看的:写人话,别复述工具调用参数。",
62382
+ "",
62383
+ "作为 subagent 运行时:只描述你自己的子任务进展。你的子任务完成不等于整体任务完成,不要把整体进度写成已完成。"
62384
+ ].join("\n");
62385
+ /**
62386
+ * 生成提示词分节的文本提供者。
62387
+ * @param isEnabled - 读取当前开关状态的函数(每次装配重新求值)。
62388
+ * @returns 开关打开时返回引导词,关闭时返回空串。
62389
+ */
62390
+ function progressSectionText(isEnabled) {
62391
+ return () => isEnabled() ? PROGRESS_GUIDANCE : "";
62392
+ }
62393
+ //#endregion
62394
+ //#region src/host/progress-updates/routes.ts
62395
+ /** 进展读取路由的完整路径。 */
62396
+ const PROGRESS_UPDATES_PATH = "/api/dsh-progress-updates/updates";
62397
+ /** 路由默认的 JSON 响应头。 */
62398
+ const JSON_HEADERS = {
62399
+ "content-type": "application/json; charset=utf-8",
62400
+ "referrer-policy": "no-referrer"
62401
+ };
62402
+ /**
62403
+ * 断言本次请求来自本机。
62404
+ *
62405
+ * 进展内容会包含用户在会话里做的事,属于个人数据,所以只有 loopback(桌面端
62406
+ * 自己)能读:socket 地址是权威判断,Host 头必须也是 loopback 权威(挡住 DNS
62407
+ * 重绑定),`X-Forwarded-For` 永远不信任。
62408
+ * @param request - 进来的 HTTP 请求。
62409
+ * @returns 是否放行。
62410
+ */
62411
+ function isLoopbackRequest(request) {
62412
+ if (!isLoopbackAddress(request.socket.remoteAddress)) return false;
62413
+ const host = request.headers.host;
62414
+ if (typeof host !== "string" || host === "") return false;
62415
+ let hostname;
62416
+ try {
62417
+ hostname = new URL("http://" + host).hostname.toLowerCase();
62418
+ } catch {
62419
+ return false;
62420
+ }
62421
+ if (!isLoopbackHostname(hostname)) return false;
62422
+ return request.headers["sec-fetch-site"] !== "cross-site";
62423
+ }
62424
+ /** socket 远端地址是否落在 loopback 范围(127/8、::1、IPv4-mapped)。 */
62425
+ function isLoopbackAddress(address) {
62426
+ if (address === void 0) return false;
62427
+ const normalized = address.toLowerCase();
62428
+ if (normalized === "::1") return true;
62429
+ if (normalized.startsWith("::ffff:")) return isIPv4Loopback(normalized.slice(7));
62430
+ return isIPv4Loopback(normalized);
62431
+ }
62432
+ /** IPv4 127/8 判定(四段十进制,首段为 127)。 */
62433
+ function isIPv4Loopback(v4) {
62434
+ const parts = v4.split(".");
62435
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
62436
+ }
62437
+ /** 主机名是否指向 loopback 权威(localhost、[::1]、127/8)。 */
62438
+ function isLoopbackHostname(hostname) {
62439
+ if (hostname === "localhost" || hostname === "[::1]") return true;
62440
+ return isIPv4Loopback(hostname);
62441
+ }
62442
+ /**
62443
+ * 写一个 JSON 响应。
62444
+ * @param res - 响应对象。
62445
+ * @param status - HTTP 状态码。
62446
+ * @param body - 要序列化的值。
62447
+ * @param headers - 追加或覆盖的头。
62448
+ */
62449
+ function writeJson(res, status, body, headers = {}) {
62450
+ res.writeHead(status, {
62451
+ ...JSON_HEADERS,
62452
+ ...headers
62453
+ });
62454
+ res.end(JSON.stringify(body));
62455
+ }
62456
+ /**
62457
+ * 进展读取路由:`GET /api/dsh-progress-updates/updates?session=<id>`。
62458
+ *
62459
+ * 返回 `{ updates: ProgressUpdate[] }`。响应带 no-store:这个列表随 agent 干活
62460
+ * 实时变化,缓存住就等于展示过期进展。
62461
+ * @param reader - 进展读取面。
62462
+ * @returns 可注册的 route。
62463
+ */
62464
+ function makeProgressUpdatesRoute(reader) {
62465
+ return {
62466
+ kind: "exact",
62467
+ path: PROGRESS_UPDATES_PATH,
62468
+ handler: (req, res) => {
62469
+ if (!isLoopbackRequest(req)) {
62470
+ writeJson(res, 403, {
62471
+ ok: false,
62472
+ error: "forbidden: loopback-only"
62473
+ });
62474
+ return;
62475
+ }
62476
+ const session = new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("session") ?? "";
62477
+ writeJson(res, 200, { updates: reader.updatesOf(session) }, { "cache-control": "no-store" });
62478
+ }
62479
+ };
62480
+ }
62481
+ //#endregion
62482
+ //#region src/host/progress-updates/index.ts
62483
+ /**
62484
+ * 读取一个配置字段,沿着 volatile 活引用取当前值。
62485
+ * @param field - 字段本身。
62486
+ * @returns 当前值,或字段缺失时的 undefined。
62487
+ */
62488
+ function readProgressConfigField(field) {
62489
+ if (field === void 0 || field === null) return void 0;
62490
+ if (typeof field === "object" && typeof field.get === "function") return field.get();
62491
+ return field;
62492
+ }
62493
+ /**
62494
+ * 解析一次读取的有效设置:每次调用都重读活引用,因此设置保存后立刻生效。
62495
+ * @param config - 主入口收到的插件配置(活引用或普通值)。
62496
+ * @returns 生效的开关与催促阈值。
62497
+ */
62498
+ function resolveProgressUpdatesSettings(config) {
62499
+ const enabled = readProgressConfigField(config?.progressUpdates);
62500
+ const threshold = readProgressConfigField(config?.progressUpdatesNudgeThreshold);
62501
+ return {
62502
+ enabled: enabled === true,
62503
+ nudgeThreshold: typeof threshold === "number" && Number.isSafeInteger(threshold) && threshold > 0 ? threshold : 8
62504
+ };
62505
+ }
62506
+ /**
62507
+ * 已挂载的模块登记表,走一个 global symbol:同一个包的两份模块实例(npm 拷贝与
62508
+ * 仓库 link)仍然共享一次判定,而重复注册同名工具或同名提示词分节会让宿主启动
62509
+ * 失败。
62510
+ */
62511
+ const MOUNTED = Symbol.for("dsh-chatgpt-subscription.mounted-modules");
62512
+ /** 本模块在登记表里的名字。 */
62513
+ const MODULE_ID = "progress-updates";
62514
+ /**
62515
+ * 把一条催促挂到一个 post-execute 决策上,同时保住该决策原有的附加上下文。
62516
+ * @param decision - `next()` 交回来的决策。
62517
+ * @param context - 要追加的催促上下文。
62518
+ * @returns 追加后的决策(形状不变)。
62519
+ */
62520
+ function withNudge(decision, context) {
62521
+ const additionalContexts = [...decision.additionalContexts ?? [], context];
62522
+ if (decision.kind === "block") return {
62523
+ kind: "block",
62524
+ feedback: decision.feedback,
62525
+ additionalContexts
62526
+ };
62527
+ if (decision.value !== void 0) return {
62528
+ kind: "accept",
62529
+ value: decision.value,
62530
+ additionalContexts
62531
+ };
62532
+ if (decision.content !== void 0) return {
62533
+ kind: "accept",
62534
+ content: decision.content,
62535
+ additionalContexts
62536
+ };
62537
+ return {
62538
+ kind: "accept",
62539
+ additionalContexts
62540
+ };
62541
+ }
62542
+ /**
62543
+ * 单实例守卫:本模块已挂载时放弃本次安装。
62544
+ * @param ctx - 宿主上下文。
62545
+ * @returns 释放函数(交出挂载权);已经挂载过时返回 undefined。
62546
+ */
62547
+ function claimMount(ctx) {
62548
+ const registry = globalThis;
62549
+ const existing = registry[MOUNTED];
62550
+ const mounted = existing instanceof Set ? existing : /* @__PURE__ */ new Set();
62551
+ if (existing !== mounted) registry[MOUNTED] = mounted;
62552
+ if (mounted.has(MODULE_ID)) return void 0;
62553
+ mounted.add(MODULE_ID);
62554
+ const release = () => {
62555
+ mounted.delete(MODULE_ID);
62556
+ };
62557
+ if (typeof ctx.effect === "function") ctx.effect(() => release, "dsh-chatgpt-subscription: progress updates mount");
62558
+ return release;
62559
+ }
62560
+ /**
62561
+ * 挂载「进展更新」的宿主半边。
62562
+ * @param ctx - 宿主插件上下文。
62563
+ * @param options - 挂载选项(设置读取器)。
62564
+ * @returns 卸载函数;重复挂载时返回一个空操作。
62565
+ */
62566
+ function installProgressUpdates(ctx, options) {
62567
+ const release = claimMount(ctx);
62568
+ if (release === void 0) return () => void 0;
62569
+ /** 每次调用重新读服务:宿主树可能在本模块挂载之后才 settle,读一次存下来会漏。 */
62570
+ const serviceOf = (name) => {
62571
+ const read = ctx.get;
62572
+ return typeof read === "function" ? read.call(ctx, name) : void 0;
62573
+ };
62574
+ /** 登记一个副作用;上下文没有 effect 时立即执行(测试用假上下文)。 */
62575
+ const track = (execute, label) => {
62576
+ if (typeof ctx.effect === "function") ctx.effect(execute, label);
62577
+ else execute();
62578
+ };
62579
+ /** 会话查找表:够把子会话回溯到顶层祖先即可。 */
62580
+ const sessionsOf = () => serviceOf("sessions");
62581
+ /** 会话投影服务:只用来读 turnBoundary,把同一个模型步里的多条工具结果合并成一轮。 */
62582
+ const projectionsOf = () => serviceOf("sessionProjections");
62583
+ /** 会话维度状态;键是计数会话 id(子会话已归并到顶层祖先)。 */
62584
+ const stores = /* @__PURE__ */ new Map();
62585
+ /** 已经计过数的「步」:同一个模型步里的后续工具结果不再重复计数。 */
62586
+ const countedSteps = /* @__PURE__ */ new Map();
62587
+ /**
62588
+ * 取一个计数会话的 store,按需创建。
62589
+ * @param key - 计数会话 id。
62590
+ * @returns 该会话的 store。
62591
+ */
62592
+ const storeOf = (key) => {
62593
+ let store = stores.get(key);
62594
+ if (store === void 0) {
62595
+ store = createProgressUpdateStore();
62596
+ stores.set(key, store);
62597
+ }
62598
+ return store;
62599
+ };
62600
+ /**
62601
+ * 一次工具调用归属的计数会话 id。
62602
+ * @param exec - 工具执行上下文(或任意带 agent 的对象)。
62603
+ * @returns 计数会话 id;判定不出来时归入 `session` 这一桶。
62604
+ */
62605
+ const counterKeyOf = (exec) => counterSessionIdOf(exec?.agent, sessionsOf()) ?? "session";
62606
+ /**
62607
+ * 浏览器半边的读取面:给定会话 id 就交出那个会话的进展快照。
62608
+ *
62609
+ * 请求不带 session(或带一个本进程没见过的 id)时,回退到那个"合成会话桶":
62610
+ * 这是单会话部署里的常态,也是插件第一次被调用、祖先血缘还没建立时的落点。
62611
+ */
62612
+ const reader = { updatesOf: (sessionId) => {
62613
+ const direct = stores.get(sessionId);
62614
+ if (direct !== void 0) return direct.state().updates;
62615
+ if (sessionId !== "") {
62616
+ const merged = counterSessionIdOf({ id: sessionId }, sessionsOf());
62617
+ const parent = merged === void 0 ? void 0 : stores.get(merged);
62618
+ if (parent !== void 0) return parent.state().updates;
62619
+ return [];
62620
+ }
62621
+ const fallback = stores.get("session");
62622
+ return fallback === void 0 ? [] : fallback.state().updates;
62623
+ } };
62624
+ track(() => {
62625
+ const tools = serviceOf("tools");
62626
+ if (tools === void 0) {
62627
+ serviceOf("logger")?.warn("[dsh-chatgpt-subscription] progress updates tool skipped: no tools service");
62628
+ return () => void 0;
62629
+ }
62630
+ const tool = createProgressUpdateTool({
62631
+ isEnabled: () => options.settings().enabled,
62632
+ sink: () => ({ push: (update) => {
62633
+ if (!options.settings().enabled) return false;
62634
+ storeOf(update.source ?? "session").push(update);
62635
+ return true;
62636
+ } }),
62637
+ sourceOf: (exec) => counterKeyOf(exec)
62638
+ });
62639
+ return tools.register(tool);
62640
+ }, "dsh-chatgpt-subscription: progress update tool");
62641
+ track(() => {
62642
+ const systemPrompt = serviceOf("systemPrompt");
62643
+ if (systemPrompt === void 0) return () => void 0;
62644
+ return systemPrompt.section({
62645
+ name: PROGRESS_SECTION_NAME,
62646
+ order: 220,
62647
+ text: progressSectionText(() => options.settings().enabled)
62648
+ });
62649
+ }, "dsh-chatgpt-subscription: progress update prompt section");
62650
+ track(() => {
62651
+ const webServer = serviceOf("webServer");
62652
+ if (webServer === void 0) return () => void 0;
62653
+ return webServer.register(makeProgressUpdatesRoute(reader));
62654
+ }, "dsh-chatgpt-subscription: progress update route");
62655
+ const disposeListener = ctx.on?.("tools/post-execute", (async (exec, _result, next) => {
62656
+ const decision = await next();
62657
+ const current = options.settings();
62658
+ if (!current.enabled) return decision;
62659
+ const key = counterKeyOf(exec);
62660
+ const store = storeOf(key);
62661
+ if (exec.name === "progress_update") {
62662
+ store.markUpdated();
62663
+ const reportedStep = stepKeyOf(exec.agent, projectionsOf());
62664
+ if (reportedStep !== void 0) countedSteps.set(key, reportedStep);
62665
+ return decision;
62666
+ }
62667
+ const step = stepKeyOf(exec.agent, projectionsOf());
62668
+ if (step !== void 0) {
62669
+ if (countedSteps.get(key) === step) return decision;
62670
+ countedSteps.set(key, step);
62671
+ }
62672
+ const rounds = store.bumpRound();
62673
+ if (!(current.nudgeThreshold === 8 ? shouldNudge(rounds) : rounds >= current.nudgeThreshold)) return decision;
62674
+ const nudges = store.markNudged().nudges;
62675
+ return withNudge(decision, createNudgeContext(nudges));
62676
+ }));
62677
+ return () => {
62678
+ disposeListener?.();
62679
+ stores.clear();
62680
+ countedSteps.clear();
62681
+ release();
62682
+ };
62683
+ }
60598
62684
  //#endregion
60599
62685
  //#region src/index.ts
60600
62686
  const Config = z.object({
@@ -60610,9 +62696,11 @@ const Config = z.object({
60610
62696
  subagentModelTools: z.array(z.string()).default([]),
60611
62697
  subagentModelInheritTools: z.array(z.string()).default(DEFAULT_SUBAGENT_INHERIT_TOOLS),
60612
62698
  subagentModelScope: z.union([z.const("session"), z.const("preference")]).default("session"),
60613
- reasoningCollapseGuard: z.union([z.object({}), z.const(false)]).default({}),
62699
+ reasoningCollapseGuard: z.union([z.object({}), z.const(false)]).default(false),
60614
62700
  noAnswerResume: z.union([z.object({}), z.const(false)]).default({}),
60615
- codexStreamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).step(1).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS)
62701
+ codexStreamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).step(1).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
62702
+ progressUpdates: z.boolean().default(false).volatile().description("启用进展更新:允许 agent 用 progress_update 工具主动汇报多步任务的进展,并在侧栏展示。"),
62703
+ progressUpdatesNudgeThreshold: z.number().step(1).min(1).max(50).default(8).volatile().description("催促阈值:连续这么多轮带工具调用却没有进展更新时,提醒 agent 发一条进展。")
60616
62704
  });
60617
62705
  const inject = [
60618
62706
  "webServer",
@@ -60645,6 +62733,10 @@ function apply(ctx, pluginConfig = {}) {
60645
62733
  const minimaxCodePreferences = registerMinimaxCodePreferenceStore(ctx.settings, minimaxCodeModelSettings);
60646
62734
  const minimaxCodeAccountPool = new MinimaxCodeAccountPool({ store: minimaxCodeStore });
60647
62735
  const workBuddyStore = new FileCredentialStore$4();
62736
+ const workBuddyUsageLedger = new WorkBuddyUsageLedger();
62737
+ workBuddyUsageLedger.load().catch(() => void 0);
62738
+ const workBuddyAtRestKey = new WorkBuddyAtRestKeyStore();
62739
+ workBuddyAtRestKey.load().catch(() => null);
60648
62740
  const workBuddyModelSettings = new FileModelSettingsStore$4();
60649
62741
  const workBuddyPreferences = registerWorkBuddyPreferenceStore(ctx.settings, workBuddyModelSettings);
60650
62742
  const workBuddyAccountPool = new WorkBuddyAccountPool({
@@ -60852,7 +62944,8 @@ function apply(ctx, pluginConfig = {}) {
60852
62944
  adapter: new WorkBuddyAdapter(workBuddyStore, workBuddyModelSettings, workBuddyPreferences, {
60853
62945
  fetchFn: modelFetch.workbuddy,
60854
62946
  attachments: ctx.attachments,
60855
- accountPool: workBuddyAccountPool
62947
+ accountPool: workBuddyAccountPool,
62948
+ usageLedger: workBuddyUsageLedger
60856
62949
  })
60857
62950
  });
60858
62951
  const workBuddyCheckin = new WorkBuddyCheckinService(workBuddyStore, {
@@ -60873,7 +62966,8 @@ function apply(ctx, pluginConfig = {}) {
60873
62966
  serving: () => workBuddyClaim.serving(),
60874
62967
  conflict: () => workBuddyClaim.conflict(),
60875
62968
  accountPool: workBuddyAccountPool,
60876
- checkin: workBuddyCheckin
62969
+ checkin: workBuddyCheckin,
62970
+ atRestKey: workBuddyAtRestKey
60877
62971
  });
60878
62972
  const claudeClaim = claimProviderRoute(ctx, {
60879
62973
  providerId: PROVIDER_ID,
@@ -60933,20 +63027,23 @@ function apply(ctx, pluginConfig = {}) {
60933
63027
  })).catch(() => []));
60934
63028
  const searchSwitcher = new SearchProviderSwitcher(ctx.loader);
60935
63029
  let lastFetchDiagnostic;
60936
- const applyWebProviders = (current = preferences.status()) => {
60937
- const pluginFetch = proxyManager.resolveActiveProxyUrl() !== null;
60938
- searchSwitcher.select(current.searchProvider, {
60939
- pluginFetch,
60940
- fetchProvider: fetchConfiguration.fetchProvider
60941
- }).then(() => {
60942
- const status = searchSwitcher.status();
60943
- if (status.state === "missing" || status.state === "failed") return;
60944
- const reason = fetchConfiguration.fetchProvider !== "auto" ? "explicit configuration" : current.searchProvider === "codex" ? "Codex search selected" : pluginFetch ? "active proxy detected" : "no proxy or Codex search";
60945
- const diagnostic = `${status.configuredFetchProvider ?? "DSH default"} (mode=${fetchConfiguration.fetchProvider}; ${reason}; plugin limits=${fetchConfiguration.fetchMaxBodyChars} chars/${fetchConfiguration.fetchMaxResponseBytes} bytes)`;
60946
- if (diagnostic !== lastFetchDiagnostic) {
60947
- lastFetchDiagnostic = diagnostic;
60948
- ctx.logger.info(`[dsh-chatgpt-subscription] Web fetch provider configured: ${diagnostic}`);
60949
- }
63030
+ const applyWebProviders = () => {
63031
+ preferences.hydrate().then(() => {
63032
+ const current = preferences.status();
63033
+ const pluginFetch = proxyManager.resolveActiveProxyUrl() !== null;
63034
+ return searchSwitcher.select(current.searchProvider, {
63035
+ pluginFetch,
63036
+ fetchProvider: fetchConfiguration.fetchProvider
63037
+ }).then(() => {
63038
+ const status = searchSwitcher.status();
63039
+ if (status.state === "missing" || status.state === "failed") return;
63040
+ const reason = fetchConfiguration.fetchProvider !== "auto" ? "explicit configuration" : current.searchProvider === "codex" ? "Codex search selected" : pluginFetch ? "active proxy detected" : "no proxy or Codex search";
63041
+ const diagnostic = `${status.configuredFetchProvider ?? "DSH default"} (mode=${fetchConfiguration.fetchProvider}; ${reason}; plugin limits=${fetchConfiguration.fetchMaxBodyChars} chars/${fetchConfiguration.fetchMaxResponseBytes} bytes)`;
63042
+ if (diagnostic !== lastFetchDiagnostic) {
63043
+ lastFetchDiagnostic = diagnostic;
63044
+ ctx.logger.info(`[dsh-chatgpt-subscription] Web fetch provider configured: ${diagnostic}`);
63045
+ }
63046
+ });
60950
63047
  }).catch((error) => {
60951
63048
  ctx.logger.warn(`[dsh-chatgpt-subscription] Web provider selection could not be applied: ${error instanceof Error ? error.message : String(error)}`);
60952
63049
  });
@@ -61154,6 +63251,7 @@ function apply(ctx, pluginConfig = {}) {
61154
63251
  isEnabled: () => antigravityPreferences.status().enabled !== false
61155
63252
  }));
61156
63253
  const disposeVideoTool = ctx.tools.register(createKimiVideoTool(ctx, { fetchFn: proxyFetch }));
63254
+ const disposeProgressUpdates = installProgressUpdates(ctx, { settings: () => resolveProgressUpdatesSettings(pluginConfig) });
61157
63255
  ctx.inject(["web"], (ctx) => {
61158
63256
  ctx.web.registerSearchProvider(createCodexSearchProvider(oauth, { fetchFn: proxyFetch }));
61159
63257
  ctx.web.registerFetchProvider(createCodexFetchProvider({
@@ -61163,19 +63261,22 @@ function apply(ctx, pluginConfig = {}) {
61163
63261
  }));
61164
63262
  setTimeout(applyWebProviders, 0);
61165
63263
  });
61166
- const disposePreferenceWatch = preferences.watch((next) => applyWebProviders(next));
63264
+ const disposePreferenceWatch = preferences.watch(() => applyWebProviders());
61167
63265
  const disposeReadyWatch = ctx.get("appReady")?.onReady(() => applyWebProviders());
61168
63266
  const disposeProxyWatch = proxyManager.onSystemProxyDetected(() => {
61169
63267
  applyWebProviders();
61170
63268
  });
63269
+ const disposeWebProviderReconciler = startWebProviderReconciler({ reconcile: () => applyWebProviders() });
61171
63270
  return () => {
61172
63271
  searchSwitcher.dispose();
61173
63272
  disposeReadyWatch?.();
61174
63273
  disposeProxyWatch();
61175
63274
  disposePreferenceWatch();
63275
+ disposeWebProviderReconciler();
61176
63276
  disposeImageTool();
61177
63277
  disposeAntigravityImageTool();
61178
63278
  disposeVideoTool();
63279
+ disposeProgressUpdates();
61179
63280
  disposeAdapter();
61180
63281
  disposeRoutes();
61181
63282
  disposeHubOverview();