dsh-connect-trae 2.10.0 → 3.0.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.
package/lib/index.js CHANGED
@@ -452,6 +452,202 @@ 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
+ * Plugin-owned daily check-in claim endpoint. POST, loopback-only, and the only
482
+ * route in this plugin that changes upstream account state.
483
+ */
484
+ const TRAE_CHECKIN_PATH = "/plugins/dsh-connect-trae/checkin";
485
+ /** Query parameter naming the region a card request addresses. */
486
+ const TRAE_REGION_PARAM = "region";
487
+ /** Every region, in card tab order. */
488
+ const TRAE_REGIONS = ["cn", "ai"];
489
+ /**
490
+ * Address one region's status route. The two regions are separate provider
491
+ * stacks; every card request carries the region whose tab the user is on.
492
+ */
493
+ function withTraeRegion(path, region) {
494
+ return `${path}?${TRAE_REGION_PARAM}=${region}`;
495
+ }
496
+ /**
497
+ * Read the region parameter off a status-route URL. Absent means the domestic
498
+ * tab (`cn`); a present-but-unknown value returns undefined so the route can
499
+ * answer 400 instead of guessing.
500
+ */
501
+ function regionOfTraeStatusUrl(url) {
502
+ const at = url.indexOf("?");
503
+ const value = at === -1 ? null : new URLSearchParams(url.slice(at + 1)).get(TRAE_REGION_PARAM);
504
+ if (value === null || value === "") return "cn";
505
+ return TRAE_REGIONS.includes(value) ? value : void 0;
506
+ }
507
+ /**
508
+ * Peel ONE `{get(): T}` live reference, the shape DSH 0.1.7 delivers a
509
+ * volatile-marked settings field as (see {@link unwrapVolatileDeep}).
510
+ */
511
+ function unwrapVolatile(value) {
512
+ if (value !== null && typeof value === "object" && typeof value.get === "function") return value.get();
513
+ return value;
514
+ }
515
+ /**
516
+ * Deep copy of a value with every `{get(): T}` live reference replaced by the
517
+ * value it resolves to.
518
+ *
519
+ * DSH 0.1.7 hands volatile-marked fields back as live references, and a live
520
+ * reference is still `typeof === 'object'` — so it passes a naive object check
521
+ * and every lookup on it is `undefined`. Worse, spreading one (`{ ...ref }`)
522
+ * does NOT read the field: it produces `{ get: <function> }`. Any merge that
523
+ * spreads a resolved field to preserve its siblings — the card's "write one
524
+ * region, keep the other" write, or the account map's spread — would otherwise
525
+ * DROP every sibling and leak a function into the document.
526
+ *
527
+ * Both halves therefore unwrap before touching a resolved field. This lives in
528
+ * the node-free host↔client bridge because the browser half needs it too.
529
+ *
530
+ * Non-reference values are recursed into so a nested volatile field is caught
531
+ * as well; arrays and objects are rebuilt rather than mutated, so the caller's
532
+ * value is never touched.
533
+ */
534
+ function unwrapVolatileDeep(value) {
535
+ if (value === null || typeof value !== "object") return value;
536
+ if (typeof value.get === "function") return unwrapVolatileDeep(value.get());
537
+ if (Array.isArray(value)) return value.map((entry) => unwrapVolatileDeep(entry));
538
+ const source = value;
539
+ const out = {};
540
+ for (const key of Object.keys(source)) out[key] = unwrapVolatileDeep(source[key]);
541
+ return out;
542
+ }
543
+ /**
544
+ * Read the per-region account selections out of a settings value.
545
+ *
546
+ * SHARED because `accounts` now has TWO writers: the card, and the composer
547
+ * panel's account table (2.14.0). Two writers of one field must use one merge
548
+ * rule, or they drift — and the rule is not obvious.
549
+ *
550
+ * Deep-unwraps first: on DSH 0.1.7 a volatile field arrives as a `{get(): T}`
551
+ * live reference, which passes the `typeof === 'object'` check and would be
552
+ * returned as if it were the map. Every lookup on it is then `undefined`, and
553
+ * the merge (`{ ...configured, [region]: id }`) would spread a reference into
554
+ * `{get: <function>}` — dropping the other region's selection and leaking a
555
+ * function into the settings document.
556
+ */
557
+ function configuredAccountsOf(configured) {
558
+ const accounts = unwrapVolatileDeep(configured)?.accounts;
559
+ return typeof accounts === "object" && accounts !== null ? accounts : {};
560
+ }
561
+ /**
562
+ * Whether the host-side "follow the Trae app's sign-in" switch is on (issue
563
+ * #29). Absent reads as OFF, so an existing configuration keeps the strict
564
+ * bind it shipped with and the switch is an opt-in — the same opt-out
565
+ * semantics as {@link regionEnabledOf}, for the same reason: a config written
566
+ * before this field existed cannot ask for behavior it never declared.
567
+ *
568
+ * Reads the whole settings section (or the bare field); deep-unwraps first
569
+ * because on DSH 0.1.7 a volatile field arrives as a `{get(): T}` live
570
+ * reference, whose `typeof === 'object'` would otherwise pass a naive check.
571
+ */
572
+ function followAppLoginEnabled(configured) {
573
+ return unwrapVolatileDeep(configured)?.followAppLogin === true;
574
+ }
575
+ /**
576
+ * Build the next `regions` settings value for the card's save. The write
577
+ * targets ONLY the signed-in account's region slot; every other region's slot
578
+ * is carried over untouched, so switching accounts never clobbers the other
579
+ * region's picks. Tolerates any stored shape (absent, non-object) by starting
580
+ * from an empty document.
581
+ */
582
+ function nextRegionSlots(regions, region, slot) {
583
+ const unwrapped = unwrapVolatileDeep(regions);
584
+ return {
585
+ ...typeof unwrapped === "object" && unwrapped !== null && !Array.isArray(unwrapped) ? unwrapped : {},
586
+ [region]: slot
587
+ };
588
+ }
589
+ /**
590
+ * Narrow a settings value to the `regions` map. Accepts EITHER the whole
591
+ * settings section (`{ regions: {...}, ... }`) or the `regions` map itself, and
592
+ * unwraps the former. This tolerance is deliberate: passing the whole section
593
+ * where the map was expected was a real shipped bug — the lookup then read
594
+ * `section['cn']` (absent), so the card's checkbox reported `true` forever and
595
+ * clicking it appeared to do nothing even though the write succeeded.
596
+ *
597
+ * Unwraps live references first: on DSH 0.1.7 the `regions` field arrives as a
598
+ * `{get(): T}` reference, which passes the `typeof === 'object'` check below
599
+ * and would otherwise be returned as if it were the map — making every region
600
+ * read back as absent (and the enabled flag read as "on" forever).
601
+ */
602
+ function regionsMapOf(value) {
603
+ const unwrapped = unwrapVolatileDeep(value);
604
+ if (typeof unwrapped !== "object" || unwrapped === null || Array.isArray(unwrapped)) return {};
605
+ const record = unwrapped;
606
+ const nested = record["regions"];
607
+ if (typeof nested === "object" && nested !== null && !Array.isArray(nested)) return nested;
608
+ return record;
609
+ }
610
+ /** One region's stored slot as a plain object; any other shape reads as empty. */
611
+ function regionSlotOf(value, region) {
612
+ const slot = unwrapVolatile(regionsMapOf(value)[region]);
613
+ return typeof slot === "object" && slot !== null && !Array.isArray(slot) ? slot : {};
614
+ }
615
+ /**
616
+ * Whether one region's provider is switched on. Opt-out semantics: only an
617
+ * explicit `false` disables it, so a config written before this switch existed
618
+ * (and the pre-region-split flat fields, which never carry `enabled`) keep both
619
+ * providers running exactly as before. The Host reads the same rule through
620
+ * `regionStateOf`, so card and Host can never disagree about a region's state.
621
+ *
622
+ * `value` may be the whole settings section or the `regions` map (see
623
+ * {@link regionsMapOf}).
624
+ */
625
+ function regionEnabledOf(value, region) {
626
+ return regionSlotOf(value, region)["enabled"] !== false;
627
+ }
628
+ /**
629
+ * Build the next `regions` settings value for a provider on/off toggle. ONLY
630
+ * the target region's `enabled` flag changes: every other field of that slot
631
+ * (its directory, selection, image opt-ins, context budgets) and every other
632
+ * region's slot are carried over verbatim, so switching a provider off never
633
+ * discards the user's model picks and switching it back on restores them.
634
+ *
635
+ * This is deliberately separate from {@link nextRegionSlots}: that helper
636
+ * writes a whole slot from a signed-in tab's draft, while this one must work
637
+ * for a region that is signed OUT — which is precisely the region a user wants
638
+ * to switch off (no international install, no international account).
639
+ *
640
+ * `value` may be the whole settings section or the `regions` map; the RETURN
641
+ * value is always the `regions` map, i.e. exactly what `settingsScope.set(
642
+ * 'regions', ...)` needs.
643
+ */
644
+ function nextRegionEnabled(value, region, enabled) {
645
+ return nextRegionSlots(regionsMapOf(value), region, {
646
+ ...regionSlotOf(value, region),
647
+ enabled
648
+ });
649
+ }
650
+ //#endregion
455
651
  //#region src/decrypt.ts
