dsh-connect-trae 3.0.0 → 3.3.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.
package/lib/index.js CHANGED
@@ -4,11 +4,11 @@ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completio
4
4
  import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
5
5
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
6
6
  import { readFile, rm, stat } from "node:fs/promises";
7
- import { createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
7
+ import { createDecipheriv, createHash, randomBytes, randomUUID, sign, timingSafeEqual } from "node:crypto";
8
8
  import { dirname, join } from "node:path";
9
9
  import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
10
10
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
11
- import { cpus, homedir, hostname, release } from "node:os";
11
+ import { cpus, homedir, hostname, release, userInfo } from "node:os";
12
12
  import { createServer } from "node:http";
13
13
  import { Readable } from "node:stream";
14
14
  import { execFile } from "node:child_process";
@@ -452,6 +452,213 @@ function createTraeAdapter(options) {
452
452
  };
453
453
  }
454
454
  //#endregion
455
+ //#region src/status-paths.ts
456
+ /** Plugin-owned usage endpoint consumed by its browser half. */
457
+ const TRAE_USAGE_PATH = "/plugins/dsh-connect-trae/usage";
458
+ /** Plugin-owned live model refresh endpoint. */
459
+ const TRAE_MODELS_REFRESH_PATH = "/plugins/dsh-connect-trae/models/refresh";
460
+ /**
461
+ * Plugin-owned availability probe: POST the model ids to test, get one verdict
462
+ * per model. Unlike every read-only sibling this route SPENDS quota — a real
463
+ * (minimal) chat call per model — so it is user-initiated only, and the card
464
+ * labels the button as consuming a small amount.
465
+ */
466
+ const TRAE_MODELS_TEST_PATH = "/plugins/dsh-connect-trae/models/test";
467
+ /** Plugin-owned local account rescan endpoint. */
468
+ const TRAE_ACCOUNTS_REFRESH_PATH = "/plugins/dsh-connect-trae/accounts/refresh";
469
+ /**
470
+ * General balance of EVERY account in a region, for the composer panel's table.
471
+ *
472
+ * Separate from the usage route on purpose: the usage document backs the
473
+ * 5-minute readout, and multiplying its upstream reads by the account count
474
+ * would charge a healthy account for something it never looks at — the same
475
+ * principle that keeps `creditAlternatives` firing only when a balance hits
476
+ * zero. This one is paid when the panel is actually opened, which is the only
477
+ * place the table is shown.
478
+ */
479
+ const TRAE_ACCOUNT_CREDITS_PATH = "/plugins/dsh-connect-trae/account-credits";
480
+ /**
481
+ * Per-account status (check-in / credits / subscription) for the account table.
482
+ *
483
+ * Each row of the account table reads its OWN state through this route — a CN
484
+ * row carries its check-in state and balance, an ai row its subscription.
485
+ * POST with the accountId in the body: one upstream read per account, paid
486
+ * only when the table is actually shown.
487
+ */
488
+ const TRAE_ACCOUNT_STATUS_PATH = "/plugins/dsh-connect-trae/account-status";
489
+ /**
490
+ * Plugin-owned daily check-in claim endpoint. POST, loopback-only, and the only
491
+ * route in this plugin that changes upstream account state. The accountId in
492
+ * the body names WHICH account to claim for, so the button can sit in each
493
+ * account row.
494
+ */
495
+ const TRAE_CHECKIN_PATH = "/plugins/dsh-connect-trae/checkin";
496
+ /** Query parameter naming the region a card request addresses. */
497
+ const TRAE_REGION_PARAM = "region";
498
+ /** Every region, in card tab order. */
499
+ const TRAE_REGIONS = ["cn", "ai"];
500
+ /**
501
+ * Address one region's status route. The two regions are separate provider
502
+ * stacks; every card request carries the region whose tab the user is on.
503
+ */
504
+ function withTraeRegion(path, region) {
505
+ return `${path}?${TRAE_REGION_PARAM}=${region}`;
506
+ }
507
+ /**
508
+ * Read the region parameter off a status-route URL. Absent means the domestic
509
+ * tab (`cn`); a present-but-unknown value returns undefined so the route can
510
+ * answer 400 instead of guessing.
511
+ */
512
+ function regionOfTraeStatusUrl(url) {
513
+ const at = url.indexOf("?");
514
+ const value = at === -1 ? null : new URLSearchParams(url.slice(at + 1)).get(TRAE_REGION_PARAM);
515
+ if (value === null || value === "") return "cn";
516
+ return TRAE_REGIONS.includes(value) ? value : void 0;
517
+ }
518
+ /**
519
+ * Peel ONE `{get(): T}` live reference, the shape DSH 0.1.7 delivers a
520
+ * volatile-marked settings field as (see {@link unwrapVolatileDeep}).
521
+ */
522
+ function unwrapVolatile(value) {
523
+ if (value !== null && typeof value === "object" && typeof value.get === "function") return value.get();
524
+ return value;
525
+ }
526
+ /**
527
+ * Deep copy of a value with every `{get(): T}` live reference replaced by the
528
+ * value it resolves to.
529
+ *
530
+ * DSH 0.1.7 hands volatile-marked fields back as live references, and a live
531
+ * reference is still `typeof === 'object'` — so it passes a naive object check
532
+ * and every lookup on it is `undefined`. Worse, spreading one (`{ ...ref }`)
533
+ * does NOT read the field: it produces `{ get: <function> }`. Any merge that
534
+ * spreads a resolved field to preserve its siblings — the card's "write one
535
+ * region, keep the other" write, or the account map's spread — would otherwise
536
+ * DROP every sibling and leak a function into the document.
537
+ *
538
+ * Both halves therefore unwrap before touching a resolved field. This lives in
539
+ * the node-free host↔client bridge because the browser half needs it too.
540
+ *
541
+ * Non-reference values are recursed into so a nested volatile field is caught
542
+ * as well; arrays and objects are rebuilt rather than mutated, so the caller's
543
+ * value is never touched.
544
+ */
545
+ function unwrapVolatileDeep(value) {
546
+ if (value === null || typeof value !== "object") return value;
547
+ if (typeof value.get === "function") return unwrapVolatileDeep(value.get());
548
+ if (Array.isArray(value)) return value.map((entry) => unwrapVolatileDeep(entry));
549
+ const source = value;
550
+ const out = {};
551
+ for (const key of Object.keys(source)) out[key] = unwrapVolatileDeep(source[key]);
552
+ return out;
553
+ }
554
+ /**
555
+ * Read the per-region account selections out of a settings value.
556
+ *
557
+ * SHARED because `accounts` now has TWO writers: the card, and the composer
558
+ * panel's account table (2.14.0). Two writers of one field must use one merge
559
+ * rule, or they drift — and the rule is not obvious.
560
+ *
561
+ * Deep-unwraps first: on DSH 0.1.7 a volatile field arrives as a `{get(): T}`
562
+ * live reference, which passes the `typeof === 'object'` check and would be
563
+ * returned as if it were the map. Every lookup on it is then `undefined`, and
564
+ * the merge (`{ ...configured, [region]: id }`) would spread a reference into
565
+ * `{get: <function>}` — dropping the other region's selection and leaking a
566
+ * function into the settings document.
567
+ */
568
+ function configuredAccountsOf(configured) {
569
+ const accounts = unwrapVolatileDeep(configured)?.accounts;
570
+ return typeof accounts === "object" && accounts !== null ? accounts : {};
571
+ }
572
+ /**
573
+ * Whether the host-side "follow the Trae app's sign-in" switch is on (issue
574
+ * #29). Absent reads as OFF, so an existing configuration keeps the strict
575
+ * bind it shipped with and the switch is an opt-in — the same opt-out
576
+ * semantics as {@link regionEnabledOf}, for the same reason: a config written
577
+ * before this field existed cannot ask for behavior it never declared.
578
+ *
579
+ * Reads the whole settings section (or the bare field); deep-unwraps first
580
+ * because on DSH 0.1.7 a volatile field arrives as a `{get(): T}` live
581
+ * reference, whose `typeof === 'object'` would otherwise pass a naive check.
582
+ */
583
+ function followAppLoginEnabled(configured) {
584
+ return unwrapVolatileDeep(configured)?.followAppLogin === true;
585
+ }
586
+ /**
587
+ * Build the next `regions` settings value for the card's save. The write
588
+ * targets ONLY the signed-in account's region slot; every other region's slot
589
+ * is carried over untouched, so switching accounts never clobbers the other
590
+ * region's picks. Tolerates any stored shape (absent, non-object) by starting
591
+ * from an empty document.
592
+ */
593
+ function nextRegionSlots(regions, region, slot) {
594
+ const unwrapped = unwrapVolatileDeep(regions);
595
+ return {
596
+ ...typeof unwrapped === "object" && unwrapped !== null && !Array.isArray(unwrapped) ? unwrapped : {},
597
+ [region]: slot
598
+ };
599
+ }
600
+ /**
601
+ * Narrow a settings value to the `regions` map. Accepts EITHER the whole
602
+ * settings section (`{ regions: {...}, ... }`) or the `regions` map itself, and
603
+ * unwraps the former. This tolerance is deliberate: passing the whole section
604
+ * where the map was expected was a real shipped bug — the lookup then read
605
+ * `section['cn']` (absent), so the card's checkbox reported `true` forever and
606
+ * clicking it appeared to do nothing even though the write succeeded.
607
+ *
608
+ * Unwraps live references first: on DSH 0.1.7 the `regions` field arrives as a
609
+ * `{get(): T}` reference, which passes the `typeof === 'object'` check below
610
+ * and would otherwise be returned as if it were the map — making every region
611
+ * read back as absent (and the enabled flag read as "on" forever).
612
+ */
613
+ function regionsMapOf(value) {
614
+ const unwrapped = unwrapVolatileDeep(value);
615
+ if (typeof unwrapped !== "object" || unwrapped === null || Array.isArray(unwrapped)) return {};
616
+ const record = unwrapped;
617
+ const nested = record["regions"];
618
+ if (typeof nested === "object" && nested !== null && !Array.isArray(nested)) return nested;
619
+ return record;
620
+ }
621
+ /** One region's stored slot as a plain object; any other shape reads as empty. */
622
+ function regionSlotOf(value, region) {
623
+ const slot = unwrapVolatile(regionsMapOf(value)[region]);
624
+ return typeof slot === "object" && slot !== null && !Array.isArray(slot) ? slot : {};
625
+ }
626
+ /**
627
+ * Whether one region's provider is switched on. Opt-out semantics: only an
628
+ * explicit `false` disables it, so a config written before this switch existed
629
+ * (and the pre-region-split flat fields, which never carry `enabled`) keep both
630
+ * providers running exactly as before. The Host reads the same rule through
631
+ * `regionStateOf`, so card and Host can never disagree about a region's state.
632
+ *
633
+ * `value` may be the whole settings section or the `regions` map (see
634
+ * {@link regionsMapOf}).
635
+ */
636
+ function regionEnabledOf(value, region) {
637
+ return regionSlotOf(value, region)["enabled"] !== false;
638
+ }
639
+ /**
640
+ * Build the next `regions` settings value for a provider on/off toggle. ONLY
641
+ * the target region's `enabled` flag changes: every other field of that slot
642
+ * (its directory, selection, image opt-ins, context budgets) and every other
643
+ * region's slot are carried over verbatim, so switching a provider off never
644
+ * discards the user's model picks and switching it back on restores them.
645
+ *
646
+ * This is deliberately separate from {@link nextRegionSlots}: that helper
647
+ * writes a whole slot from a signed-in tab's draft, while this one must work
648
+ * for a region that is signed OUT — which is precisely the region a user wants
649
+ * to switch off (no international install, no international account).
650
+ *
651
+ * `value` may be the whole settings section or the `regions` map; the RETURN
652
+ * value is always the `regions` map, i.e. exactly what `settingsScope.set(
653
+ * 'regions', ...)` needs.
654
+ */
655
+ function nextRegionEnabled(value, region, enabled) {
656
+ return nextRegionSlots(regionsMapOf(value), region, {
657
+ ...regionSlotOf(value, region),
658
+ enabled
659
+ });
660
+ }
661
+ //#endregion
455
662
  //#region src/decrypt.ts
