dsh-connect-workbuddy 5.7.0 → 5.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1883,6 +1883,14 @@ const CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
1883
1883
  * `/v3/config` as {@link PRODUCT_UA}.
1884
1884
  */
1885
1885
  const PRODUCT_UA = "WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0";
1886
+ /**
1887
+ * The app version inside {@link PRODUCT_UA}, for headers that need it alone.
1888
+ *
1889
+ * Derived from the UA instead of repeated as a literal: the `X-IDE-Version`
1890
+ * header (issue #132) must agree with what the UA claims, and two independent
1891
+ * literals is exactly how those two drift apart at the next version bump.
1892
+ */
1893
+ const APP_VERSION = PRODUCT_UA.match(/WorkBuddy\/([^\s]+)/u)?.[1] ?? "5.8.3";
1886
1894
  const JSON_TIMEOUT_MS = 3e4;
1887
1895
  const ERROR_BODY_LIMIT = 4096;
1888
1896
  /**
@@ -1964,11 +1972,148 @@ const CREDITS_EXHAUSTED_CODE = "\"code\":14018";
1964
1972
  /** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
1965
1973
  const SESSION_DEAD_MARKERS = ["Offline user session not found", "12153"];
1966
1974
  /**
1975
+ * The business code a failure envelope states, or `undefined` when it names none.
1976
+ *
1977
+ * WHY PARSE RATHER THAN SEARCH THE TEXT. The five marker constants below this
1978
+ * file used to look for the quoted-key form (`'"code":11140'`) with `includes`,
1979
+ * which is exact only for ONE serialization: measured behaviour of the real
1980
+ * gateway is compact JSON, so it worked. It is nonetheless a spelling test
1981
+ * dressed up as a code test, and two spellings that a proxy, a re-serializer or
1982
+ * a nested envelope can produce fell through it — `{"code": 11140}` (a space) and
1983
+ * `"code":"11140"` (a string). The consequence of a MISS is not neutral: with no
1984
+ * policy marker and no channel marker, the body classifies as `client`, and
1985
+ * `outcomeOfFailure(403, …)` then answers `credential-rejected`, which marks a
1986
+ * perfectly healthy account as SIGNED OUT and benches it (issue #127 review).
1987
+ *
1988
+ * Reading the field is exact for all three spellings and for the nested
1989
+ * `{"error":{"data":{"code":…}}}` shape this file already documents at the
1990
+ * credit markers. `JSON.parse` also decodes `\uXXXX` escapes, which raw-text
1991
+ * matching cannot see (see {@link isAccountRestrictedBody}).
1992
+ *
1993
+ * The body arrives TRUNCATED to {@link ERROR_BODY_LIMIT}, so a parse failure is
1994
+ * normal for a large HTML error page and falls back to the compact/spaced text
1995
+ * forms rather than reporting no code. A request id is hexadecimal and can
1996
+ * contain `11140` by coincidence, which is why the fallback anchors on the
1997
+ * quoted key and a digit boundary rather than the bare digits.
1998
+ */
1999
+ function businessCodeOf(body) {
2000
+ let parsed;
2001
+ try {
2002
+ parsed = JSON.parse(body);
2003
+ } catch {
2004
+ const match = /"code"\s*:\s*"?(\d{4,6}(?!\d))/u.exec(body);
2005
+ return match?.[1] === void 0 ? void 0 : Number(match[1]);
2006
+ }
2007
+ if (typeof parsed !== "object" || parsed === null) return void 0;
2008
+ const raw = parsed;
2009
+ const direct = asBusinessCode(raw["code"]);
2010
+ if (direct !== void 0) return direct;
2011
+ const error = raw["error"];
2012
+ if (typeof error === "object" && error !== null) {
2013
+ const data = error["data"];
2014
+ if (typeof data === "object" && data !== null) {
2015
+ const nested = asBusinessCode(data["code"]);
2016
+ if (nested !== void 0) return nested;
2017
+ }
2018
+ }
2019
+ const data = raw["data"];
2020
+ if (typeof data === "object" && data !== null) return asBusinessCode(data["code"]);
2021
+ }
2022
+ /** One code field, accepting the number and the string spelling alike. */
2023
+ function asBusinessCode(value) {
2024
+ if (typeof value === "number" && Number.isInteger(value)) return value;
2025
+ if (typeof value === "string" && /^\d{4,6}$/u.test(value.trim())) return Number(value.trim());
2026
+ }
2027
+ /** The `11140` content-policy family, whatever the envelope's JSON spelling is. */
2028
+ const CONTENT_POLICY_CODE = 11140;
2029
+ /**
1967
2030
  * Content-policy refusal markers from the chat gateway. The quoted-key form
1968
2031
  * avoids substring hits inside unrelated values (request ids are hex and can
1969
2032
  * contain `11140` by coincidence).
1970
- */
1971
- const POLICY_REJECT_MARKERS = ["request illegal", "\"code\":11140"];
2033
+ *
2034
+ * `request illegal` is deliberately absent: the live `11128` channel-gate body
2035
+ * uses the SAME `msg`, so this marker used to run first and classify that gate
2036
+ * as `policy_reject`. The consequence was severe — the channel rescue ladder
2037
+ * never ran, the reply changed 400 to 403, and users were told to switch
2038
+ * accounts for a request-side refusal (issue #127).
2039
+ */
2040
+ function isContentPolicyBody(body) {
2041
+ return businessCodeOf(body) === CONTENT_POLICY_CODE;
2042
+ }
2043
+ /**
2044
+ * Whether a failure body is the account-restriction member of the `11140`
2045
+ * family (issue #127).
2046
+ *
2047
+ * TWO HALVES MUST BOTH HOLD — the business code `"code":11140` AND one of the
2048
+ * restriction spellings. This is the same rule this file already applies to the
2049
+ * `11128` family after issue #127: a bare sentence never classifies anything,
2050
+ * because it is text that can appear anywhere.
2051
+ *
2052
+ * WHY THE CODE HALF IS NOT OPTIONAL HERE. The verdict is the heaviest in the
2053
+ * classifier: `account_restricted` is failover-worthy AND benches the account
2054
+ * with NO cooldown — nothing but a re-authorisation returns it. Measured before
2055
+ * the code was required (see `classifyUpstreamError`), a body that merely
2056
+ * MENTIONS the phrase classified as `account_restricted` whatever else it was:
2057
+ * a 402 credit exhaustion, a 401 dead session, and even a 500 whose generic
2058
+ * `retry later` text happened to contain the words. Each of those would have
2059
+ * taken an account permanently out of the pool for a condition that was about
2060
+ * the wallet, the token, or the platform. The code half is what keeps a heavy,
2061
+ * non-self-healing verdict pinned to the one business code that states it.
2062
+ *
2063
+ * Both measured `displayMsg` spellings are recognised (the Chinese sentence and
2064
+ * its English translation). A `11140` body carrying neither stays
2065
+ * `policy_reject`: inventing an account verdict from the shared code alone would
2066
+ * bench an account over a request-side refusal — the mirror-image error, which
2067
+ * the code half must not be allowed to fix by loosening the phrase half.
2068
+ */
2069
+ const ACCOUNT_RESTRICTED_MARKERS = ["账号存在异常行为", "restricted due to unusual activity"];
2070
+ /**
2071
+ * Whether a failure body is the account-restriction member of the `11140` family.
2072
+ *
2073
+ * Three separations make this decision safe, and all three were earned by the
2074
+ * issue #127 review:
2075
+ *
2076
+ * 1. **Code-gated.** Only a `11140` envelope can reach the phrase test
2077
+ * ({@link isContentPolicyBody}). The phrase alone is not evidence about the
2078
+ * account: a risk-control throttle, a credit notice or an HTML edge page can
2079
+ * carry the same sentence, and this verdict is the only IRREVERSIBLE one in
2080
+ * the classifier — it benches the account with no cooldown.
2081
+ * 2. **Decoded text.** The match runs on `displayMsg` / `displayTips` / `msg`
2082
+ * after `JSON.parse`, not on the raw bytes. An upstream or proxy that
2083
+ * serializes non-ASCII as `\uXXXX` (Python's `json.dumps` default) would
2084
+ * otherwise never match the Chinese sentence, and a restriction would silently
2085
+ * degrade to a content refusal. Matching the FIELD rather than the whole body
2086
+ * also stops the phrase in an unrelated field from deciding the class.
2087
+ * 3. **Whitespace-normalized.** A line break inside the sentence survives as
2088
+ * `\n` in raw JSON and as real whitespace once parsed; collapsing runs of
2089
+ * whitespace means both spellings match.
2090
+ */
2091
+ function isAccountRestrictedBody(body) {
2092
+ if (!isContentPolicyBody(body)) return false;
2093
+ const haystack = accountVerdictText(body);
2094
+ if (haystack === "") return false;
2095
+ return ACCOUNT_RESTRICTED_MARKERS.some((marker) => haystack.includes(normalizeVerdictText(marker)));
2096
+ }
2097
+ /** The sentences an account verdict can be stated in: display fields, then `msg`. */
2098
+ function accountVerdictText(body) {
2099
+ let parsed;
2100
+ try {
2101
+ parsed = JSON.parse(body);
2102
+ } catch {
2103
+ return normalizeVerdictText(body);
2104
+ }
2105
+ if (typeof parsed !== "object" || parsed === null) return "";
2106
+ const raw = parsed;
2107
+ return normalizeVerdictText([
2108
+ localizedText(raw["displayMsg"]),
2109
+ localizedText(raw["displayTips"]),
2110
+ typeof raw["msg"] === "string" ? raw["msg"] : void 0
2111
+ ].filter((value) => typeof value === "string").join(" "));
2112
+ }
2113
+ /** NFKC, zero-width stripped, whitespace runs collapsed, lower-cased. */
2114
+ function normalizeVerdictText(text) {
2115
+ return text.normalize("NFKC").replace(/[\u200b-\u200d\ufeff]/gu, "").replace(/\s+/gu, " ").trim().toLowerCase();
2116
+ }
1972
2117
  /**
1973
2118
  * Business code of the MODEL-scoped throttle (`14003`, "too many requests").
1974
2119
  *
@@ -2252,7 +2397,8 @@ function classifyUpstreamError(status, body) {
2252
2397
  for (const marker of HARD_CREDIT_MARKERS) if (lower.includes(marker.toLowerCase()) || body.includes(marker)) return "hard_credit";
2253
2398
  if (body.includes(CREDITS_EXHAUSTED_CODE)) return "hard_credit";
2254
2399
  for (const marker of SESSION_DEAD_MARKERS) if (body.includes(marker)) return "session_dead";
2255
- for (const marker of POLICY_REJECT_MARKERS) if (body.includes(marker)) return "policy_reject";
2400
+ if (isAccountRestrictedBody(body)) return "account_restricted";
2401
+ if (isContentPolicyBody(body)) return "policy_reject";
2256
2402
  if (isChannelRejectBody(body)) return "channel_reject";
2257
2403
  if (isModelBusyBody(body)) return "model_busy";
2258
2404
  if (parseQueueAnswer(body) !== void 0) return "model_queue";
@@ -2426,8 +2572,8 @@ function commonHeaders(credential) {
2426
2572
  *
2427
2573
  * VALUES. The gateway does not verify where these ids come from — any well-formed
2428
2574
  * UUID passes — so nothing here is a secret and nothing is persisted. They are
2429
- * still freshly generated per request, matching the official client's
2430
- * `randomUUID()` per call: reusing one value across requests would forge a
2575
+ * still freshly generated per request, as the official client generates
2576
+ * its own per call: reusing one value across requests would forge a
2431
2577
  * conversation identity the plugin does not actually have, and the first thing
2432
2578
  * that would break is the upstream's own correlation of a session it is being
2433
2579
  * told about twice under one id.
@@ -2437,17 +2583,77 @@ function commonHeaders(credential) {
2437
2583
  * side is exactly how the 3.1.0 defect became international-only and invisible
2438
2584
  * from the CN side. One code path, both regions.
2439
2585
  *
2440
- * @returns the three headers, every value distinct.
2586
+ * @returns the three headers (four when the usage identity is on, which adds
2587
+ * `X-Request-ID`), every value distinct.
2441
2588
  */
2442
- function conversationHeaders() {
2589
+ function conversationHeaders(shape, identity = {}) {
2590
+ const officialIds = shape === "chat" && identity.usageIdentity === true;
2591
+ const id = officialIds ? uuidv7 : randomUUID;
2592
+ const messageId = id();
2593
+ return {
2594
+ "X-Conversation-ID": id(),
2595
+ "X-Conversation-Request-ID": id(),
2596
+ "X-Conversation-Message-ID": messageId,
2597
+ ...officialIds ? { "X-Request-ID": messageId } : {}
2598
+ };
2599
+ }
2600
+ /**
2601
+ * A 32-character lowercase UUIDv7, as the official client's request ids appear.
2602
+ *
2603
+ * WHY THE FORMAT MATTERS AT ALL (issue #132). The gateway echoes
2604
+ * `X-Conversation-Message-ID` back as the SSE `id`, and the WorkBuddy usage page
2605
+ * shows that value as the request id. Official traffic therefore shows
2606
+ * `01a169…`-style 32-hex ids, while this plugin's `randomUUID()` produced
2607
+ * hyphenated v4 ids — so the same account's usage page read as two different
2608
+ * clients, and a reporter comparing them concluded the plugin was not the
2609
+ * official client. Nothing upstream REJECTS a v4 id — the hyphenated v4 this
2610
+ * plugin has always sent is what answers 200 today — so this is a
2611
+ * display-fidelity fix, not a gate.
2612
+ *
2613
+ * Only the FORMAT is aligned, never the value's meaning: the timestamp prefix
2614
+ * is the moment this request was generated, which is exactly what a v7 id is
2615
+ * for. It is NOT a claim that these ids correlate with an official session —
2616
+ * the plugin still generates a fresh identity per request (see
2617
+ * {@link conversationHeaders}).
2618
+ */
2619
+ function uuidv7(nowMs = Date.now()) {
2620
+ const bytes = randomBytes(16);
2621
+ for (let index = 0; index < 6; index++) bytes[index] = Math.floor(nowMs / 2 ** (8 * (5 - index))) & 255;
2622
+ bytes[6] = bytes[6] & 15 | 112;
2623
+ bytes[8] = bytes[8] & 63 | 128;
2624
+ return bytes.toString("hex");
2625
+ }
2626
+ /**
2627
+ * The IDE identity headers the official desktop client sends on chat (issue #132).
2628
+ *
2629
+ * WHY. The gateway records the usage page's 使用端 (client) column from
2630
+ * `X-IDE-Name`, and it is EMPTY when the header is absent. The plugin has always
2631
+ * spoken the CLI channel — {@link CLIENT_UA}, which is correct for the chat
2632
+ * contract — but it sent no `X-IDE-*` at all, so every request this plugin made
2633
+ * appeared on the user's own usage page as an unidentified client while the
2634
+ * official app's requests read `WorkBuddy`. That is a reporting mismatch, not a
2635
+ * gate: the reporter measured that adding the header changes the usage column
2636
+ * and nothing else (HTTP 200, model unaffected).
2637
+ *
2638
+ * `X-IDE-Type` stays `CLI` on purpose. Claiming the desktop app's type as well
2639
+ * would be a bigger identity claim than the one this fix needs, and the mixed
2640
+ * CLI + WorkBuddy shape is what the reporter measured. The version comes from
2641
+ * {@link PRODUCT_UA} rather than a second hardcoded number, so the two cannot
2642
+ * drift apart when the app version is bumped.
2643
+ *
2644
+ * Sent only when {@link WorkBuddyWireIdentity.usageIdentity} is on — see that
2645
+ * field for the evidence tier behind the default.
2646
+ */
2647
+ function ideHeaders(identity) {
2648
+ if (identity.usageIdentity !== true) return {};
2443
2649
  return {
2444
- "X-Conversation-ID": randomUUID(),
2445
- "X-Conversation-Request-ID": randomUUID(),
2446
- "X-Conversation-Message-ID": randomUUID()
2650
+ "X-IDE-Type": "CLI",
2651
+ "X-IDE-Name": "WorkBuddy",
2652
+ "X-IDE-Version": APP_VERSION
2447
2653
  };
2448
2654
  }
2449
2655
  /** Chat request headers, including the X-No-* conventions the official CLI uses. */
2450
- function chatHeaders(credential) {
2656
+ function chatHeaders(credential, identity = {}) {
2451
2657
  return {
2452
2658
  ...commonHeaders(credential),
2453
2659
  "Content-Type": "application/json",
@@ -2455,7 +2661,8 @@ function chatHeaders(credential) {
2455
2661
  ...credential.enterpriseId === void 0 || credential.enterpriseId === "" ? { "X-No-Enterprise-Id": "1" } : { "X-Enterprise-Id": credential.enterpriseId },
2456
2662
  ...credential.domain === "" ? { "X-No-Department-Info": "1" } : { "X-Domain": credential.domain },
2457
2663
  "X-Product": "SaaS",
2458
- ...conversationHeaders()
2664
+ ...ideHeaders(identity),
2665
+ ...conversationHeaders("chat", identity)
2459
2666
  };
2460
2667
  }
2461
2668
  /** Refresh-endpoint headers; X-Refresh-Token appears here and nowhere else. */
@@ -2492,9 +2699,17 @@ function billingHeaders(credential) {
2492
2699
  * 探针当时没带,带了只是多一个可能被网关拒掉的变量,没有收益。
2493
2700
  *
2494
2701
  * 复用 {@link conversationHeaders}:官方 CLI 的 ImageService 也是每次现生成
2495
- * 一组会话 UUID,且两个区走同一条代码路径(见该函数的注释)。
2496
- */
2497
- function imageHeaders(credential) {
2702
+ * 一组会话 UUID。但这里**传 `'image'`,且刻意不跟 `usageIdentity` 开关走**:
2703
+ * issue #132 的实测只覆盖 chat,而生图这两个入口在 2026-10-21 是用带连字符的
2704
+ * v4 答的 200,而 id 的**格式**从来不是生图那道闸问的事。把对齐「顺手统一」到
2705
+ * 这里,等于用一条没人测过的字节去换一次显示对齐——`tests/upstream.spec.ts` 里
2706
+ * 那条生图守卫钉的就是这一点,而 `shape` 是**唯一**挡住它的东西:客户端把
2707
+ * `usageIdentity` 一路传进了 `imageHeaders`,是 `'image'` 这个形状参数让它即便带着开关
2708
+ * 也不采用 chat 的形状。守卫因此真能失败——两个方向都实跑过:把 `'image'` 改成
2709
+ * `'chat'` 会红,删掉 `shape === 'chat'` 判据也会红。(identity 没接进来之前,`shape`
2710
+ * 是空转参数、只改它守卫照样绿;接进来是这一步的前提,别退回成不传 identity。)
2711
+ */
2712
+ function imageHeaders(credential, identity = {}) {
2498
2713
  return {
2499
2714
  "Authorization": `Bearer ${credential.accessToken}`,
2500
2715
  "Accept": "application/json, text/plain, */*",
@@ -2505,7 +2720,7 @@ function imageHeaders(credential) {
2505
2720
  ...credential.uid === "" ? { "X-No-User-Id": "1" } : { "X-User-Id": credential.uid },
2506
2721
  ...credential.enterpriseId === void 0 || credential.enterpriseId === "" ? { "X-No-Enterprise-Id": "1" } : { "X-Enterprise-Id": credential.enterpriseId },
2507
2722
  ...credential.domain === "" ? { "X-No-Department-Info": "1" } : { "X-Domain": credential.domain },
2508
- ...conversationHeaders()
2723
+ ...conversationHeaders("image", identity)
2509
2724
  };
2510
2725
  }
2511
2726
  /**
@@ -2576,6 +2791,68 @@ const WORKBUDDY_FALLBACK_SYSTEM_PROMPT = "You are a helpful assistant.";
2576
2791
  */
2577
2792
  const UNAPPROVED_CHANNEL_OPENINGS = ["you are an ai agent powered by deepseek harness", "you are claude code, anthropic's official cli for claude"];
2578
2793
  /**
2794
+ * The identity clauses an agent prompt introduces itself with, matched so the
2795
+ * plugin can break them INSIDE rather than in front of.
2796
+ *
2797
+ * WHY {@link UNAPPROVED_CHANNEL_OPENINGS} DOES NOT COVER THESE. That list is a
2798
+ * PREFIX rule, and it is the right rule for the gate the plugin measured on
2799
+ * 2026-10-21. It is the wrong rule for the gate the upstream has been running
2800
+ * since: a case-insensitive EXACT SUBSTRING match ANYWHERE in a scanned
2801
+ * message, which is what this plugin's own issue #125 recorded (three sites in
2802
+ * the live DSH prompt, "只改任意一两处不够,三处全改才 200") and what
2803
+ * `workbuddy2api-hub` #533 independently bisected to the byte on a real 124 KB
2804
+ * request. A prefix prepend cannot break a substring match, because the banned
2805
+ * span is still in the text, intact, one line lower.
2806
+ *
2807
+ * THE THREE MEASURED SITES, and why each needs its own alternative:
2808
+ *
2809
+ * You are an AI agent powered by DeepSeek Harness.
2810
+ * You are a coding agent powered by the <model> model.
2811
+ * You are interacting with the user through the DeepSeek Harness Web GUI …
2812
+ *
2813
+ * The first is on the prefix list. The other two are NOT, and the second is the
2814
+ * one that survives every rung this plugin had: it carries no adjacent
2815
+ * capitalised word pair (so {@link WORD_INTERNAL_BREAK} misses it), no comma or
2816
+ * clause punctuation, and its terminal full stop lands AFTER the banned span (so
2817
+ * the sentence rules miss it too). Measured against a real DSH prompt: sparse
2818
+ * perturbation clears sites 1 and 3 and leaves site 2 byte-identical, which is
2819
+ * exactly the request that kept answering 11128.
2820
+ *
2821
+ * The model name in site 2 is not stable (`qmodel_38max`, `deepseek-v4.1-flash`,
2822
+ * …), so the clause is matched up to `powered by` and the variable tail is left
2823
+ * to the break rule itself.
2824
+ */
2825
+ const IDENTITY_CLAUSE = /(you)(\s+are\s+(?:(?:an?\s+)?[\w-]*\s*agent\s+powered\s+by|interacting\s+with\s+the\s+user\s+through\s+the\s+deepseek\s+harness))/giu;
2826
+ /**
2827
+ * Break an identity clause at EVERY word boundary inside it.
2828
+ *
2829
+ * NOT one break, and that is deliberate. The plugin does not know which span the
2830
+ * upstream listed: #533's minimal failing sample is the whole sentence, but the
2831
+ * gate is a substring match, so the listed span could equally be a shorter piece
2832
+ * of it (`a coding agent powered by`). One break at the front defeats only the
2833
+ * spans that start there. Breaking at every internal word boundary defeats every
2834
+ * span inside the clause, whatever its endpoints, for about ten invisible
2835
+ * characters.
2836
+ *
2837
+ * The cost is why this can run on EVERY request instead of only in a rescue:
2838
+ * measured on a real 10,818-character DSH system prompt it adds ~28 characters
2839
+ * (+0.26%), against the dense rescue's 7,742 (+70.7%). Both break all three
2840
+ * sites; only this one is cheap enough to send unconditionally.
2841
+ *
2842
+ * The break goes AFTER each whitespace run, so the text keeps its own spacing
2843
+ * and a reader (or a tokenizer) sees the same words in the same order.
2844
+ *
2845
+ * Naturally idempotent: once a clause carries a break, `IDENTITY_CLAUSE` no
2846
+ * longer matches it — `\s+` does not match U+200B, so the second alternative
2847
+ * fails at the inserted character and the clause is left alone on every later
2848
+ * pass. That is a property this function has to have on its own, because
2849
+ * {@link perturbChannelFingerprints} can no longer refuse to touch text that
2850
+ * already carries an invisible break.
2851
+ */
2852
+ function breakIdentityClauses(text) {
2853
+ return text.replace(IDENTITY_CLAUSE, (clause) => clause.replace(/(\s+)/gu, `$1${ZERO_WIDTH_SPACE}`));
2854
+ }
2855
+ /**
2579
2856
  * Whether this text OPENS with an opening the gateways refuse.
2580
2857
  *
2581
2858
  * The gate is a PREFIX match on the START of a `system` message, so this is
@@ -2668,8 +2945,8 @@ function neutralizeChannelFingerprints(obj) {
2668
2945
  if (!scansOpening(message["role"])) continue;
2669
2946
  const content = message["content"];
2670
2947
  if (typeof content === "string") {
2671
- if (!isUnapprovedChannelOpening(content)) continue;
2672
- message["content"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${content}`;
2948
+ const next = neutralizeScannedText(content);
2949
+ if (next !== void 0) message["content"] = next;
2673
2950
  continue;
2674
2951
  }
2675
2952
  if (!Array.isArray(content)) continue;
@@ -2679,12 +2956,37 @@ function neutralizeChannelFingerprints(obj) {
2679
2956
  const type = record["type"];
2680
2957
  if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
2681
2958
  const text = record["text"];
2682
- if (typeof text !== "string" || !isUnapprovedChannelOpening(text)) continue;
2683
- record["text"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${text}`;
2959
+ if (typeof text !== "string") continue;
2960
+ const next = neutralizeScannedText(text);
2961
+ if (next !== void 0) record["text"] = next;
2684
2962
  }
2685
2963
  }
2686
2964
  }
2687
2965
  /**
2966
+ * Both neutralising transforms, applied to one scanned text part.
2967
+ *
2968
+ * `undefined` means the text goes on the wire byte-for-byte, which is the case
2969
+ * for every ordinary message: an ordinary conversation contains no identity
2970
+ * clause, so this changes nothing at all and the body is not re-serialised.
2971
+ *
2972
+ * THE ORDER IS LOAD-BEARING. The prefix rule reads the START of the text, so it
2973
+ * has to look at the text the HOST wrote; running {@link breakIdentityClauses}
2974
+ * first would insert an invisible break inside `You are an AI agent powered by
2975
+ * DeepSeek Harness`, and {@link isUnapprovedChannelOpening} would then no longer
2976
+ * match it — silently dropping the neutral head the prefix gate still needs. So
2977
+ * the head goes on first, and the clause breaks land in the text underneath it.
2978
+ *
2979
+ * BOTH RUN, because they defend different gates. The head defeats the prefix
2980
+ * match at offset 0; the clause breaks defeat the exact-substring match
2981
+ * anywhere inside. A body that needs only one of them gets only that one:
2982
+ * the Claude Code opening on the prefix list carries no identity clause, and an
2983
+ * identity clause nobody put on the prefix list needs no head.
2984
+ */
2985
+ function neutralizeScannedText(text) {
2986
+ const broken = breakIdentityClauses(isUnapprovedChannelOpening(text) ? `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${text}` : text);
2987
+ return broken === text ? void 0 : broken;
2988
+ }
2989
+ /**
2688
2990
  * A body in which EVERY scanned opening (`system` and `assistant`) is moved
2689
2991
  * behind the neutral head.
2690
2992
  *
@@ -2857,8 +3159,7 @@ function perturbChannelFingerprints(bodyJson, dense = false) {
2857
3159
  if (!Array.isArray(messages)) return void 0;
2858
3160
  let changed = false;
2859
3161
  const perturb = (text) => {
2860
- if (text.includes(ZERO_WIDTH_SPACE)) return void 0;
2861
- const boundaries = text.replace(/([.!?])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`).replace(/([。!?])/gu, `$1${ZERO_WIDTH_SPACE}`).replace(/([,;])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`).replace(WORD_INTERNAL_BREAK, (whole, head, tail) => `${head}${ZERO_WIDTH_SPACE}${tail}`);
3162
+ const boundaries = text.replace(/([.!?])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`).replace(/([。!?])(?!\u200b)/gu, `$1${ZERO_WIDTH_SPACE}`).replace(/([,;])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`).replace(WORD_INTERNAL_BREAK, (whole, head, tail) => `${head}${ZERO_WIDTH_SPACE}${tail}`);
2862
3163
  if (!dense) return boundaries === text ? void 0 : boundaries;
2863
3164
  const denseText = boundaries.replace(/(?=\S)/gu, (whole, offset, source) => {
2864
3165
  if (source.slice(Math.max(0, offset - 1), offset + 1).includes(ZERO_WIDTH_SPACE)) return "";
@@ -3041,6 +3342,133 @@ function modelIdOfBody(bodyJson) {
3041
3342
  }
3042
3343
  }
3043
3344
  /**
3345
+ * Prefixes the DSH harness uses for the context it injects as `user` messages.
3346
+ *
3347
+ * MEASURED shapes, not guesses: the harness's time-context provider appends
3348
+ * `Time sampled while preparing turn N, step M: …`, its runtime-context
3349
+ * provider appends `Current runtime context. …`, and its reminders arrive as
3350
+ * `<system-reminder>` blocks. `billion-context` may additionally wrap each
3351
+ * message in an `<acp …>mNNNNN</acp>` marker, so the pattern TOLERATES that
3352
+ * marker in front of an injected block — the marker itself is never removed
3353
+ * from an injected message (see {@link ACP_LEADING_TAG} for where a marker IS
3354
+ * removed, and why that is only the recorded prompt).
3355
+ *
3356
+ * A body whose trailing user message starts with none of these is left exactly
3357
+ * as it arrived — the alternative (moving "the last non-empty user message")
3358
+ * would reorder real conversations from other clients.
3359
+ */
3360
+ const INJECTED_USER_PREFIX = /^(?:<acp\b[^>]*>m\d{1,8}<\/acp>[ \t]*\r?\n?)*(?:Time sampled while preparing turn \d+, step \d+:|Current runtime context\.|<system-reminder>)/u;
3361
+ /**
3362
+ * The per-message marker `billion-context` puts in front of every message.
3363
+ *
3364
+ * Stripped from the REAL prompt only (issue #132), because the usage page takes
3365
+ * that message verbatim and the recorded row would otherwise open with
3366
+ * `<acp …>m00042</acp>` instead of what the user typed. Only the marker is
3367
+ * removed, never any text: the earlier messages keep theirs, so a model that
3368
+ * cites a past message by its `mNNNNN` id can still do it — the one id lost is
3369
+ * the message being answered right now, which is the only reference that cannot
3370
+ * matter. Injected messages keep their marker too: they are not the row being
3371
+ * recorded, and touching them would widen the edit for no benefit. Done only
3372
+ * under the same opt-in as the reorder, since it deletes bytes the upstream
3373
+ * counts.
3374
+ */
3375
+ const ACP_LEADING_TAG = /^<acp\b[^>]*>m\d{1,8}<\/acp>[ \t]*\r?\n?/u;
3376
+ /**
3377
+ * The plain text of one message's content, for prefix matching only.
3378
+ *
3379
+ * Array-shaped content contributes its text parts joined by a newline; a
3380
+ * non-text part is skipped rather than stringified, because `[object Object]`
3381
+ * would make every prefix test miss.
3382
+ */
3383
+ function textOfContent(content) {
3384
+ if (typeof content === "string") return content;
3385
+ if (!Array.isArray(content)) return "";
3386
+ const parts = [];
3387
+ for (const part of content) {
3388
+ if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
3389
+ const record = part;
3390
+ if (typeof record["text"] !== "string") continue;
3391
+ parts.push(record["text"]);
3392
+ }
3393
+ return parts.join("\n");
3394
+ }
3395
+ /**
3396
+ * Drop the leading `billion-context` marker from one message's content.
3397
+ *
3398
+ * Handles both shapes (a bare string, or array content whose first `text` part
3399
+ * carries the marker) and leaves everything else untouched. Returns whether it
3400
+ * changed anything, so a body with no marker is not re-serialized for nothing.
3401
+ */
3402
+ function stripLeadingAcpTag(content) {
3403
+ if (typeof content === "string") {
3404
+ if (!ACP_LEADING_TAG.test(content)) return {
3405
+ value: content,
3406
+ changed: false
3407
+ };
3408
+ return {
3409
+ value: content.replace(ACP_LEADING_TAG, ""),
3410
+ changed: true
3411
+ };
3412
+ }
3413
+ if (!Array.isArray(content)) return {
3414
+ value: content,
3415
+ changed: false
3416
+ };
3417
+ for (const part of content) {
3418
+ if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
3419
+ const record = part;
3420
+ if (record["type"] !== "text" || typeof record["text"] !== "string") continue;
3421
+ if (!ACP_LEADING_TAG.test(record["text"])) continue;
3422
+ record["text"] = record["text"].replace(ACP_LEADING_TAG, "");
3423
+ return {
3424
+ value: content,
3425
+ changed: true
3426
+ };
3427
+ }
3428
+ return {
3429
+ value: content,
3430
+ changed: false
3431
+ };
3432
+ }
3433
+ /** Whether this message is one of the harness's injected context blocks. */
3434
+ function isInjectedUserMessage(message) {
3435
+ if (message["role"] !== "user") return false;
3436
+ return INJECTED_USER_PREFIX.test(textOfContent(message["content"]).replace(/^\s+/u, ""));
3437
+ }
3438
+ /**
3439
+ * Move the trailing injected `user` messages above the real prompt.
3440
+ *
3441
+ * Returns `true` when the order changed, so the caller can serialize only once.
3442
+ * Never throws, and a body with nothing to move keeps its exact message order.
3443
+ */
3444
+ function keepPromptLast(messages) {
3445
+ let promptIndex = -1;
3446
+ for (let index = messages.length - 1; index >= 0; index--) {
3447
+ const value = messages[index];
3448
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
3449
+ const message = value;
3450
+ if (message["role"] !== "user") continue;
3451
+ if (isInjectedUserMessage(message)) continue;
3452
+ if (textOfContent(message["content"]).trim() === "") continue;
3453
+ promptIndex = index;
3454
+ break;
3455
+ }
3456
+ if (promptIndex < 0) return false;
3457
+ const trailing = messages.slice(promptIndex + 1).filter((value) => typeof value === "object" && value !== null && !Array.isArray(value) ? isInjectedUserMessage(value) : false);
3458
+ if (trailing.length === 0) return false;
3459
+ const kept = messages.filter((value, index) => !(index > promptIndex && typeof value === "object" && value !== null && !Array.isArray(value) && isInjectedUserMessage(value)));
3460
+ kept.splice(promptIndex, 0, ...trailing);
3461
+ const prompt = kept[promptIndex + trailing.length];
3462
+ if (typeof prompt === "object" && prompt !== null && !Array.isArray(prompt)) {
3463
+ const message = prompt;
3464
+ const stripped = stripLeadingAcpTag(message["content"]);
3465
+ if (stripped.changed) message["content"] = stripped.value;
3466
+ }
3467
+ messages.length = 0;
3468
+ messages.push(...kept);
3469
+ return true;
3470
+ }
3471
+ /**
3044
3472
  * Normalize an OpenAI chat-completions body for the WorkBuddy upstream: force
3045
3473
  * `stream: true` (the upstream rejects non-streaming), flatten `tool_choice`
3046
3474
  * (the upstream's field is a string; object forms return 400), rewrite
@@ -3057,7 +3485,7 @@ function modelIdOfBody(bodyJson) {
3057
3485
  * upstream already answers keeps its prompt as close to byte-for-byte as these
3058
3486
  * rules allow.
3059
3487
  */
3060
- function prepareChatBody(source) {
3488
+ function prepareChatBody(source, options) {
3061
3489
  let body;
3062
3490
  try {
3063
3491
  body = JSON.parse(source);
@@ -3073,6 +3501,7 @@ function prepareChatBody(source) {
3073
3501
  if (message["role"] === "developer") message["role"] = "system";
3074
3502
  }
3075
3503
  ensureSystemHead(obj);
3504
+ if (options?.keepPromptLast === true && Array.isArray(obj["messages"])) keepPromptLast(obj["messages"]);
3076
3505
  neutralizeChannelFingerprints(obj);
3077
3506
  normalizeToolChoice(obj);
3078
3507
  dropRefusedReasoning(obj);
@@ -3526,6 +3955,7 @@ function requestEvidenceOf(bodyJson) {
3526
3955
  */
3527
3956
  var WorkBuddyUpstreamClient = class {
3528
3957
  onFallback;
3958
+ identity;
3529
3959
  /**
3530
3960
  * Reports a fallback the catalog reader had to take.
3531
3961
  *
@@ -3535,8 +3965,9 @@ var WorkBuddyUpstreamClient = class {
3535
3965
  * `ctx.logger.warn` in; without a host the event is dropped, which is the
3536
3966
  * same silence the previous single-source reader had.
3537
3967
  */
3538
- constructor(onFallback) {
3968
+ constructor(onFallback, identity = {}) {
3539
3969
  this.onFallback = onFallback;
3970
+ this.identity = identity;
3540
3971
  }
3541
3972
  /** POST the chat endpoint; a successful answer is the raw SSE response. */
3542
3973
  async chatStream(credential, bodyJson, signal) {
@@ -3545,7 +3976,7 @@ var WorkBuddyUpstreamClient = class {
3545
3976
  response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
3546
3977
  method: "POST",
3547
3978
  headers: {
3548
- ...chatHeaders(credential),
3979
+ ...chatHeaders(credential, this.identity),
3549
3980
  "Authorization": `Bearer ${credential.accessToken}`
3550
3981
  },
3551
3982
  body: bodyJson,
@@ -3608,7 +4039,7 @@ var WorkBuddyUpstreamClient = class {
3608
4039
  response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
3609
4040
  method: "POST",
3610
4041
  headers: {
3611
- ...chatHeaders(credential),
4042
+ ...chatHeaders(credential, this.identity),
3612
4043
  "Authorization": `Bearer ${credential.accessToken}`
3613
4044
  },
3614
4045
  body: bodyJson,
@@ -3810,7 +4241,7 @@ var WorkBuddyUpstreamClient = class {
3810
4241
  async generateImage(credential, request, signal) {
3811
4242
  const response = await fetch(`${chatBase(credential)}${IMAGE_GENERATIONS_PATH}`, {
3812
4243
  method: "POST",
3813
- headers: imageHeaders(credential),
4244
+ headers: imageHeaders(credential, this.identity),
3814
4245
  body: JSON.stringify(buildImageBody(request)),
3815
4246
  signal: signal ?? AbortSignal.timeout(IMAGE_TIMEOUT_MS)
3816
4247
  });
@@ -5176,9 +5607,21 @@ var WorkBuddyCredentialStore = class {
5176
5607
  * refuses to use is what made the dropdown look healthy while every request
5177
5608
  * failed with 401.
5178
5609
  *
5610
+ * THE POOL'S RUNTIME OVERRIDE IS PART OF `current()`, SO IT IS PART OF THIS.
5611
+ * `current()` consults `rotatedAccountId` FIRST; this method used to ignore it
5612
+ * and answer from `accountId` alone. With the pool on, the two therefore
5613
+ * disagreed on every rotation: the shim billed account B while the card marked
5614
+ * account A — the reporter's screenshot showed 「当前选用」 on a rate-limited
5615
+ * account while the pill's total belonged to the one actually serving. Worse,
5616
+ * the usage document takes its `credential` from `current()` and its
5617
+ * `account.accountId` from this list, so one document could name two accounts.
5618
+ *
5179
5619
  * So an explicit selection that matches NO local account marks nothing as
5180
5620
  * selected (no silent fallback to a different account — see {@link current}).
5181
5621
  * The implicit default is marked only when nothing was explicitly chosen.
5622
+ * A live rotation marker wins over both, mirroring `current()`'s precedence,
5623
+ * and only while it still names a local account — `current()` falls through in
5624
+ * that case, and so does this.
5182
5625
  */
5183
5626
  async accounts() {
5184
5627
  const credentials = await this.readAll();
@@ -5186,6 +5629,7 @@ var WorkBuddyCredentialStore = class {
5186
5629
  const hasExplicitSelection = this.accountId !== void 0;
5187
5630
  const selectedExists = hasExplicitSelection && credentials.some((credential) => workbuddyAccountId(credential) === this.accountId);
5188
5631
  const defaultSelected = this.preferred(credentials);
5632
+ const rotatedId = this.rotatedAccountId !== void 0 && credentials.some((credential) => workbuddyAccountId(credential) === this.rotatedAccountId) ? this.rotatedAccountId : void 0;
5189
5633
  return credentials.map((credential) => {
5190
5634
  const id = workbuddyAccountId(credential);
5191
5635
  return {
@@ -5196,7 +5640,7 @@ var WorkBuddyCredentialStore = class {
5196
5640
  source: credential.source,
5197
5641
  tokenExpiresAtMs: credential.expiresAtMs,
5198
5642
  filePath: credential.filePath,
5199
- selected: selectedExists ? id === this.accountId : !hasExplicitSelection && credential === defaultSelected
5643
+ selected: rotatedId !== void 0 ? id === rotatedId : selectedExists ? id === this.accountId : !hasExplicitSelection && credential === defaultSelected
5200
5644
  };
5201
5645
  });
5202
5646
  }
@@ -6148,10 +6592,221 @@ function generationOf(version) {
6148
6592
  if (match === null) return "legacy";
6149
6593
  return Number(match[1]) >= 87 ? "modern" : "legacy";
6150
6594
  }
6595
+ /** The versions this build can read (add a new one on upgrade, keep old ones). */
6596
+ const KNOWN_ROSTER_SEEN_VERSIONS = Object.freeze([1]);
6597
+ /**
6598
+ * The "new" window: a first-seen stamp inside it counts as new.
6599
+ *
6600
+ * 7 days is a deliberate scale — it has to cover "the user was away a few days
6601
+ * and came back to two new models" without letting one bulk upload still carry
6602
+ * a marker two weeks later. The window is a CONSTANT here rather than a config:
6603
+ * a "new model" marker that could be tuned to 365 days has no meaning.
6604
+ */
6605
+ const ROSTER_SEEN_NEW_WINDOW_MS = 6048e5;
6606
+ /**
6607
+ * How long a read answer stays valid before the file is read again.
6608
+ *
6609
+ * Another process may have just written, so the answer has to be visible within
6610
+ * a tick — but a single snapshot assembly should not re-read the file several
6611
+ * times.
6612
+ */
6613
+ const ROSTER_SEEN_READ_TTL_MS = 5e3;
6614
+ /** Filename prefix for one region's first-sighting history. */
6615
+ const ROSTER_SEEN_FILE_PREFIX = ".workbuddy-roster-seen";
6616
+ const EMPTY_PAYLOAD = {
6617
+ seen: {},
6618
+ version: null
6619
+ };
6620
+ /** Whether this build can read a stored version. */
6621
+ function isKnownRosterSeenVersion(version) {
6622
+ return typeof version === "number" && KNOWN_ROSTER_SEEN_VERSIONS.includes(version);
6623
+ }
6624
+ /**
6625
+ * Normalise a first-seen table: keep only entries whose id is a non-empty
6626
+ * string and whose stamp is a finite positive number.
6627
+ *
6628
+ * Dropping rather than repairing a bad entry is deliberate: a stamp of 0 or
6629
+ * negative makes `now - stamp` come out as decades, and that id would count as
6630
+ * "new" forever. Better to let it take a real stamp the next time it appears.
6631
+ */
6632
+ function normalizeRosterSeen(raw) {
6633
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {};
6634
+ const out = {};
6635
+ for (const [id, stamp] of Object.entries(raw)) {
6636
+ if (id === "") continue;
6637
+ if (typeof stamp !== "number" || !Number.isFinite(stamp) || stamp <= 0) continue;
6638
+ out[id] = stamp;
6639
+ }
6640
+ return out;
6641
+ }
6642
+ /**
6643
+ * One region's path for its first-sighting history.
6644
+ *
6645
+ * Region-keyed for the same reason the credential and pool copies are: the two
6646
+ * regions are parallel stacks and must never overwrite each other's history.
6647
+ */
6648
+ function rosterSeenPath(region, dir) {
6649
+ if (typeof dir === "string" && dir !== "") return join(dir, `${ROSTER_SEEN_FILE_PREFIX}.${region}.json`);
6650
+ return join(resolveDshHome(), `${ROSTER_SEEN_FILE_PREFIX}.${region}.json`);
6651
+ }
6652
+ /**
6653
+ * The ids from a first-seen table that are still "new", oldest first.
6654
+ *
6655
+ * Ascending on purpose: the caller shows only the first few names (the pill
6656
+ * tooltip / popover budget), and the ones seen EARLIEST are the ones closest to
6657
+ * expiring — and the ones the reader is most likely to have already met and
6658
+ * needs told "it really only just appeared".
6659
+ */
6660
+ function freshIdsOf(seen, nowMs) {
6661
+ return Object.entries(seen).filter(([, first]) => nowMs - first < ROSTER_SEEN_NEW_WINDOW_MS).sort((left, right) => left[1] - right[1]).map(([id]) => id);
6662
+ }
6663
+ /**
6664
+ * Parse a raw JSON value into a payload; an unreadable version reads as an
6665
+ * empty table but KEEPS the version number — reads stay permissive, while the
6666
+ * write guard refuses to overwrite it (the two halves are one rule).
6667
+ */
6668
+ function parseRosterSeenPayload(raw) {
6669
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return EMPTY_PAYLOAD;
6670
+ const source = raw;
6671
+ const version = typeof source["version"] === "number" ? source["version"] : null;
6672
+ if (!isKnownRosterSeenVersion(version)) return {
6673
+ seen: {},
6674
+ version
6675
+ };
6676
+ return {
6677
+ seen: normalizeRosterSeen(source["seen"]),
6678
+ version
6679
+ };
6680
+ }
6681
+ /**
6682
+ * The file-backed first-sighting table for one region.
6683
+ */
6684
+ function createFileRosterSeenStore(region, options = {}) {
6685
+ const { dir, ttlMs = ROSTER_SEEN_READ_TTL_MS } = options;
6686
+ const filePath = rosterSeenPath(region, dir);
6687
+ /** Read the file's raw JSON, or `null` for a missing or unreadable file. */
6688
+ const readRaw = async () => {
6689
+ try {
6690
+ return JSON.parse(await readFile(filePath, "utf8"));
6691
+ } catch {
6692
+ return null;
6693
+ }
6694
+ };
6695
+ let cached;
6696
+ const readPayload = async () => {
6697
+ if (cached !== void 0 && Date.now() - cached.at < ttlMs) return cached.payload;
6698
+ const payload = parseRosterSeenPayload(await readRaw());
6699
+ cached = {
6700
+ payload,
6701
+ at: Date.now()
6702
+ };
6703
+ return payload;
6704
+ };
6705
+ let writeTail = Promise.resolve();
6706
+ const enqueueWrite = (task) => {
6707
+ const run = writeTail.then(task, task);
6708
+ writeTail = run.then(() => void 0, () => void 0);
6709
+ return run;
6710
+ };
6711
+ return {
6712
+ /**
6713
+ * Record one round's complete catalog and answer which of its ids are still
6714
+ * inside the new window.
6715
+ *
6716
+ * One call diffs AND persists (see the module note). The first write is
6717
+ * SEEDING: every id is stamped as already expired and `newIds` answers
6718
+ * empty.
6719
+ */
6720
+ async note(ids, nowMs = Date.now()) {
6721
+ const list = Array.isArray(ids) ? ids.filter((id) => typeof id === "string" && id !== "") : [];
6722
+ const unique = [...new Set(list)];
6723
+ if (unique.length === 0) return { newIds: [] };
6724
+ const now = Number.isFinite(nowMs) ? nowMs : Date.now();
6725
+ return enqueueWrite(() => withFileLock(filePath, async () => {
6726
+ const current = parseRosterSeenPayload(await readRaw());
6727
+ if (current.version !== null && !isKnownRosterSeenVersion(current.version)) return { newIds: [] };
6728
+ const previous = current.seen;
6729
+ const seeded = current.version === null;
6730
+ const next = {};
6731
+ for (const id of unique) {
6732
+ const first = previous[id];
6733
+ next[id] = typeof first === "number" && first > 0 ? Math.min(first, now) : seeded ? now - ROSTER_SEEN_NEW_WINDOW_MS : now;
6734
+ }
6735
+ if (Object.keys(next).length > 200) return { newIds: [] };
6736
+ if (seeded || Object.keys(previous).length !== Object.keys(next).length || Object.keys(next).some((id) => previous[id] !== next[id])) {
6737
+ await writeFileAtomic(filePath, `${JSON.stringify({
6738
+ version: 1,
6739
+ seen: next,
6740
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
6741
+ }, null, 2)}\n`, {
6742
+ mode: 384,
6743
+ dirMode: 448
6744
+ });
6745
+ cached = {
6746
+ payload: {
6747
+ seen: next,
6748
+ version: 1
6749
+ },
6750
+ at: Date.now()
6751
+ };
6752
+ }
6753
+ return { newIds: seeded ? [] : freshIdsOf(next, now) };
6754
+ }));
6755
+ },
6756
+ /**
6757
+ * Read-only: the ids that still count as "new" right now (ascending).
6758
+ *
6759
+ * NEVER WRITES. The snapshot and the provider route are read paths, and
6760
+ * writing happens only in the reconciler's `note`; a read path that topped
6761
+ * up stamps would let one panel poll rewrite "who is new".
6762
+ */
6763
+ async fresh(nowMs = Date.now()) {
6764
+ return freshIdsOf((await readPayload()).seen, Number.isFinite(nowMs) ? nowMs : Date.now());
6765
+ },
6766
+ /** Whether this table has ever been written (seeded vs "really none new"). */
6767
+ async isSet() {
6768
+ return (await readPayload()).version !== null;
6769
+ },
6770
+ /**
6771
+ * The read-only readout the usage route carries, or the version note when
6772
+ * the on-disk history is in a version this build cannot read.
6773
+ */
6774
+ async readout(nowMs = Date.now()) {
6775
+ const payload = await readPayload();
6776
+ const version = payload.version;
6777
+ if (version === null || isKnownRosterSeenVersion(version)) return { newModelIds: freshIdsOf(payload.seen, Number.isFinite(nowMs) ? nowMs : Date.now()) };
6778
+ return {
6779
+ newModelIds: [],
6780
+ versionNote: `The first-sighting history is stored in version ${String(version)}, which this build cannot read; it is answered as empty, so the pill shows no new-model marker. Known versions: ${KNOWN_ROSTER_SEEN_VERSIONS.join(", ")}.`
6781
+ };
6782
+ },
6783
+ /** The on-disk path (diagnostics and tests). */
6784
+ filePath
6785
+ };
6786
+ }
6787
+ /**
6788
+ * doctor's read-only summary: parse a roster-seen payload without building a
6789
+ * store or touching any cache.
6790
+ *
6791
+ * `null` for a payload the whole shape does not recognise; `versionKnown: false`
6792
+ * for a recognised version this build cannot read — so `doctor` can tell "the
6793
+ * file is corrupt" from "a newer build wrote it".
6794
+ */
6795
+ function describeRosterSeenPayload(raw) {
6796
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
6797
+ const version = raw["version"];
6798
+ if (typeof version !== "number") return null;
6799
+ const seen = normalizeRosterSeen(raw["seen"]);
6800
+ return {
6801
+ versionKnown: isKnownRosterSeenVersion(version),
6802
+ ids: Object.keys(seen).length,
6803
+ fresh: freshIdsOf(seen, Date.now()).length
6804
+ };
6805
+ }
6151
6806
  //#endregion
6152
6807
  //#region src/version.ts
6153
6808
  /** The npm package version this build was produced from. */
6154
- const WORKBUDDY_CONNECT_VERSION = "5.7.0";
6809
+ const WORKBUDDY_CONNECT_VERSION = "5.8.1";
6155
6810
  //#endregion
6156
6811
  //#region src/host-heartbeat.ts
6157
6812
  /**
@@ -6309,4 +6964,4 @@ async function clearHostHeartbeat() {
6309
6964
  await rm(workbuddyHostHeartbeatPath(), { force: true });
6310
6965
  }
6311
6966
  //#endregion
6312
- export { prepareChatBody as $, parseWorkBuddyAuth as A, readAtRestKey as At, declaredTools as B, defaultDesktopAuthCandidates as C, findWorkbuddyAppExecutable as Ct, isEncryptedCredentialError as D, macosBundleExecutable as Dt, hasEncryptedCredentialFields as E, isWorkbuddyBundle as Et, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as F, writeFileAtomic as Ft, isFirstMessageGateBody as G, isContextTooLongBody as H, WorkBuddyCredentialRejectedError as I, openingEvidenceOfBody as J, knownRegionOf as K, WorkBuddyUpstreamClient as L, workbuddyOwnAuthPath as M, hostPeersInfo as Mt, CONTEXT_OVERFLOW_MARKER as N, resolveDshHome as Nt, isVaultAccountId as O, macosNestedAppBundles as Ot, CREDENTIAL_REJECTED_CODE as P, withFileLock as Pt, perturbChannelFingerprints as Q, channelGateMemberOf as R, authFileName as S, fetchAtRestKeyPayload as St, defaultDesktopAuthPath as T, isEncryptedFieldWrapper as Tt, isConversationIdGateBody as U, forceNeutralHead as V, isCredentialRejectedError as W, parseReasoning as X, parseCreditMultiplier as Y, parseUpstreamModel as Z, ENCRYPTED_CREDENTIAL_CODE as _, nativeModalityOf as _t, readHostHeartbeat as a, lastRefusalFor as at, WorkBuddyCredentialStore as b, deriveAtRestKey as bt, WORKBUDDY_CONNECT_VERSION as c, recordRefusal as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, WorkBuddyImageError as dt, regionOf as et, WorkBuddyCatalog as f, pickImageModel as ft, withoutRouters as g, imageDefaultFor as gt, fallbackModelsFor as h, NATIVE_MODALITY_BY_MODEL_ID as ht, processStartTimeMs as i, defaultOffModelIds as it, workbuddyAccountId as j, workbuddyAppExecutableCandidates as jt, legacyWorkbuddyOwnAuthPath as k, openEncryptedField as kt, piAiRuntimeInfo as l, withEffectiveOff as lt, deriveCatalog as m, queueNoticeText as mt, clearHostHeartbeat as n, attachBusyLevels as nt, workbuddyHostHeartbeatPath as o, offDefaultFor as ot, applyContextBudgets as p, parseQueueAnswer as pt, modelIdOfBody as q, isHeartbeatProcessAlive as r, REFUSES_OFF_MODEL_IDS as rt, writeHostHeartbeat as s, offRefused as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, requestEvidenceOf as tt, FALLBACK_WORKBUDDY_MODELS as u, DEFAULT_IMAGE_SIZE as ut, WORKBUDDY_AUTH_FILENAME as v, WORKBUDDY_APP_EXECUTABLE_ENV as vt, defaultDesktopAuthDirs as w, findWorkbuddyAppExecutableWithSource as wt, WorkBuddyEncryptedCredentialError as x, deriveAtRestKeyId as xt, WORKBUDDY_AUTH_FILE_ENV as y, clearAtRestKeyCache as yt, classifyUpstreamError as z };
6967
+ export { parseCreditMultiplier as $, isEncryptedCredentialError as A, isWorkbuddyBundle as At, WorkBuddyCredentialRejectedError as B, WorkBuddyCredentialStore as C, clearAtRestKeyCache as Ct, defaultDesktopAuthDirs as D, findWorkbuddyAppExecutable as Dt, defaultDesktopAuthCandidates as E, fetchAtRestKeyPayload as Et, workbuddyAccountId as F, workbuddyAppExecutableCandidates as Ft, forceNeutralHead as G, channelGateMemberOf as H, workbuddyOwnAuthPath as I, hostPeersInfo as It, isCredentialRejectedError as J, isContextTooLongBody as K, CONTEXT_OVERFLOW_MARKER as L, resolveDshHome as Lt, legacyWorkbuddyOwnAuthPath as M, macosNestedAppBundles as Mt, parseWorkBuddyAuth as N, openEncryptedField as Nt, defaultDesktopAuthPath as O, findWorkbuddyAppExecutableWithSource as Ot, preferredAccountName as P, readAtRestKey as Pt, openingEvidenceOfBody as Q, CREDENTIAL_REJECTED_CODE as R, withFileLock as Rt, WORKBUDDY_AUTH_FILE_ENV as S, WORKBUDDY_APP_EXECUTABLE_ENV as St, authFileName as T, deriveAtRestKeyId as Tt, classifyUpstreamError as U, WorkBuddyUpstreamClient as V, declaredTools as W, knownRegionOf as X, isFirstMessageGateBody as Y, modelIdOfBody as Z, deriveCatalog as _, parseQueueAnswer as _t, readHostHeartbeat as a, requestEvidenceOf as at, ENCRYPTED_CREDENTIAL_CODE as b, imageDefaultFor as bt, WORKBUDDY_CONNECT_VERSION as c, defaultOffModelIds as ct, rosterSeenPath as d, offRefused as dt, parseReasoning as et, piAiRuntimeInfo as f, recordRefusal as ft, applyContextBudgets as g, pickImageModel as gt, WorkBuddyCatalog as h, WorkBuddyImageError as ht, processStartTimeMs as i, regionOf as it, isVaultAccountId as j, macosBundleExecutable as jt, hasEncryptedCredentialFields as k, isEncryptedFieldWrapper as kt, createFileRosterSeenStore as l, lastRefusalFor as lt, FALLBACK_WORKBUDDY_MODELS_GLOBAL as m, DEFAULT_IMAGE_SIZE as mt, clearHostHeartbeat as n, perturbChannelFingerprints as nt, workbuddyHostHeartbeatPath as o, attachBusyLevels as ot, FALLBACK_WORKBUDDY_MODELS as p, withEffectiveOff as pt, isConversationIdGateBody as q, isHeartbeatProcessAlive as r, prepareChatBody as rt, writeHostHeartbeat as s, REFUSES_OFF_MODEL_IDS as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, parseUpstreamModel as tt, describeRosterSeenPayload as u, offDefaultFor as ut, fallbackModelsFor as v, queueNoticeText as vt, WorkBuddyEncryptedCredentialError as w, deriveAtRestKey as wt, WORKBUDDY_AUTH_FILENAME as x, nativeModalityOf as xt, withoutRouters as y, NATIVE_MODALITY_BY_MODEL_ID as yt, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as z, writeFileAtomic as zt };