456
652
  const TRAE_AUTH_STORAGE_KEY = "iCubeAuthInfo://icube.cloudide";
457
653
  const SALT_A = Uint8Array.from([
@@ -1171,13 +1367,52 @@ var TraeCredentialStore = class {
1171
1367
  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
1368
  }
1173
1369
  /**
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.
1370
+ * Deterministic default when no account is explicitly selected: the account
1371
+ * the Trae desktop app is signed in to RIGHT NOW, else the first discovered
1372
+ * one. This is NOT credit-seeking — it never reorders accounts to find one
1373
+ * with general credits. The plugin bills exactly the account the user
1374
+ * selected, the app's current sign-in when nothing has been selected, and
1375
+ * only then the first account.
1376
+ *
1377
+ * The app's live sign-in outranks the plugin's own refreshed copies (issue
1378
+ * #29): after the user switches accounts in Trae, the desktop storage file
1379
+ * holds the NEW login while the plugin's copy of the old one still resolves,
1380
+ * so a bare `credentials[0]` kept defaulting to a sign-in the app has
1381
+ * abandoned — and `dsh-connect-workbuddy`, whose `preferred()` ranks the live
1382
+ * file first, followed the app instead. Ordering only: the VALUE behind an
1383
+ * account id is still whichever copy expires later (`readAll`), so a refresh
1384
+ * the plugin performed is never discarded by this preference.
1178
1385
  */
1179
1386
  preferred(credentials) {
1180
- return credentials[0];
1387
+ return credentials.find((credential) => credential.source === "desktop") ?? credentials[0];
1388
+ }
1389
+ /**
1390
+ * The account the Trae app is signed in to right now, when that differs from
1391
+ * the account this store is bound to (issue #29).
1392
+ *
1393
+ * This is a READ, not a rebind: `current()` keeps resolving the bound
1394
+ * account verbatim so billing never moves without a decision — the store
1395
+ * only reports the mismatch, and the card (hint row) or the host (follow
1396
+ * switch) turns it into a settings write the user can see and undo. Absent
1397
+ * when nothing is live, when the live account IS the bound one, or when the
1398
+ * binding itself already fell through to the live account.
1399
+ */
1400
+ async liveLogin() {
1401
+ if (this.accountId === void 0 || this.accountId === "") return void 0;
1402
+ const credentials = await this.readAll();
1403
+ const live = this.preferred(credentials);
1404
+ if (live === void 0) return void 0;
1405
+ const liveId = traeAccountId(live);
1406
+ if (liveId === this.accountId) return void 0;
1407
+ return {
1408
+ id: liveId,
1409
+ accountName: live.accountName ?? (live.userId || `${live.edition} account`),
1410
+ edition: live.edition,
1411
+ region: regionOfCredential(live),
1412
+ source: live.source,
1413
+ tokenExpiresAtMs: live.expiresAtMs,
1414
+ selected: false
1415
+ };
1181
1416
  }
1182
1417
  async accounts() {
1183
1418
  const credentials = await this.readAll();
@@ -1266,13 +1501,16 @@ var TraeCredentialStore = class {
1266
1501
  */
1267
1502
  async readAll() {
1268
1503
  const { credentials: desktop } = await this.readDesktopAll();
1269
- const credentials = [...desktop.filter((credential) => this.matchesRegion(credential))];
1270
- for (const own of await this.readOwns()) {
1271
- if (!this.matchesRegion(own)) continue;
1272
- if (credentials.some((credential) => traeAccountId(credential) === traeAccountId(own))) continue;
1273
- credentials.push(own);
1274
- }
1275
- return credentials;
1504
+ const byAccount = /* @__PURE__ */ new Map();
1505
+ const consider = (credential) => {
1506
+ if (!this.matchesRegion(credential)) return;
1507
+ const id = traeAccountId(credential);
1508
+ const incumbent = byAccount.get(id);
1509
+ if (incumbent === void 0 || credential.expiresAtMs > incumbent.expiresAtMs) byAccount.set(id, credential);
1510
+ };
1511
+ for (const credential of desktop) consider(credential);
1512
+ for (const own of await this.readOwns()) consider(own);
1513
+ return [...byAccount.values()];
1276
1514
  }
1277
1515
  /**
1278
1516
  * Which paths were tried and why each one failed. Read-only and token-free:
@@ -3924,159 +4162,6 @@ var TraeUsageClient = class {
3924
4162
  }
3925
4163
  };
3926
4164
  //#endregion
3927
- //#region src/status-paths.ts
3928
- /** Plugin-owned usage endpoint consumed by its browser half. */
3929
- const TRAE_USAGE_PATH = "/plugins/dsh-connect-trae/usage";
3930
- /** Plugin-owned live model refresh endpoint. */
3931
- const TRAE_MODELS_REFRESH_PATH = "/plugins/dsh-connect-trae/models/refresh";
3932
- /**
3933
- * Plugin-owned availability probe: POST the model ids to test, get one verdict
3934
- * per model. Unlike every read-only sibling this route SPENDS quota — a real
3935
- * (minimal) chat call per model — so it is user-initiated only, and the card
3936
- * labels the button as consuming a small amount.
3937
- */
3938
- const TRAE_MODELS_TEST_PATH = "/plugins/dsh-connect-trae/models/test";
3939
- /** Plugin-owned local account rescan endpoint. */
3940
- const TRAE_ACCOUNTS_REFRESH_PATH = "/plugins/dsh-connect-trae/accounts/refresh";
3941
- /**
3942
- * Plugin-owned daily check-in claim endpoint. POST, loopback-only, and the only
3943
- * route in this plugin that changes upstream account state.
3944
- */
3945
- const TRAE_CHECKIN_PATH = "/plugins/dsh-connect-trae/checkin";
3946
- /** Query parameter naming the region a card request addresses. */
3947
- const TRAE_REGION_PARAM = "region";
3948
- /** Every region, in card tab order. */
3949
- const TRAE_REGIONS = ["cn", "ai"];
3950
- /**
3951
- * Address one region's status route. The two regions are separate provider
3952
- * stacks; every card request carries the region whose tab the user is on.
3953
- */
3954
- function withTraeRegion(path, region) {
3955
- return `${path}?${TRAE_REGION_PARAM}=${region}`;
3956
- }
3957
- /**
3958
- * Read the region parameter off a status-route URL. Absent means the domestic
3959
- * tab (`cn`); a present-but-unknown value returns undefined so the route can
3960
- * answer 400 instead of guessing.
3961
- */
3962
- function regionOfTraeStatusUrl(url) {
3963
- const at = url.indexOf("?");
3964
- const value = at === -1 ? null : new URLSearchParams(url.slice(at + 1)).get(TRAE_REGION_PARAM);
3965
- if (value === null || value === "") return "cn";
3966
- return TRAE_REGIONS.includes(value) ? value : void 0;
3967
- }
3968
- /**
3969
- * Peel ONE `{get(): T}` live reference, the shape DSH 0.1.7 delivers a
3970
- * volatile-marked settings field as (see {@link unwrapVolatileDeep}).
3971
- */
3972
- function unwrapVolatile(value) {
3973
- if (value !== null && typeof value === "object" && typeof value.get === "function") return value.get();
3974
- return value;
3975
- }
3976
- /**
3977
- * Deep copy of a value with every `{get(): T}` live reference replaced by the
3978
- * value it resolves to.
3979
- *
3980
- * DSH 0.1.7 hands volatile-marked fields back as live references, and a live
3981
- * reference is still `typeof === 'object'` — so it passes a naive object check
3982
- * and every lookup on it is `undefined`. Worse, spreading one (`{ ...ref }`)
3983
- * does NOT read the field: it produces `{ get: <function> }`. Any merge that
3984
- * spreads a resolved field to preserve its siblings — the card's "write one
3985
- * region, keep the other" write, or the account map's spread — would otherwise
3986
- * DROP every sibling and leak a function into the document.
3987
- *
3988
- * Both halves therefore unwrap before touching a resolved field. This lives in
3989
- * the node-free host↔client bridge because the browser half needs it too.
3990
- *
3991
- * Non-reference values are recursed into so a nested volatile field is caught
3992
- * as well; arrays and objects are rebuilt rather than mutated, so the caller's
3993
- * value is never touched.
3994
- */
3995
- function unwrapVolatileDeep(value) {
3996
- if (value === null || typeof value !== "object") return value;
3997
- if (typeof value.get === "function") return unwrapVolatileDeep(value.get());
3998
- if (Array.isArray(value)) return value.map((entry) => unwrapVolatileDeep(entry));
3999
- const source = value;
4000
- const out = {};
4001
- for (const key of Object.keys(source)) out[key] = unwrapVolatileDeep(source[key]);
4002
- return out;
4003
- }
4004
- /**
4005
- * Build the next `regions` settings value for the card's save. The write
4006
- * targets ONLY the signed-in account's region slot; every other region's slot
4007
- * is carried over untouched, so switching accounts never clobbers the other
4008
- * region's picks. Tolerates any stored shape (absent, non-object) by starting
4009
- * from an empty document.
4010
- */
4011
- function nextRegionSlots(regions, region, slot) {
4012
- const unwrapped = unwrapVolatileDeep(regions);
4013
- return {
4014
- ...typeof unwrapped === "object" && unwrapped !== null && !Array.isArray(unwrapped) ? unwrapped : {},
4015
- [region]: slot
4016
- };
4017
- }
4018
- /**
4019
- * Narrow a settings value to the `regions` map. Accepts EITHER the whole
4020
- * settings section (`{ regions: {...}, ... }`) or the `regions` map itself, and
4021
- * unwraps the former. This tolerance is deliberate: passing the whole section
4022
- * where the map was expected was a real shipped bug — the lookup then read
4023
- * `section['cn']` (absent), so the card's checkbox reported `true` forever and
4024
- * clicking it appeared to do nothing even though the write succeeded.
4025
- *
4026
- * Unwraps live references first: on DSH 0.1.7 the `regions` field arrives as a
4027
- * `{get(): T}` reference, which passes the `typeof === 'object'` check below
4028
- * and would otherwise be returned as if it were the map — making every region
4029
- * read back as absent (and the enabled flag read as "on" forever).
4030
- */
4031
- function regionsMapOf(value) {
4032
- const unwrapped = unwrapVolatileDeep(value);
4033
- if (typeof unwrapped !== "object" || unwrapped === null || Array.isArray(unwrapped)) return {};
4034
- const record = unwrapped;
4035
- const nested = record["regions"];
4036
- if (typeof nested === "object" && nested !== null && !Array.isArray(nested)) return nested;
4037
- return record;
4038
- }
4039
- /** One region's stored slot as a plain object; any other shape reads as empty. */
4040
- function regionSlotOf(value, region) {
4041
- const slot = unwrapVolatile(regionsMapOf(value)[region]);
4042
- return typeof slot === "object" && slot !== null && !Array.isArray(slot) ? slot : {};
4043
- }
4044
- /**
4045
- * Whether one region's provider is switched on. Opt-out semantics: only an
4046
- * explicit `false` disables it, so a config written before this switch existed
4047
- * (and the pre-region-split flat fields, which never carry `enabled`) keep both
4048
- * providers running exactly as before. The Host reads the same rule through
4049
- * `regionStateOf`, so card and Host can never disagree about a region's state.
4050
- *
4051
- * `value` may be the whole settings section or the `regions` map (see
4052
- * {@link regionsMapOf}).
4053
- */
4054
- function regionEnabledOf(value, region) {
4055
- return regionSlotOf(value, region)["enabled"] !== false;
4056
- }
4057
- /**
4058
- * Build the next `regions` settings value for a provider on/off toggle. ONLY
4059
- * the target region's `enabled` flag changes: every other field of that slot
4060
- * (its directory, selection, image opt-ins, context budgets) and every other
4061
- * region's slot are carried over verbatim, so switching a provider off never
4062
- * discards the user's model picks and switching it back on restores them.
4063
- *
4064
- * This is deliberately separate from {@link nextRegionSlots}: that helper
4065
- * writes a whole slot from a signed-in tab's draft, while this one must work
4066
- * for a region that is signed OUT — which is precisely the region a user wants
4067
- * to switch off (no international install, no international account).
4068
- *
4069
- * `value` may be the whole settings section or the `regions` map; the RETURN
4070
- * value is always the `regions` map, i.e. exactly what `settingsScope.set(
4071
- * 'regions', ...)` needs.
4072
- */
4073
- function nextRegionEnabled(value, region, enabled) {
4074
- return nextRegionSlots(regionsMapOf(value), region, {
4075
- ...regionSlotOf(value, region),
4076
- enabled
4077
- });
4078
- }
4079
- //#endregion
4080
4165
  //#region src/web-status.ts
4081
4166
  /**
4082
4167
  * The upstream's business code for "该设备今日已参与签到" — this DEVICE already
@@ -4219,6 +4304,10 @@ async function traeWebUsage(deps, region) {
4219
4304
  message: safeMessage(error)
4220
4305
  };
4221
4306
  }
4307
+ let appLogin;
4308
+ try {
4309
+ appLogin = await deps.liveLogin?.(region);
4310
+ } catch {}
4222
4311
  const account = {
4223
4312
  accountId: accounts.find((item) => item.selected)?.id ?? "",
4224
4313
  accountName: credential.accountName ?? credential.userId,
@@ -4226,6 +4315,7 @@ async function traeWebUsage(deps, region) {
4226
4315
  region,
4227
4316
  enabled,
4228
4317
  accounts,
4318
+ ...appLogin === void 0 ? {} : { appLogin },
4229
4319
  models: deps.displayModels(region).map((model) => ({
4230
4320
  ...model,
4231
4321
  ...model.input === void 0 ? {} : { input: [...model.input] }
@@ -4297,6 +4387,37 @@ async function creditAlternatives(deps, region, accounts) {
4297
4387
  * The region a request addresses, or a 400 answer. Absent parameter means the
4298
4388
  * domestic tab; an unknown value is refused rather than guessed.
4299
4389
  */
4390
+ /**
4391
+ * The general balance of every account in one region.
4392
+ *
4393
+ * One upstream read per account — the same call `creditsOfAccount` already
4394
+ * makes for the exhausted-balance alternatives, widened to every account and to
4395
+ * zero balances. Costs nothing until the composer panel is opened, so the
4396
+ * 5-minute readout never pays for it (see {@link TRAE_ACCOUNT_CREDITS_PATH}).
4397
+ *
4398
+ * An account whose read fails or is unavailable still appears, without a
4399
+ * figure: the table lists how many accounts exist even when one cannot be
4400
+ * quoted, rather than dropping it and making the switch unavailable.
4401
+ */
4402
+ async function traeAccountCredits(deps, region) {
4403
+ const accounts = (await deps.store(region).accounts()).filter((account) => account.region === region);
4404
+ if (accounts.length === 0) return [];
4405
+ return Promise.all(accounts.map(async (account) => {
4406
+ let generalAvailable;
4407
+ if (account.selected) try {
4408
+ generalAvailable = toCredits(await deps.client(region).snapshot()).generalAvailable;
4409
+ } catch {}
4410
+ else if (deps.creditsOfAccount !== void 0) try {
4411
+ generalAvailable = (await deps.creditsOfAccount(region, account.id))?.generalAvailable;
4412
+ } catch {}
4413
+ return {
4414
+ id: account.id,
4415
+ accountName: account.accountName,
4416
+ selected: account.selected,
4417
+ ...generalAvailable === void 0 ? {} : { generalAvailable }
4418
+ };
4419
+ }));
4420
+ }
4300
4421
  function requestRegion(req, res) {
4301
4422
  const region = regionOfTraeStatusUrl(req.url ?? "/");
4302
4423
  if (region === void 0) {
@@ -4329,6 +4450,27 @@ function registerTraeUsageRoute(ctx, deps) {
4329
4450
  }
4330
4451
  }
4331
4452
  });
4453
+ /**
4454
+ * General balance of every account in the region — read only when the
4455
+ * composer panel is opened. One upstream read per account, paid once per
4456
+ * open rather than on the 5-minute cycle (see
4457
+ * {@link TRAE_ACCOUNT_CREDITS_PATH}).
4458
+ */
4459
+ const disposeAccountCredits = ctx.webServer.register({
4460
+ kind: "exact",
4461
+ path: TRAE_ACCOUNT_CREDITS_PATH,
4462
+ handler: async (req, res) => {
4463
+ if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
4464
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
4465
+ const region = requestRegion(req, res);
4466
+ if (region === void 0) return;
4467
+ try {
4468
+ json(res, 200, { accounts: await traeAccountCredits(deps, region) });
4469
+ } catch (error) {
4470
+ json(res, 500, { error: safeMessage(error) });
4471
+ }
4472
+ }
4473
+ });
4332
4474
  const disposeAccounts = ctx.webServer.register({
4333
4475
  kind: "exact",
4334
4476
  path: TRAE_ACCOUNTS_REFRESH_PATH,
@@ -4462,6 +4604,7 @@ function registerTraeUsageRoute(ctx, deps) {
4462
4604
  disposeRefresh();
4463
4605
  disposeModelsTest();
4464
4606
  disposeAccounts();
4607
+ disposeAccountCredits();
4465
4608
  disposeUsage();
4466
4609
  };
4467
4610
  }, "dsh-connect-trae: Web usage route");
@@ -4769,7 +4912,8 @@ const Config = z.object({
4769
4912
  "solo-sg"
4770
4913
  ]).default("auto").description("Trae edition hint")),
4771
4914
  accountId: z.string().description("Deprecated: pre-split account selector, attributed to its own region"),
4772
- showPointsInMainUi: asVolatile(z.boolean().default(true).description("在DSH主界面显示积分状态(可在侧边栏那一行上随时关闭)")),
4915
+ showPointsInMainUi: asVolatile(z.boolean().default(false).description("在输入框工具栏显示积分读数「Trae CN · <数值>」(仅当选中的模型属于本插件时出现,可点击查看账号与积分明细)")),
4916
+ followAppLogin: asVolatile(z.boolean().default(false).description("跟随 Trae 桌面端当前登录账号换绑(打开后每次配置生效时重绑到 App 现在登录的账号)")),
4773
4917
  accounts: asVolatile(accountSelectionConfig.description("Per-region account selections, keyed cn | ai")),
4774
4918
  regions: asVolatile(z.dict(regionStateConfig).default({}).description("Per-region model directory and selection, keyed cn | ai")),
4775
4919
  lastCatalog: z.array(modelConfig).description("Deprecated: pre-region-split CN model directory"),
@@ -5025,6 +5169,49 @@ function apply(ctx, config) {
5025
5169
  rawDiagnostic: () => rawDiagnostic()
5026
5170
  };
5027
5171
  }
5172
+ /**
5173
+ * The app's current sign-in when it differs from the bound account (issue
5174
+ * #29). Pure read: the store resolves the bound account verbatim, so billing
5175
+ * never moves from here — the card's hint and the follow switch below are
5176
+ * the only writers.
5177
+ */
5178
+ const liveLoginOf = async (region) => {
5179
+ try {
5180
+ const live = await stacks[region].store.liveLogin();
5181
+ if (live === void 0 || live.region !== region) return void 0;
5182
+ return {
5183
+ id: live.id,
5184
+ accountName: live.accountName,
5185
+ edition: live.edition,
5186
+ region: live.region
5187
+ };
5188
+ } catch {
5189
+ return;
5190
+ }
5191
+ };
5192
+ /**
5193
+ * Persist `accounts.<region>` = the app's live sign-in and rebind the store
5194
+ * (issue #29). The ONE Host-side path that moves the binding, driven by the
5195
+ * follow switch in {@link maybeFollowAppLogin}; the card's one-click hint
5196
+ * goes through its own settings write instead (one writer per path). Returns
5197
+ * the applied login so the caller can report what moved.
5198
+ *
5199
+ * The write goes through `settings.update` (the volatile write gate), not a
5200
+ * direct config mutation: `apply()` receives a snapshot, and the settings
5201
+ * event (`loader/volatile-update`) is what re-runs `applySelection` with the
5202
+ * updated document — the same channel every card write already uses.
5203
+ */
5204
+ const followAppLogin = async (region) => {
5205
+ const live = await liveLoginOf(region);
5206
+ if (live === void 0 || writeSettings === void 0) return void 0;
5207
+ const configured = configuredAccountsOf(current());
5208
+ if (configured[region] === live.id) return live;
5209
+ await writeSettings({ accounts: {
5210
+ ...configured,
5211
+ [region]: live.id
5212
+ } });
5213
+ return live;
5214
+ };
5028
5215
  ctx.inject(["webServer"], (webCtx) => registerTraeUsageRoute(webCtx, {
5029
5216
  store: (region) => stacks[region].store,
5030
5217
  client: (region) => stacks[region].usageClient,
@@ -5034,7 +5221,8 @@ function apply(ctx, config) {
5034
5221
  discoverModels: (region, signal) => stacks[region].discoverModels(signal),
5035
5222
  probeModels: (region, ids) => stacks[region].probeModels(ids),
5036
5223
  rawDiagnostic: (region) => stacks[region].rawDiagnostic(),
5037
- creditsOfAccount: (region, accountId) => stacks[region].creditsOfAccount(accountId)
5224
+ creditsOfAccount: (region, accountId) => stacks[region].creditsOfAccount(accountId),
5225
+ liveLogin: (region) => liveLoginOf(region)
5038
5226
  }));
5039
5227
  /**
5040
5228
  * Live registration handles per region, filled once the shim is listening.
@@ -5090,13 +5278,59 @@ function apply(ctx, config) {
5090
5278
  }
5091
5279
  } catch {}
5092
5280
  })();
5281
+ let writeSettings;
5093
5282
  ctx.inject(["settings"], (settingsCtx) => {
5094
5283
  const settings = settingsCtx.settings;
5095
5284
  ctx.effect(() => settings.configure({ auto: true }, ctx.fiber));
5285
+ writeSettings = async (patch) => {
5286
+ const revision = () => settings.describe().find((entry) => entry.ns === settingsNs)?.revision;
5287
+ const conflict = (error) => typeof error === "object" && error !== null && error.code === "SETTINGS_CONFLICT";
5288
+ try {
5289
+ await settings.update(settingsNs, patch, revision());
5290
+ } catch (error) {
5291
+ if (!conflict(error)) throw error;
5292
+ await settings.update(settingsNs, patch, revision());
5293
+ }
5294
+ };
5295
+ ctx.effect(() => () => {
5296
+ writeSettings = void 0;
5297
+ });
5096
5298
  });
5097
5299
  ctx.on("loader/volatile-update", () => {
5098
5300
  applySelection(current());
5301
+ maybeFollowAppLogin();
5099
5302
  });
5303
+ /**
5304
+ * Follow the app's sign-in on every settings change when the switch is ON
5305
+ * (issue #29). Runs AFTER `applySelection` so this pass binds the previous
5306
+ * document, reads the app's live login against it, and — if they differ —
5307
+ * writes `accounts.<region>` once; that write emits `volatile-update` again,
5308
+ * which re-runs `applySelection` on the NEW document. The loop terminates on
5309
+ * its own the moment `liveLogin()` reports no mismatch (the second pass binds
5310
+ * the live account, so `preferred` and `current` agree). The guard below
5311
+ * additionally forbids two writes in flight, so a stuck mismatch can never
5312
+ * turn into a write storm: the next settings change retries from scratch.
5313
+ *
5314
+ * The switch reads FALSE for an existing config (see {@link Config}), so a
5315
+ * user who never opted in never gets a rebind — the card's one-click hint is
5316
+ * their path instead.
5317
+ */
5318
+ let followInFlight = false;
5319
+ const maybeFollowAppLogin = async () => {
5320
+ if (followInFlight || !followAppLoginEnabled(current())) return;
5321
+ followInFlight = true;
5322
+ try {
5323
+ for (const region of REGION_KEYS) {
5324
+ if (!regionEnabled(current(), region)) continue;
5325
+ await followAppLogin(region);
5326
+ }
5327
+ } catch (error) {
5328
+ ctx.logger.warn("dsh-connect-trae: follow-app-login rebind skipped", error);
5329
+ } finally {
5330
+ followInFlight = false;
5331
+ }
5332
+ };
5333
+ maybeFollowAppLogin();
5100
5334
  let stopped = false;
5101
5335
  ctx.effect(() => () => {
5102
5336
  stopped = true;
@@ -5178,4 +5412,4 @@ function apply(ctx, config) {
5178
5412
  });
5179
5413
  }
5180
5414
  //#endregion
5181
- 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 };
5415
+ 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, 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 };