456
663
  const TRAE_AUTH_STORAGE_KEY = "iCubeAuthInfo://icube.cloudide";
457
664
  const SALT_A = Uint8Array.from([
@@ -1171,13 +1378,52 @@ var TraeCredentialStore = class {
1171
1378
  return this.edition === "auto" ? all.filter((candidate) => candidate.source === "desktop" || candidate.edition === cliEdition) : all.filter((candidate) => candidate.edition === this.edition && (candidate.source === "desktop" || candidate.edition === cliEdition));
1172
1379
  }
1173
1380
  /**
1174
- * Deterministic default when no account is explicitly selected: the first
1175
- * discovered account. This is NOT credit-seeking — it never reorders accounts
1176
- * to find one with general credits. The plugin bills exactly the account the
1177
- * user selected, or the first account when nothing has been selected yet.
1381
+ * Deterministic default when no account is explicitly selected: the account
1382
+ * the Trae desktop app is signed in to RIGHT NOW, else the first discovered
1383
+ * one. This is NOT credit-seeking — it never reorders accounts to find one
1384
+ * with general credits. The plugin bills exactly the account the user
1385
+ * selected, the app's current sign-in when nothing has been selected, and
1386
+ * only then the first account.
1387
+ *
1388
+ * The app's live sign-in outranks the plugin's own refreshed copies (issue
1389
+ * #29): after the user switches accounts in Trae, the desktop storage file
1390
+ * holds the NEW login while the plugin's copy of the old one still resolves,
1391
+ * so a bare `credentials[0]` kept defaulting to a sign-in the app has
1392
+ * abandoned — and `dsh-connect-workbuddy`, whose `preferred()` ranks the live
1393
+ * file first, followed the app instead. Ordering only: the VALUE behind an
1394
+ * account id is still whichever copy expires later (`readAll`), so a refresh
1395
+ * the plugin performed is never discarded by this preference.
1178
1396
  */
1179
1397
  preferred(credentials) {
1180
- return credentials[0];
1398
+ return credentials.find((credential) => credential.source === "desktop") ?? credentials[0];
1399
+ }
1400
+ /**
1401
+ * The account the Trae app is signed in to right now, when that differs from
1402
+ * the account this store is bound to (issue #29).
1403
+ *
1404
+ * This is a READ, not a rebind: `current()` keeps resolving the bound
1405
+ * account verbatim so billing never moves without a decision — the store
1406
+ * only reports the mismatch, and the card (hint row) or the host (follow
1407
+ * switch) turns it into a settings write the user can see and undo. Absent
1408
+ * when nothing is live, when the live account IS the bound one, or when the
1409
+ * binding itself already fell through to the live account.
1410
+ */
1411
+ async liveLogin() {
1412
+ if (this.accountId === void 0 || this.accountId === "") return void 0;
1413
+ const credentials = await this.readAll();
1414
+ const live = this.preferred(credentials);
1415
+ if (live === void 0) return void 0;
1416
+ const liveId = traeAccountId(live);
1417
+ if (liveId === this.accountId) return void 0;
1418
+ return {
1419
+ id: liveId,
1420
+ accountName: live.accountName ?? (live.userId || `${live.edition} account`),
1421
+ edition: live.edition,
1422
+ region: regionOfCredential(live),
1423
+ source: live.source,
1424
+ tokenExpiresAtMs: live.expiresAtMs,
1425
+ selected: false
1426
+ };
1181
1427
  }
1182
1428
  async accounts() {
1183
1429
  const credentials = await this.readAll();
@@ -1401,26 +1647,35 @@ var TraeCredentialStore = class {
1401
1647
  };
1402
1648
  //#endregion
1403
1649
  //#region src/refresh.ts
1404
- const REFRESH_CONTRACT = {
1650
+ /** Path of the current token-exchange endpoint, relative to the credential host. */
1651
+ const TRAE_EXCHANGE_TOKEN_PATH = "/trae/api/v3/oauth/ExchangeToken";
1652
+ /** Path of the superseded endpoint; only reachable as the fallback. */
1653
+ const TRAE_LEGACY_EXCHANGE_TOKEN_PATH = "/cloudide/api/v3/trae/oauth/ExchangeToken";
1654
+ /** Shared desktop ClientID; the current contract also uses it for `cn` / `sg`. */
1655
+ const DESKTOP_CLIENT_ID = "ono9krqynydwx5";
1656
+ /** SOLO ClientID; every SOLO package uses it, CN and international alike. */
1657
+ const SOLO_CLIENT_ID = "en1oxy7wnw8j9n";
1658
+ /** The current contract: `/trae/api/v3`, per-edition ClientID, always proofed. */
1659
+ const CURRENT_CONTRACT = {
1405
1660
  cn: {
1406
- path: "/cloudide/api/v3/trae/oauth/ExchangeToken",
1407
- clientId: "ono9krqynydwx5",
1408
- deviceInfo: false
1661
+ path: TRAE_EXCHANGE_TOKEN_PATH,
1662
+ clientId: DESKTOP_CLIENT_ID,
1663
+ platformCode: "IDE_PC"
1409
1664
  },
1410
1665
  sg: {
1411
- path: "/cloudide/api/v3/trae/oauth/ExchangeToken",
1412
- clientId: "ono9krqynydwx5",
1413
- deviceInfo: false
1666
+ path: TRAE_EXCHANGE_TOKEN_PATH,
1667
+ clientId: DESKTOP_CLIENT_ID,
1668
+ platformCode: "IDE_PC"
1414
1669
  },
1415
1670
  solo: {
1416
- path: "/cloudide/api/v3/trae/oauth/ExchangeToken",
1417
- clientId: "ono9krqynydwx5",
1418
- deviceInfo: false
1671
+ path: TRAE_EXCHANGE_TOKEN_PATH,
1672
+ clientId: SOLO_CLIENT_ID,
1673
+ platformCode: "SOLO_PC"
1419
1674
  },
1420
1675
  "solo-sg": {
1421
- path: "/trae/api/v3/oauth/ExchangeToken",
1422
- clientId: "en1oxy7wnw8j9n",
1423
- deviceInfo: true
1676
+ path: TRAE_EXCHANGE_TOKEN_PATH,
1677
+ clientId: SOLO_CLIENT_ID,
1678
+ platformCode: "SOLO_PC"
1424
1679
  }
1425
1680
  };
1426
1681
  function normalizeHost(host) {
@@ -1428,49 +1683,173 @@ function normalizeHost(host) {
1428
1683
  if (value === "") throw new Error("Trae refresh host is missing");
1429
1684
  return value.replace(/\/$/, "");
1430
1685
  }
1686
+ /** `OSInfo` value the official client sends (a bare platform word). */
1687
+ function osInfo(platform = process.platform) {
1688
+ return platform === "darwin" ? "mac" : platform === "win32" ? "windows" : platform;
1689
+ }
1431
1690
  /**
1432
- * Exchange a refresh token for a fresh access token, following the calling
1433
- * edition's verified contract. `device` is only used by editions whose
1434
- * official client sends a DeviceInfo body; when it cannot be resolved the
1435
- * field is omitted rather than sent empty.
1691
+ * Build the exact string the official client signs.
1692
+ *
1693
+ * The separator is a NEWLINE, not a space: the shipped bundle joins the six
1694
+ * fields with `.join("\n")`. This is load-bearing rather than cosmetic — a
1695
+ * space-joined message of identical fields is rejected with `20403 Token device
1696
+ * not match`, which is indistinguishable from a wrong key and sends debugging
1697
+ * in entirely the wrong direction.
1436
1698
  */
1437
- async function refreshTraeCredential(credential, signal, device) {
1438
- const contract = REFRESH_CONTRACT[credential.edition];
1439
- if (contract === void 0) throw new Error(`Trae ${credential.edition} refresh contract is not verified`);
1440
- if (credential.refreshToken === void 0) throw new Error("Trae refresh token is missing");
1441
- const body = {
1442
- ClientID: contract.clientId,
1443
- ClientSecret: "-",
1444
- RefreshToken: credential.refreshToken,
1445
- UserID: credential.userId
1699
+ function traeDeviceProofMessage(input) {
1700
+ return [
1701
+ "POST",
1702
+ input.path,
1703
+ input.clientId,
1704
+ input.refreshToken,
1705
+ String(input.timestamp),
1706
+ input.nonce
1707
+ ].join("\n");
1708
+ }
1709
+ /**
1710
+ * Sign a device proof with the install's own private key.
1711
+ *
1712
+ * `timestamp`/`nonce` are injectable so tests can pin a deterministic signature;
1713
+ * the defaults are the whole-second timestamp and 16 random bytes the official
1714
+ * client uses.
1715
+ */
1716
+ function signTraeDeviceProof(input) {
1717
+ const timestamp = input.timestamp ?? Math.floor(Date.now() / 1e3);
1718
+ const nonce = input.nonce ?? randomBytes(16).toString("hex");
1719
+ const message = traeDeviceProofMessage({
1720
+ path: input.path,
1721
+ clientId: input.clientId,
1722
+ refreshToken: input.refreshToken,
1723
+ timestamp,
1724
+ nonce
1725
+ });
1726
+ return {
1727
+ Signature: sign("sha256", Buffer.from(message), input.privateKeyPEM).toString("base64"),
1728
+ Timestamp: timestamp,
1729
+ Nonce: nonce
1446
1730
  };
1447
- if (contract.deviceInfo && device !== void 0) body["DeviceInfo"] = {
1731
+ }
1732
+ /** The `DeviceInfo` block the current contract sends. */
1733
+ function traeDeviceInfoBody(contract, device, platform) {
1734
+ const os = osInfo(platform);
1735
+ return {
1448
1736
  DeviceID: device.deviceId,
1449
1737
  MachineID: device.machineId,
1450
- PlatformCode: credential.edition === "solo-sg" ? "SOLO_PC" : "TRAE",
1738
+ PlatformCode: contract.platformCode,
1451
1739
  DeviceType: "PC",
1452
- DeviceName: hostname()
1740
+ ...device.deviceName === void 0 ? {} : { DeviceName: device.deviceName },
1741
+ ...device.deviceModel === void 0 ? {} : { DeviceModel: device.deviceModel },
1742
+ ...device.appVersion === void 0 ? {} : { ClientVersion: device.appVersion },
1743
+ ...device.deviceKeyPair === void 0 ? {} : { DevicePublicKey: device.deviceKeyPair.publicKeyPEM },
1744
+ ...device.deviceBrand === void 0 ? {} : { DeviceBrand: device.deviceBrand },
1745
+ ...device.deviceCpu === void 0 ? {} : { DeviceCPU: device.deviceCpu },
1746
+ OSInfo: os,
1747
+ OSVersion: device.osVersion ?? `${os} ${hostname()}`
1453
1748
  };
1454
- const response = await fetch(`${normalizeHost(credential.host)}${contract.path}`, {
1455
- method: "POST",
1456
- headers: { "Content-Type": "application/json" },
1457
- body: JSON.stringify(body),
1458
- signal: signal ?? AbortSignal.timeout(3e4)
1459
- });
1460
- if (!response.ok) throw new Error(`Trae token refresh failed (http ${response.status})`);
1461
- const result = (await response.json()).Result;
1749
+ }
1750
+ /** The current contract's complete request body. */
1751
+ function currentBody(credential, contract, device) {
1752
+ return {
1753
+ ClientID: contract.clientId,
1754
+ ClientSecret: "",
1755
+ RefreshToken: credential.refreshToken,
1756
+ DeviceInfo: traeDeviceInfoBody(contract, device),
1757
+ DeviceProof: signTraeDeviceProof({
1758
+ path: contract.path,
1759
+ clientId: contract.clientId,
1760
+ refreshToken: credential.refreshToken ?? "",
1761
+ privateKeyPEM: device.deviceKeyPair.privateKeyPEM
1762
+ }),
1763
+ ...device.appVersion === void 0 ? {} : { IDEVersion: device.appVersion }
1764
+ };
1765
+ }
1766
+ /**
1767
+ * The superseded shape, kept as a fallback for installs the current contract
1768
+ * cannot serve: a machine whose `storage.json` carries no device keypair (an
1769
+ * older install that has never refreshed on the new contract, or a CLI-only
1770
+ * home) has nothing to sign a proof with.
1771
+ *
1772
+ * The ClientID split is the SAME one the current contract uses, and that is not
1773
+ * a coincidence — it is enforced by the OAuth CLIENT, not by the endpoint: a
1774
+ * SOLO-package refresh token is refused by the DESKTOP client with `10101
1775
+ * refresh token is not matched to the client`, and vice versa, on this path
1776
+ * exactly as on the current one. Verified 2026-10-11 across all four editions:
1777
+ * each answers 200 here with its own ClientID and 400 with the other one.
1778
+ *
1779
+ * The pre-3.1.0 code hardcoded the desktop ClientID on this path, which is why
1780
+ * an international SOLO account had no working fallback at all.
1781
+ */
1782
+ function legacyBody(credential, clientId) {
1783
+ return {
1784
+ ClientID: clientId,
1785
+ ClientSecret: "-",
1786
+ RefreshToken: credential.refreshToken,
1787
+ UserID: credential.userId
1788
+ };
1789
+ }
1790
+ /** Parse one ExchangeToken response into an outcome, or throw with the reason. */
1791
+ function outcomeOf(payload, label) {
1792
+ const result = payload.Result;
1462
1793
  const accessToken = typeof result?.["Token"] === "string" ? result["Token"] : "";
1463
- if (accessToken === "") throw new Error("Trae token refresh returned no token");
1794
+ if (accessToken === "") throw new Error(`${label} returned no token`);
1464
1795
  const expiry = result?.["TokenExpireAt"];
1465
1796
  const expiresAtMs = typeof expiry === "number" ? expiry : typeof expiry === "string" ? Date.parse(expiry) : NaN;
1466
- if (!Number.isFinite(expiresAtMs)) throw new Error("Trae token refresh returned an invalid expiry");
1797
+ if (!Number.isFinite(expiresAtMs)) throw new Error(`${label} returned an invalid expiry`);
1467
1798
  const refreshToken = typeof result?.["RefreshToken"] === "string" && result["RefreshToken"] !== "" ? result["RefreshToken"] : void 0;
1799
+ const refreshExpiry = result?.["RefreshExpireAt"];
1800
+ const parsedRefreshExpiry = typeof refreshExpiry === "string" ? Date.parse(refreshExpiry) : NaN;
1801
+ const refreshExpiresAtMs = typeof refreshExpiry === "number" && Number.isFinite(refreshExpiry) && refreshExpiry > 0 ? refreshExpiry : Number.isFinite(parsedRefreshExpiry) ? parsedRefreshExpiry : void 0;
1468
1802
  return {
1469
1803
  accessToken,
1470
1804
  ...refreshToken === void 0 ? {} : { refreshToken },
1471
- expiresAtMs
1805
+ expiresAtMs,
1806
+ ...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs }
1472
1807
  };
1473
1808
  }
1809
+ /** POST one refresh body and parse it, labelling failures with the path tried. */
1810
+ async function postExchangeToken(host, path, body, signal, label) {
1811
+ const response = await fetch(`${normalizeHost(host)}${path}`, {
1812
+ method: "POST",
1813
+ headers: { "Content-Type": "application/json" },
1814
+ body: JSON.stringify(body),
1815
+ signal: signal ?? AbortSignal.timeout(3e4)
1816
+ });
1817
+ if (!response.ok) throw new Error(`${label} failed (http ${response.status})`);
1818
+ return outcomeOf(await response.json(), label);
1819
+ }
1820
+ /**
1821
+ * Exchange a refresh token for a fresh access token.
1822
+ *
1823
+ * The CURRENT contract is attempted first whenever it is possible at all — it is
1824
+ * what the shipped clients use. It needs the install's device keypair; without
1825
+ * one there is nothing to sign, so the request is skipped rather than sent to be
1826
+ * rejected with `20405`.
1827
+ *
1828
+ * The SUPERSEDED contract is then tried as a fallback, which is what keeps older
1829
+ * installs and CLI-only machines working; it carries no device block but the
1830
+ * SAME per-edition ClientID, because that split belongs to the OAuth client
1831
+ * rather than to the endpoint (see {@link legacyBody}). When both are impossible
1832
+ * or both fail, the error names every attempt so a "sign in again" verdict is
1833
+ * diagnosable rather than mysterious.
1834
+ */
1835
+ async function refreshTraeCredential(credential, signal, device) {
1836
+ const contract = CURRENT_CONTRACT[credential.edition];
1837
+ if (contract === void 0) throw new Error(`Trae ${credential.edition} refresh contract is not verified`);
1838
+ if (credential.refreshToken === void 0) throw new Error("Trae refresh token is missing");
1839
+ const attempts = [];
1840
+ if (device?.deviceKeyPair === void 0) attempts.push(`${contract.path}: no device keypair in this install's storage`);
1841
+ else try {
1842
+ return await postExchangeToken(credential.host, contract.path, currentBody(credential, contract, device), signal, "Trae token refresh");
1843
+ } catch (error) {
1844
+ attempts.push(`${contract.path}: ${error instanceof Error ? error.message : String(error)}`);
1845
+ }
1846
+ try {
1847
+ return await postExchangeToken(credential.host, TRAE_LEGACY_EXCHANGE_TOKEN_PATH, legacyBody(credential, contract.clientId), signal, "Trae token refresh (legacy)");
1848
+ } catch (error) {
1849
+ attempts.push(`${TRAE_LEGACY_EXCHANGE_TOKEN_PATH}: ${error instanceof Error ? error.message : String(error)}`);
1850
+ }
1851
+ throw new Error(`Trae token refresh failed — ${attempts.join("; ")}`);
1852
+ }
1474
1853
  //#endregion
1475
1854
  //#region src/identity.ts
1476
1855
  function nonEmpty(value) {
@@ -1481,6 +1860,33 @@ function deviceCenterId(storage) {
1481
1860
  const ids = Object.keys(storage).filter((key) => key.startsWith(prefix)).map((key) => key.slice(25)).filter(Boolean);
1482
1861
  return ids.length === 1 ? ids[0] : void 0;
1483
1862
  }
1863
+ /**
1864
+ * Read the install's device keypair out of `storage.json`.
1865
+ *
1866
+ * Tolerant on purpose: an older install (or one that has never refreshed on the
1867
+ * new contract) simply has no keypair, and the caller must still be able to
1868
+ * resolve an identity for the chat/usage paths — only a refresh then degrades.
1869
+ * A malformed entry is treated as absent rather than failing the read.
1870
+ */
1871
+ function deviceKeyPairOf(storage) {
1872
+ const key = Object.keys(storage).find((name) => name.startsWith("iCubeAuthInfo://icube-dc:"));
1873
+ if (key === void 0) return void 0;
1874
+ const encoded = storage[key];
1875
+ if (typeof encoded !== "string" || encoded.trim() === "") return void 0;
1876
+ try {
1877
+ const plaintext = parseTraeAuthValue(encoded);
1878
+ if (typeof plaintext !== "object" || plaintext === null || Array.isArray(plaintext)) return void 0;
1879
+ const value = plaintext;
1880
+ const privateKeyPEM = nonEmpty(value["privateKeyPEM"]);
1881
+ const publicKeyPEM = nonEmpty(value["publicKeyPEM"]);
1882
+ return privateKeyPEM === void 0 || publicKeyPEM === void 0 ? void 0 : {
1883
+ privateKeyPEM,
1884
+ publicKeyPEM
1885
+ };
1886
+ } catch {
1887
+ return;
1888
+ }
1889
+ }
1484
1890
  /** Read stable identity from Trae-owned files without generating impersonated IDs. */
1485
1891
  async function readTraeIdentity(candidate, options = {}) {
1486
1892
  const platform = options.platform ?? process.platform;
@@ -1519,6 +1925,15 @@ async function readTraeIdentity(candidate, options = {}) {
1519
1925
  const deviceBrand = platform === "darwin" ? nonEmpty(env["TRAE_DEVICE_BRAND"]) : void 0;
1520
1926
  const deviceCpu = cpus()[0]?.model.split(" ")[0];
1521
1927
  const osVersion = `${platform === "darwin" ? "macOS" : platform === "win32" ? "Windows" : platform} ${release()}`;
1928
+ const deviceName = (() => {
1929
+ try {
1930
+ return nonEmpty(userInfo().username);
1931
+ } catch {
1932
+ return;
1933
+ }
1934
+ })();
1935
+ const deviceModel = nonEmpty(env["TRAE_DEVICE_MODEL"]);
1936
+ const deviceKeyPair = deviceKeyPairOf(storage);
1522
1937
  return {
1523
1938
  edition: candidate.edition,
1524
1939
  machineId,
@@ -1527,6 +1942,9 @@ async function readTraeIdentity(candidate, options = {}) {
1527
1942
  ...buildVersion === void 0 ? {} : { buildVersion },
1528
1943
  ...deviceBrand === void 0 ? {} : { deviceBrand },
1529
1944
  ...deviceCpu === void 0 ? {} : { deviceCpu },
1945
+ ...deviceName === void 0 ? {} : { deviceName },
1946
+ ...deviceModel === void 0 ? {} : { deviceModel },
1947
+ ...deviceKeyPair === void 0 ? {} : { deviceKeyPair },
1530
1948
  osVersion,
1531
1949
  platform
1532
1950
  };
@@ -3685,6 +4103,21 @@ const TRAE_PAY_BASE = "https://api.trae.cn";
3685
4103
  function asNumber(value) {
3686
4104
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
3687
4105
  }
4106
+ /**
4107
+ * Normalize an upstream epoch to milliseconds.
4108
+ *
4109
+ * The pack's `end_time` arrives in SECONDS while {@link TraeUsagePack.endTimeMs}
4110
+ * names milliseconds. Measured against a live CN account: `1788340660` is
4111
+ * 2026-09-02, and reading it as milliseconds would place the pack in 1970 — a
4112
+ * date that sorts ahead of every real one and reads as "long expired". The
4113
+ * magnitude test is the same one `auth.ts` applies to stored expiry fields, so
4114
+ * an upstream that ever switches to milliseconds is passed through instead of
4115
+ * being multiplied into the year 57000.
4116
+ */
4117
+ function epochToMs(value) {
4118
+ if (value === void 0 || !Number.isFinite(value) || value <= 0) return 0;
4119
+ return value > 0xe8d4a51000 ? value : value * 1e3;
4120
+ }
3688
4121
  function parseUsageSnapshot(payload) {
3689
4122
  const summaryRaw = payload["usage_summary"];
3690
4123
  const summary = {
@@ -3708,7 +4141,7 @@ function parseUsageSnapshot(payload) {
3708
4141
  packs.push({
3709
4142
  displayDesc: typeof pack["display_desc"] === "string" ? pack["display_desc"] : "",
3710
4143
  entitlementId: typeof base?.["entitlement_id"] === "string" ? base["entitlement_id"] : "",
3711
- endTimeMs: asNumber(base?.["end_time"]) ?? 0,
4144
+ endTimeMs: epochToMs(asNumber(base?.["end_time"])),
3712
4145
  currency: asNumber(base?.["currency"]) ?? 0,
3713
4146
  ...availableEndpoint === void 0 ? {} : { availableEndpoint },
3714
4147
  ...creditsLimit === void 0 ? {} : { creditsLimit },
@@ -3927,170 +4360,6 @@ var TraeUsageClient = class {
3927
4360
  }
3928
4361
  };
3929
4362
  //#endregion
3930
- //#region src/status-paths.ts
3931
- /** Plugin-owned usage endpoint consumed by its browser half. */
3932
- const TRAE_USAGE_PATH = "/plugins/dsh-connect-trae/usage";
3933
- /** Plugin-owned live model refresh endpoint. */
3934
- const TRAE_MODELS_REFRESH_PATH = "/plugins/dsh-connect-trae/models/refresh";
3935
- /**
3936
- * Plugin-owned availability probe: POST the model ids to test, get one verdict
3937
- * per model. Unlike every read-only sibling this route SPENDS quota — a real
3938
- * (minimal) chat call per model — so it is user-initiated only, and the card
3939
- * labels the button as consuming a small amount.
3940
- */
3941
- const TRAE_MODELS_TEST_PATH = "/plugins/dsh-connect-trae/models/test";
3942
- /** Plugin-owned local account rescan endpoint. */
3943
- const TRAE_ACCOUNTS_REFRESH_PATH = "/plugins/dsh-connect-trae/accounts/refresh";
3944
- /**
3945
- * General balance of EVERY account in a region, for the composer panel's table.
3946
- *
3947
- * Separate from the usage route on purpose: the usage document backs the
3948
- * 5-minute readout, and multiplying its upstream reads by the account count
3949
- * would charge a healthy account for something it never looks at — the same
3950
- * principle that keeps `creditAlternatives` firing only when a balance hits
3951
- * zero. This one is paid when the panel is actually opened, which is the only
3952
- * place the table is shown.
3953
- */
3954
- const TRAE_ACCOUNT_CREDITS_PATH = "/plugins/dsh-connect-trae/account-credits";
3955
- /**
3956
- * Plugin-owned daily check-in claim endpoint. POST, loopback-only, and the only
3957
- * route in this plugin that changes upstream account state.
3958
- */
3959
- const TRAE_CHECKIN_PATH = "/plugins/dsh-connect-trae/checkin";
3960
- /** Query parameter naming the region a card request addresses. */
3961
- const TRAE_REGION_PARAM = "region";
3962
- /** Every region, in card tab order. */
3963
- const TRAE_REGIONS = ["cn", "ai"];
3964
- /**
3965
- * Address one region's status route. The two regions are separate provider
3966
- * stacks; every card request carries the region whose tab the user is on.
3967
- */
3968
- function withTraeRegion(path, region) {
3969
- return `${path}?${TRAE_REGION_PARAM}=${region}`;
3970
- }
3971
- /**
3972
- * Read the region parameter off a status-route URL. Absent means the domestic
3973
- * tab (`cn`); a present-but-unknown value returns undefined so the route can
3974
- * answer 400 instead of guessing.
3975
- */
3976
- function regionOfTraeStatusUrl(url) {
3977
- const at = url.indexOf("?");
3978
- const value = at === -1 ? null : new URLSearchParams(url.slice(at + 1)).get(TRAE_REGION_PARAM);
3979
- if (value === null || value === "") return "cn";
3980
- return TRAE_REGIONS.includes(value) ? value : void 0;
3981
- }
3982
- /**
3983
- * Peel ONE `{get(): T}` live reference, the shape DSH 0.1.7 delivers a
3984
- * volatile-marked settings field as (see {@link unwrapVolatileDeep}).
3985
- */
3986
- function unwrapVolatile(value) {
3987
- if (value !== null && typeof value === "object" && typeof value.get === "function") return value.get();
3988
- return value;
3989
- }
3990
- /**
3991
- * Deep copy of a value with every `{get(): T}` live reference replaced by the
3992
- * value it resolves to.
3993
- *
3994
- * DSH 0.1.7 hands volatile-marked fields back as live references, and a live
3995
- * reference is still `typeof === 'object'` — so it passes a naive object check
3996
- * and every lookup on it is `undefined`. Worse, spreading one (`{ ...ref }`)
3997
- * does NOT read the field: it produces `{ get: <function> }`. Any merge that
3998
- * spreads a resolved field to preserve its siblings — the card's "write one
3999
- * region, keep the other" write, or the account map's spread — would otherwise
4000
- * DROP every sibling and leak a function into the document.
4001
- *
4002
- * Both halves therefore unwrap before touching a resolved field. This lives in
4003
- * the node-free host↔client bridge because the browser half needs it too.
4004
- *
4005
- * Non-reference values are recursed into so a nested volatile field is caught
4006
- * as well; arrays and objects are rebuilt rather than mutated, so the caller's
4007
- * value is never touched.
4008
- */
4009
- function unwrapVolatileDeep(value) {
4010
- if (value === null || typeof value !== "object") return value;
4011
- if (typeof value.get === "function") return unwrapVolatileDeep(value.get());
4012
- if (Array.isArray(value)) return value.map((entry) => unwrapVolatileDeep(entry));
4013
- const source = value;
4014
- const out = {};
4015
- for (const key of Object.keys(source)) out[key] = unwrapVolatileDeep(source[key]);
4016
- return out;
4017
- }
4018
- /**
4019
- * Build the next `regions` settings value for the card's save. The write
4020
- * targets ONLY the signed-in account's region slot; every other region's slot
4021
- * is carried over untouched, so switching accounts never clobbers the other
4022
- * region's picks. Tolerates any stored shape (absent, non-object) by starting
4023
- * from an empty document.
4024
- */
4025
- function nextRegionSlots(regions, region, slot) {
4026
- const unwrapped = unwrapVolatileDeep(regions);
4027
- return {
4028
- ...typeof unwrapped === "object" && unwrapped !== null && !Array.isArray(unwrapped) ? unwrapped : {},
4029
- [region]: slot
4030
- };
4031
- }
4032
- /**
4033
- * Narrow a settings value to the `regions` map. Accepts EITHER the whole
4034
- * settings section (`{ regions: {...}, ... }`) or the `regions` map itself, and
4035
- * unwraps the former. This tolerance is deliberate: passing the whole section
4036
- * where the map was expected was a real shipped bug — the lookup then read
4037
- * `section['cn']` (absent), so the card's checkbox reported `true` forever and
4038
- * clicking it appeared to do nothing even though the write succeeded.
4039
- *
4040
- * Unwraps live references first: on DSH 0.1.7 the `regions` field arrives as a
4041
- * `{get(): T}` reference, which passes the `typeof === 'object'` check below
4042
- * and would otherwise be returned as if it were the map — making every region
4043
- * read back as absent (and the enabled flag read as "on" forever).
4044
- */
4045
- function regionsMapOf(value) {
4046
- const unwrapped = unwrapVolatileDeep(value);
4047
- if (typeof unwrapped !== "object" || unwrapped === null || Array.isArray(unwrapped)) return {};
4048
- const record = unwrapped;
4049
- const nested = record["regions"];
4050
- if (typeof nested === "object" && nested !== null && !Array.isArray(nested)) return nested;
4051
- return record;
4052
- }
4053
- /** One region's stored slot as a plain object; any other shape reads as empty. */
4054
- function regionSlotOf(value, region) {
4055
- const slot = unwrapVolatile(regionsMapOf(value)[region]);
4056
- return typeof slot === "object" && slot !== null && !Array.isArray(slot) ? slot : {};
4057
- }
4058
- /**
4059
- * Whether one region's provider is switched on. Opt-out semantics: only an
4060
- * explicit `false` disables it, so a config written before this switch existed
4061
- * (and the pre-region-split flat fields, which never carry `enabled`) keep both
4062
- * providers running exactly as before. The Host reads the same rule through
4063
- * `regionStateOf`, so card and Host can never disagree about a region's state.
4064
- *
4065
- * `value` may be the whole settings section or the `regions` map (see
4066
- * {@link regionsMapOf}).
4067
- */
4068
- function regionEnabledOf(value, region) {
4069
- return regionSlotOf(value, region)["enabled"] !== false;
4070
- }
4071
- /**
4072
- * Build the next `regions` settings value for a provider on/off toggle. ONLY
4073
- * the target region's `enabled` flag changes: every other field of that slot
4074
- * (its directory, selection, image opt-ins, context budgets) and every other
4075
- * region's slot are carried over verbatim, so switching a provider off never
4076
- * discards the user's model picks and switching it back on restores them.
4077
- *
4078
- * This is deliberately separate from {@link nextRegionSlots}: that helper
4079
- * writes a whole slot from a signed-in tab's draft, while this one must work
4080
- * for a region that is signed OUT — which is precisely the region a user wants
4081
- * to switch off (no international install, no international account).
4082
- *
4083
- * `value` may be the whole settings section or the `regions` map; the RETURN
4084
- * value is always the `regions` map, i.e. exactly what `settingsScope.set(
4085
- * 'regions', ...)` needs.
4086
- */
4087
- function nextRegionEnabled(value, region, enabled) {
4088
- return nextRegionSlots(regionsMapOf(value), region, {
4089
- ...regionSlotOf(value, region),
4090
- enabled
4091
- });
4092
- }
4093
- //#endregion
4094
4363
  //#region src/web-status.ts
4095
4364
  /**
4096
4365
  * The upstream's business code for "该设备今日已参与签到" — this DEVICE already
@@ -4149,6 +4418,32 @@ async function readModelTestBody(req) {
4149
4418
  }
4150
4419
  return ids;
4151
4420
  }
4421
+ /**
4422
+ * Read the accountId a per-account route needs from a POST body. A bounded,
4423
+ * single-string reader — the same families of guards the model-test body uses,
4424
+ * because the claim route here is a state-changing endpoint.
4425
+ */
4426
+ async function readAccountBody(req) {
4427
+ const chunks = [];
4428
+ let size = 0;
4429
+ for await (const chunk of req) {
4430
+ const buffer = chunk;
4431
+ size += buffer.byteLength;
4432
+ if (size > 16384) throw new Error("request body too large");
4433
+ chunks.push(buffer);
4434
+ }
4435
+ const text = Buffer.concat(chunks).toString("utf8").trim();
4436
+ if (text === "") throw new Error("empty body");
4437
+ let parsed;
4438
+ try {
4439
+ parsed = JSON.parse(text);
4440
+ } catch {
4441
+ throw new Error("invalid JSON body");
4442
+ }
4443
+ const accountId = typeof parsed === "object" && parsed !== null ? parsed["accountId"] : void 0;
4444
+ if (typeof accountId !== "string" || accountId.trim() === "") throw new Error("body must carry an accountId string");
4445
+ return accountId.trim();
4446
+ }
4152
4447
  /** Loopback browser origins only; other devices are refused until trusted origins exist. */
4153
4448
  function loopbackOrigin(req) {
4154
4449
  const origin = req.headers.origin;
@@ -4174,7 +4469,8 @@ function toCredits(snapshot) {
4174
4469
  const accounts = snapshot.packs.map((pack) => ({
4175
4470
  displayDesc: pack.displayDesc,
4176
4471
  remain: credit(Math.max(0, (pack.creditsLimit ?? 0) - (pack.consumedCredits ?? 0))),
4177
- size: pack.creditsLimit ?? 0
4472
+ size: pack.creditsLimit ?? 0,
4473
+ ...typeof pack.endTimeMs === "number" && Number.isFinite(pack.endTimeMs) && pack.endTimeMs > 0 ? { expiresAtMs: pack.endTimeMs } : {}
4178
4474
  }));
4179
4475
  const remaining = (endpoint) => snapshot.packs.filter((pack) => pack.availableEndpoint === endpoint).reduce((sum, pack) => credit(sum + Math.max(0, (pack.creditsLimit ?? 0) - (pack.consumedCredits ?? 0))), 0);
4180
4476
  return {
@@ -4233,6 +4529,10 @@ async function traeWebUsage(deps, region) {
4233
4529
  message: safeMessage(error)
4234
4530
  };
4235
4531
  }
4532
+ let appLogin;
4533
+ try {
4534
+ appLogin = await deps.liveLogin?.(region);
4535
+ } catch {}
4236
4536
  const account = {
4237
4537
  accountId: accounts.find((item) => item.selected)?.id ?? "",
4238
4538
  accountName: credential.accountName ?? credential.userId,
@@ -4240,6 +4540,7 @@ async function traeWebUsage(deps, region) {
4240
4540
  region,
4241
4541
  enabled,
4242
4542
  accounts,
4543
+ ...appLogin === void 0 ? {} : { appLogin },
4243
4544
  models: deps.displayModels(region).map((model) => ({
4244
4545
  ...model,
4245
4546
  ...model.input === void 0 ? {} : { input: [...model.input] }
@@ -4395,6 +4696,33 @@ function registerTraeUsageRoute(ctx, deps) {
4395
4696
  }
4396
4697
  }
4397
4698
  });
4699
+ /**
4700
+ * Per-account status for one row of the card's account table (CN:
4701
+ * check-in + balances; ai: subscription). Read-only, one upstream call per
4702
+ * account, fetched once per table render. `accountId` rides in the query
4703
+ * (a GET, like every other read-only sibling). Absent `accountStateOf`
4704
+ * the route answers 503 so the table degrades to bare rows.
4705
+ */
4706
+ const disposeAccountStatus = ctx.webServer.register({
4707
+ kind: "exact",
4708
+ path: TRAE_ACCOUNT_STATUS_PATH,
4709
+ handler: async (req, res) => {
4710
+ if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
4711
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
4712
+ if (deps.accountStateOf === void 0) return json(res, 503, { error: "account status unavailable" });
4713
+ const region = requestRegion(req, res);
4714
+ if (region === void 0) return;
4715
+ const accountId = new URL(req.url ?? "/", "http://localhost").searchParams.get("accountId");
4716
+ if (accountId === null || accountId.trim() === "") return json(res, 400, { error: "missing accountId" });
4717
+ try {
4718
+ const state = await deps.accountStateOf(region, accountId);
4719
+ if (state === void 0) return json(res, 404, { error: "unknown account" });
4720
+ json(res, 200, state);
4721
+ } catch (error) {
4722
+ json(res, 500, { error: safeMessage(error) });
4723
+ }
4724
+ }
4725
+ });
4398
4726
  const disposeAccounts = ctx.webServer.register({
4399
4727
  kind: "exact",
4400
4728
  path: TRAE_ACCOUNTS_REFRESH_PATH,
@@ -4490,6 +4818,27 @@ function registerTraeUsageRoute(ctx, deps) {
4490
4818
  if (region === void 0) return;
4491
4819
  if (region !== "cn") return json(res, 404, { error: "check-in is not available for the international region" });
4492
4820
  try {
4821
+ let accountId;
4822
+ try {
4823
+ accountId = await readAccountBody(req);
4824
+ } catch {
4825
+ accountId = void 0;
4826
+ }
4827
+ if (accountId !== void 0) {
4828
+ if (deps.claimCheckinOf === void 0) return json(res, 503, { error: "check-in by account unavailable" });
4829
+ const outcome = await deps.claimCheckinOf(region, accountId);
4830
+ if (outcome === void 0) return json(res, 404, { error: "unknown account" });
4831
+ const { claim, checkin: after } = outcome;
4832
+ const deviceClaimed = claim.code === CHECKIN_DEVICE_ALREADY_CLAIMED;
4833
+ return json(res, 200, {
4834
+ claimed: claim.claimed,
4835
+ alreadyCheckedIn: after.checkedIn,
4836
+ ...deviceClaimed ? { deviceCheckedIn: true } : {},
4837
+ code: claim.code,
4838
+ message: claim.message,
4839
+ checkin: toCheckin(after)
4840
+ });
4841
+ }
4493
4842
  const client = deps.client(region);
4494
4843
  const current = await client.checkinStatus();
4495
4844
  if (!current.enabled) return json(res, 409, { error: "check-in is not enabled for this account" });
@@ -4529,6 +4878,7 @@ function registerTraeUsageRoute(ctx, deps) {
4529
4878
  disposeModelsTest();
4530
4879
  disposeAccounts();
4531
4880
  disposeAccountCredits();
4881
+ disposeAccountStatus();
4532
4882
  disposeUsage();
4533
4883
  };
4534
4884
  }, "dsh-connect-trae: Web usage route");
@@ -4837,6 +5187,7 @@ const Config = z.object({
4837
5187
  ]).default("auto").description("Trae edition hint")),
4838
5188
  accountId: z.string().description("Deprecated: pre-split account selector, attributed to its own region"),
4839
5189
  showPointsInMainUi: asVolatile(z.boolean().default(false).description("在输入框工具栏显示积分读数「Trae CN · <数值>」(仅当选中的模型属于本插件时出现,可点击查看账号与积分明细)")),
5190
+ followAppLogin: asVolatile(z.boolean().default(false).description("跟随 Trae 桌面端当前登录账号换绑(打开后每次配置生效时重绑到 App 现在登录的账号)")),
4840
5191
  accounts: asVolatile(accountSelectionConfig.description("Per-region account selections, keyed cn | ai")),
4841
5192
  regions: asVolatile(z.dict(regionStateConfig).default({}).description("Per-region model directory and selection, keyed cn | ai")),
4842
5193
  lastCatalog: z.array(modelConfig).description("Deprecated: pre-region-split CN model directory"),
@@ -4949,16 +5300,27 @@ function apply(ctx, config) {
4949
5300
  }], hint ?? (region === "ai" ? "solo-sg" : "cn"));
4950
5301
  };
4951
5302
  /**
4952
- * Resolution failures degrade to omitting DeviceInfo (its being required
4953
- * is unverified, docs/INTL_SG_EVIDENCE.md §5) rather than failing the
4954
- * refresh.
5303
+ * The current refresh contract signs a DeviceProof with the install's own
5304
+ * registered keypair, so every field the server reads must come from the
5305
+ * SAME installation the credential belongs to.
5306
+ *
5307
+ * Resolution failures leave `deviceKeyPair` absent, and the refresh then
5308
+ * degrades to the superseded contract instead of sending an unsigned
5309
+ * request the server answers with `20405 Device proof required`.
4955
5310
  */
4956
5311
  const refreshDeviceSafe = async () => {
4957
5312
  try {
4958
5313
  const value = await identity();
4959
5314
  return {
4960
5315
  deviceId: value.deviceId,
4961
- machineId: value.machineId
5316
+ machineId: value.machineId,
5317
+ ...value.deviceName === void 0 ? {} : { deviceName: value.deviceName },
5318
+ ...value.deviceModel === void 0 ? {} : { deviceModel: value.deviceModel },
5319
+ ...value.deviceBrand === void 0 ? {} : { deviceBrand: value.deviceBrand },
5320
+ ...value.deviceCpu === void 0 ? {} : { deviceCpu: value.deviceCpu },
5321
+ ...value.osVersion === void 0 ? {} : { osVersion: value.osVersion },
5322
+ ...value.appVersion === void 0 ? {} : { appVersion: value.appVersion },
5323
+ ...value.deviceKeyPair === void 0 ? {} : { deviceKeyPair: value.deviceKeyPair }
4962
5324
  };
4963
5325
  } catch {
4964
5326
  return;
@@ -5016,16 +5378,17 @@ function apply(ctx, config) {
5016
5378
  ctx.logger.warn("dsh-connect-trae: Raw gateway unavailable; continuing with native SOLO tool-call channel", error);
5017
5379
  }
5018
5380
  })();
5381
+ const usageClient = new TraeUsageClient({
5382
+ credential: () => store.resolve(),
5383
+ deviceId: async () => (await identity()).deviceId
5384
+ });
5019
5385
  stacks[region] = {
5020
5386
  region,
5021
5387
  store,
5022
5388
  catalog,
5023
5389
  shim,
5024
5390
  delegating,
5025
- usageClient: new TraeUsageClient({
5026
- credential: () => store.resolve(),
5027
- deviceId: async () => (await identity()).deviceId
5028
- }),
5391
+ usageClient,
5029
5392
  /**
5030
5393
  * Credit balance of one named account, WITHOUT rebinding the store
5031
5394
  * (issue #25). The card asks this only after it saw the bound account
@@ -5042,6 +5405,39 @@ function apply(ctx, config) {
5042
5405
  if (credential === void 0) return void 0;
5043
5406
  return toCredits(await new TraeUsageClient({ credential: async () => credential }).snapshot());
5044
5407
  },
5408
+ accountStateOf: async (accountId) => {
5409
+ const state = { id: accountId };
5410
+ const selectedCredential = await store.current();
5411
+ const client = selectedCredential !== void 0 && traeAccountId(selectedCredential) === accountId ? usageClient : (() => {
5412
+ const credential = store.credentialOf(accountId);
5413
+ return new TraeUsageClient({
5414
+ credential: async () => credential,
5415
+ deviceId: async () => (await identity()).deviceId
5416
+ });
5417
+ })();
5418
+ if (region === "ai") {
5419
+ state.payStatus = await client.payStatus();
5420
+ return state;
5421
+ }
5422
+ const [snapshot, checkin] = await Promise.allSettled([client.snapshot(), client.checkinStatus()]);
5423
+ if (snapshot.status === "fulfilled") state.credits = toCredits(snapshot.value);
5424
+ if (checkin.status === "fulfilled") state.checkin = toCheckin(checkin.value);
5425
+ return state;
5426
+ },
5427
+ claimCheckinOf: async (accountId) => {
5428
+ const selectedCredential = await store.current();
5429
+ const client = selectedCredential !== void 0 && traeAccountId(selectedCredential) === accountId ? usageClient : (() => {
5430
+ const credential = store.credentialOf(accountId);
5431
+ return new TraeUsageClient({
5432
+ credential: async () => credential,
5433
+ deviceId: async () => (await identity()).deviceId
5434
+ });
5435
+ })();
5436
+ return {
5437
+ claim: await client.claimCheckin(),
5438
+ checkin: await client.checkinStatus()
5439
+ };
5440
+ },
5045
5441
  discoverModels: async (signal) => {
5046
5442
  const merged = mergeTraeModelSources(await withDirectoryRetry(() => remoteCatalog.fetchModels(signal)), await solo.fetchModels(signal));
5047
5443
  wire.callableKeys.clear();
@@ -5092,6 +5488,49 @@ function apply(ctx, config) {
5092
5488
  rawDiagnostic: () => rawDiagnostic()
5093
5489
  };
5094
5490
  }
5491
+ /**
5492
+ * The app's current sign-in when it differs from the bound account (issue
5493
+ * #29). Pure read: the store resolves the bound account verbatim, so billing
5494
+ * never moves from here — the card's hint and the follow switch below are
5495
+ * the only writers.
5496
+ */
5497
+ const liveLoginOf = async (region) => {
5498
+ try {
5499
+ const live = await stacks[region].store.liveLogin();
5500
+ if (live === void 0 || live.region !== region) return void 0;
5501
+ return {
5502
+ id: live.id,
5503
+ accountName: live.accountName,
5504
+ edition: live.edition,
5505
+ region: live.region
5506
+ };
5507
+ } catch {
5508
+ return;
5509
+ }
5510
+ };
5511
+ /**
5512
+ * Persist `accounts.<region>` = the app's live sign-in and rebind the store
5513
+ * (issue #29). The ONE Host-side path that moves the binding, driven by the
5514
+ * follow switch in {@link maybeFollowAppLogin}; the card's one-click hint
5515
+ * goes through its own settings write instead (one writer per path). Returns
5516
+ * the applied login so the caller can report what moved.
5517
+ *
5518
+ * The write goes through `settings.update` (the volatile write gate), not a
5519
+ * direct config mutation: `apply()` receives a snapshot, and the settings
5520
+ * event (`loader/volatile-update`) is what re-runs `applySelection` with the
5521
+ * updated document — the same channel every card write already uses.
5522
+ */
5523
+ const followAppLogin = async (region) => {
5524
+ const live = await liveLoginOf(region);
5525
+ if (live === void 0 || writeSettings === void 0) return void 0;
5526
+ const configured = configuredAccountsOf(current());
5527
+ if (configured[region] === live.id) return live;
5528
+ await writeSettings({ accounts: {
5529
+ ...configured,
5530
+ [region]: live.id
5531
+ } });
5532
+ return live;
5533
+ };
5095
5534
  ctx.inject(["webServer"], (webCtx) => registerTraeUsageRoute(webCtx, {
5096
5535
  store: (region) => stacks[region].store,
5097
5536
  client: (region) => stacks[region].usageClient,
@@ -5101,7 +5540,10 @@ function apply(ctx, config) {
5101
5540
  discoverModels: (region, signal) => stacks[region].discoverModels(signal),
5102
5541
  probeModels: (region, ids) => stacks[region].probeModels(ids),
5103
5542
  rawDiagnostic: (region) => stacks[region].rawDiagnostic(),
5104
- creditsOfAccount: (region, accountId) => stacks[region].creditsOfAccount(accountId)
5543
+ creditsOfAccount: (region, accountId) => stacks[region].creditsOfAccount(accountId),
5544
+ accountStateOf: (region, accountId) => stacks[region].accountStateOf(accountId),
5545
+ claimCheckinOf: (region, accountId) => stacks[region].claimCheckinOf(accountId),
5546
+ liveLogin: (region) => liveLoginOf(region)
5105
5547
  }));
5106
5548
  /**
5107
5549
  * Live registration handles per region, filled once the shim is listening.
@@ -5157,13 +5599,59 @@ function apply(ctx, config) {
5157
5599
  }
5158
5600
  } catch {}
5159
5601
  })();
5602
+ let writeSettings;
5160
5603
  ctx.inject(["settings"], (settingsCtx) => {
5161
5604
  const settings = settingsCtx.settings;
5162
5605
  ctx.effect(() => settings.configure({ auto: true }, ctx.fiber));
5606
+ writeSettings = async (patch) => {
5607
+ const revision = () => settings.describe().find((entry) => entry.ns === settingsNs)?.revision;
5608
+ const conflict = (error) => typeof error === "object" && error !== null && error.code === "SETTINGS_CONFLICT";
5609
+ try {
5610
+ await settings.update(settingsNs, patch, revision());
5611
+ } catch (error) {
5612
+ if (!conflict(error)) throw error;
5613
+ await settings.update(settingsNs, patch, revision());
5614
+ }
5615
+ };
5616
+ ctx.effect(() => () => {
5617
+ writeSettings = void 0;
5618
+ });
5163
5619
  });
5164
5620
  ctx.on("loader/volatile-update", () => {
5165
5621
  applySelection(current());
5622
+ maybeFollowAppLogin();
5166
5623
  });
5624
+ /**
5625
+ * Follow the app's sign-in on every settings change when the switch is ON
5626
+ * (issue #29). Runs AFTER `applySelection` so this pass binds the previous
5627
+ * document, reads the app's live login against it, and — if they differ —
5628
+ * writes `accounts.<region>` once; that write emits `volatile-update` again,
5629
+ * which re-runs `applySelection` on the NEW document. The loop terminates on
5630
+ * its own the moment `liveLogin()` reports no mismatch (the second pass binds
5631
+ * the live account, so `preferred` and `current` agree). The guard below
5632
+ * additionally forbids two writes in flight, so a stuck mismatch can never
5633
+ * turn into a write storm: the next settings change retries from scratch.
5634
+ *
5635
+ * The switch reads FALSE for an existing config (see {@link Config}), so a
5636
+ * user who never opted in never gets a rebind — the card's one-click hint is
5637
+ * their path instead.
5638
+ */
5639
+ let followInFlight = false;
5640
+ const maybeFollowAppLogin = async () => {
5641
+ if (followInFlight || !followAppLoginEnabled(current())) return;
5642
+ followInFlight = true;
5643
+ try {
5644
+ for (const region of REGION_KEYS) {
5645
+ if (!regionEnabled(current(), region)) continue;
5646
+ await followAppLogin(region);
5647
+ }
5648
+ } catch (error) {
5649
+ ctx.logger.warn("dsh-connect-trae: follow-app-login rebind skipped", error);
5650
+ } finally {
5651
+ followInFlight = false;
5652
+ }
5653
+ };
5654
+ maybeFollowAppLogin();
5167
5655
  let stopped = false;
5168
5656
  ctx.effect(() => () => {
5169
5657
  stopped = true;
@@ -5245,4 +5733,4 @@ function apply(ctx, config) {
5245
5733
  });
5246
5734
  }
5247
5735
  //#endregion
5248
- export { Config, FALLBACK_TRAE_MODELS, FALLBACK_TRAE_MODELS_AI, REGION_GATEWAYS, SseDecoder, TRAE_AI_PROVIDER, TRAE_CHECKIN_PATH, TRAE_CN_AGENT_TASK_PATH, TRAE_CN_TITLE_PATH, TRAE_MODELS_TEST_PATH, TRAE_MODEL_DETAIL_FUNCTIONS, TRAE_MODEL_DETAIL_PATH, TRAE_PAY_BASE, TRAE_PROBE_TIMEOUT_MS, TRAE_PROVIDER, TRAE_PROVIDERS, TRAE_PROVIDER_DISPLAY_NAMES, TRAE_RAW_CHAT_V1_PATH, TRAE_RAW_CHAT_V2_PATH, TRAE_REASONING_EFFORTS, TRAE_REGIONS, TRAE_REGION_PARAM, TRAE_SETTINGS_NS, TRAE_SOLO_CHAT_PATH, TRAE_SOLO_FUNCTION, TRAE_SOLO_MODELS_PATH, TRAE_SOLO_REMOTE_BASE, TRAE_STATE_DB_FILENAME, TRAE_STREAM_IDLE_TIMEOUT_MS, TRAE_USAGE_PATH, TRAE_VERSION_CODE_FALLBACK, TraeCatalog, TraeCredentialStore, TraeDelegatingUpstreamClient, TraeFallbackUpstreamClient, TraeGatedUpstreamClient, TraeRawCapabilityController, TraeRawCapabilityState, TraeRawChatUpstreamClient, TraeSoloBridge, TraeSoloRemoteCatalogClient, TraeSoloUpstreamClient, TraeUsageClient, UnconfiguredTraeUpstreamClient, apply, applyContextBudgets, applyImageSelection, applyReasoningEffort, asVolatile, bridgeTraeSoloStream, buildTraeAgentTaskBody, buildTraeCnHeaders, buildTraeFusionRawChatEnvelope, buildTraeModelDetailRequest, buildTraeRawChatDraft, buildTraeRawChatRuntimeConfig, classifyProbeError, classifyProbeResult, classifyTraeRawChatFailure, createTraeAdapter, createTraeRawGateway, createTraeShim, decodeRawChatChunk, decodeTraeEvent, decryptTraeStorageValue, deriveCatalog, describeIdShape, describeNameShape, discoveredCatalog, fallbackModelsFor, hashTraeRawChatArg, identityHeaders, inject, legacyTraeOwnAuthPath, maskUserPath, mergeTraeModelSources, name, nextRegionEnabled, nextRegionSlots, normalizeTraeCredential, normalizeTraeVersionCode, parseObservedModelConfig, parseReasoningCapability, parseTraeAuthValue, parseTraeCachedModel, parseTraeModelExtraConfigLogLine, parseTraeRawChatBehaviorConfig, parseTraeRemoteModel, parseTraeStorageDocument, pickTraeStorageIdentity, prepareSoloBody, probeBody, probeModelsSequentially, probeOneModel, probeTraeRawChatCapability, rawCapabilityDiagnostic, rawCapabilityFingerprint, readTraeCachedModel, readTraeCliIdentity, readTraeIdentity, refreshTraeCredential, regionEnabled, regionEnabledOf, regionOfCredential, regionOfEdition, regionOfHost, regionOfTraeProvider, regionOfTraeStatusUrl, regionOfUserRegion, regionStateOf, registerTraeUsageRoute, resolveTraeIdentity, resolveTraeRawRuntime, sanitizeCatalog, settingsNamespaceOf, traeEndpoint, traeInputModalities, traeModelDisplayName, traeOwnAuthPath, traeRawChatExtraInfo, traeStateDatabaseCandidates, traeStorageCandidates, traeWebUsage, traeWindowsAppNames, unwrapVolatile, unwrapVolatileDeep, withTraeRegion };
5736
+ export { Config, FALLBACK_TRAE_MODELS, FALLBACK_TRAE_MODELS_AI, REGION_GATEWAYS, SseDecoder, TRAE_ACCOUNT_STATUS_PATH, TRAE_AI_PROVIDER, TRAE_CHECKIN_PATH, TRAE_CN_AGENT_TASK_PATH, TRAE_CN_TITLE_PATH, TRAE_MODELS_TEST_PATH, TRAE_MODEL_DETAIL_FUNCTIONS, TRAE_MODEL_DETAIL_PATH, TRAE_PAY_BASE, TRAE_PROBE_TIMEOUT_MS, TRAE_PROVIDER, TRAE_PROVIDERS, TRAE_PROVIDER_DISPLAY_NAMES, TRAE_RAW_CHAT_V1_PATH, TRAE_RAW_CHAT_V2_PATH, TRAE_REASONING_EFFORTS, TRAE_REGIONS, TRAE_REGION_PARAM, TRAE_SETTINGS_NS, TRAE_SOLO_CHAT_PATH, TRAE_SOLO_FUNCTION, TRAE_SOLO_MODELS_PATH, TRAE_SOLO_REMOTE_BASE, TRAE_STATE_DB_FILENAME, TRAE_STREAM_IDLE_TIMEOUT_MS, TRAE_USAGE_PATH, TRAE_VERSION_CODE_FALLBACK, TraeCatalog, TraeCredentialStore, TraeDelegatingUpstreamClient, TraeFallbackUpstreamClient, TraeGatedUpstreamClient, TraeRawCapabilityController, TraeRawCapabilityState, TraeRawChatUpstreamClient, TraeSoloBridge, TraeSoloRemoteCatalogClient, TraeSoloUpstreamClient, TraeUsageClient, UnconfiguredTraeUpstreamClient, apply, applyContextBudgets, applyImageSelection, applyReasoningEffort, asVolatile, bridgeTraeSoloStream, buildTraeAgentTaskBody, buildTraeCnHeaders, buildTraeFusionRawChatEnvelope, buildTraeModelDetailRequest, buildTraeRawChatDraft, buildTraeRawChatRuntimeConfig, classifyProbeError, classifyProbeResult, classifyTraeRawChatFailure, configuredAccountsOf, createTraeAdapter, createTraeRawGateway, createTraeShim, decodeRawChatChunk, decodeTraeEvent, decryptTraeStorageValue, deriveCatalog, describeIdShape, describeNameShape, discoveredCatalog, fallbackModelsFor, followAppLoginEnabled, hashTraeRawChatArg, identityHeaders, inject, legacyTraeOwnAuthPath, maskUserPath, mergeTraeModelSources, name, nextRegionEnabled, nextRegionSlots, normalizeTraeCredential, normalizeTraeVersionCode, parseObservedModelConfig, parseReasoningCapability, parseTraeAuthValue, parseTraeCachedModel, parseTraeModelExtraConfigLogLine, parseTraeRawChatBehaviorConfig, parseTraeRemoteModel, parseTraeStorageDocument, pickTraeStorageIdentity, prepareSoloBody, probeBody, probeModelsSequentially, probeOneModel, probeTraeRawChatCapability, rawCapabilityDiagnostic, rawCapabilityFingerprint, readTraeCachedModel, readTraeCliIdentity, readTraeIdentity, refreshTraeCredential, regionEnabled, regionEnabledOf, regionOfCredential, regionOfEdition, regionOfHost, regionOfTraeProvider, regionOfTraeStatusUrl, regionOfUserRegion, regionStateOf, registerTraeUsageRoute, resolveTraeIdentity, resolveTraeRawRuntime, sanitizeCatalog, settingsNamespaceOf, traeEndpoint, traeInputModalities, traeModelDisplayName, traeOwnAuthPath, traeRawChatExtraInfo, traeStateDatabaseCandidates, traeStorageCandidates, traeWebUsage, traeWindowsAppNames, unwrapVolatile, unwrapVolatileDeep, withTraeRegion };