@magpie-community/opencode-trae-auth 0.2.21 → 0.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +63 -19
  2. package/index.mjs +100 -32
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -231,7 +231,9 @@ which levels it takes — its `reasoning_effort_config`, with the options
231
231
  (`light`, `high`, `extra_high`) and the default. The request takes the same
232
232
  names as `reasoning_effort`, so a level an agent picks is sent as Trae
233
233
  spells it: the plugin offers each model's own levels as `variants`
234
- (`low`/`high`/`xhigh` for `light`/`high`/`extra_high`), and sends nothing
234
+ (`low`/`high`/`xhigh` for `light`/`high`/`extra_high`; `medium` and `max`
235
+ keep their names, offered only where the entry names them, as for DeepSeek
236
+ V4.1 Flash's `max` in Trae's app), and sends nothing
235
237
  for a level the model doesn't name, or for a model whose entry names none —
236
238
  Trae then serves it at its own default. An entry naming levels this map
237
239
  can't spell offers none, rather than being sent one Trae would reject. A
@@ -354,12 +356,15 @@ an empty answer.
354
356
  Trae CN gives credits for a daily check-in (每日签到); Trae Global has no
355
357
  such page, so this is CN only. magpie can press it
356
358
  once a day for each account (Settings, or the switch on the usage card),
357
- through `auth.checkin`: `claimed` when Trae grants the credits, `done`
358
- when the status says today's is in already, `inactive` when the page is
359
+ through `auth.checkin`: `claimed` when the status says today's is in after
360
+ the claim, `done` when it said so before it, `inactive` when the page is
359
361
  disabled, `ineligible` when the claim answers 9095 (this device has
360
- checked in today) or 9074 (to be done in Trae CN's own app; not asked
361
- again that day), and `failed` for a turned-away sign-in or any other
362
- answer, which is tried again later that day.
362
+ checked in today) or 9074 (Trae did not take it for this account's
363
+ device) or answers code 0 while the read-back still says the day is out,
364
+ and `failed` for a turned-away sign-in or any answer that got nowhere,
365
+ which is tried again later that day. An `ineligible` is the day's answer:
366
+ magpie asks that account no more until tomorrow, and only reads its
367
+ status.
363
368
  It asks Trae CN's own pages through
364
369
  this plugin's fetch, which sends them as the account, as Trae CN's IDE
365
370
  (3.3.104) sends them: its Cloud-IDE-JWT (renewed first when near its
@@ -390,10 +395,27 @@ answered it 9074 even with the client's headers.
390
395
  plugin's, on its own device, got 9074 (yetone/magpie#808, #1920); that
391
396
  this header is what changes it hasn't been tried on a real account
392
397
  yet. Every other account keeps its own device, and a file that can't be
393
- read or made out, or holds no single device, changes nothing. Files:
394
- macOS `~/Library/Application Support/Trae CN/…`, Windows
395
- `%APPDATA%\Trae CN\…` (or `trae-cn`), Linux `~/.config/trae-cn/…`
396
- (or `Trae CN`; not seen on a real Linux install).
398
+ read or made out, or holds no single device, changes nothing.
399
+ - **Every profile is read (0.2.21):** a machine can hold more than one
400
+ Trae CN client profile — the IDE's and TRAE SOLO CN's each write their
401
+ own `storage.json`, and each names the account that profile is signed in
402
+ as, with that account's device. So every profile is read and the one
403
+ signed in as the account answers with its device, which is how two
404
+ accounts can each check in on a real client's device. Before, only the
405
+ first profile was read and it decided for all of them: a second
406
+ account's own profile was never seen, and one profile signed in as
407
+ someone else (or one file that didn't parse) hid every profile after it.
408
+ A profile that isn't signed in as the account, or that names no single
409
+ device, is passed over rather than ending the search. Files: macOS
410
+ `~/Library/Application Support/Trae CN/…` and `…/TRAE SOLO CN/…`, Windows
411
+ `%APPDATA%\Trae CN\…`, `…\TRAE SOLO CN\…` (or `trae-cn`), Linux
412
+ `~/.config/trae-cn/…`, `…/Trae CN/…`, `…/TRAE SOLO CN/…`. The IDE's
413
+ folder has been seen in a real install (macOS and Windows); TRAE SOLO
414
+ CN's name and its `iCubeAuthInfo://` keys are read as the same client
415
+ of the same realm writes them, which hasn't been checked against real
416
+ files — harmless if they differ, since a folder that isn't there, or a
417
+ key that isn't named, is simply not read. A client started with its own
418
+ `--user-data-dir` is not found: there is no list of those to read.
397
419
  - **Every other account's device (0.2.16):** its own device id, in the
398
420
  client's shape. Trae CN answered a claim on the plugin's 19-digit id
399
421
  9074, while the same account and token on a 16-digit id were taken
@@ -404,15 +426,37 @@ answered it 9074 even with the client's headers.
404
426
  keep the 19-digit device it signed in with.
405
427
  - **What comes back:** Trae's answer as it is. A 401 or code 1001 marks
406
428
  the account for a new sign-in. Code 9095 means this device has checked
407
- in today, and that holds for the day. No device id is ever changed or
408
- rotated to get round a 9095 or a 9074, and no account is given another
409
- account's device or its own client's device unless the client is signed
410
- in as that account. The credit a claim granted is its `credits`, or
411
- its `extra_credits` when it names only that; Trae CN's claim usually
412
- names neither (`{"code":0,"message":"success"}`), and then the status
413
- answer's `credits` is the credit. The two are never added: the status
414
- carries `extra_credits` beside `credits` (100 and 100 on a real
415
- account) while the day gives 100.
429
+ in today, and that holds for the day. Code 9074 means Trae did not take
430
+ the claim for this account's device: its own words say the pool is busy,
431
+ which two measurements do not bear out — the same account, token and
432
+ headers were taken on a 16-digit id where a 19-digit one got 9074
433
+ (plugins#194), and on 2026-10-23 the same account and headers were taken
434
+ on a device id Trae had never seen while this account's own id answered
435
+ 9074. The message says so, and does not send people to Trae CN's app,
436
+ which is not where the problem is. Signing the account in to a Trae CN
437
+ client on this machine is the one thing seen to change a 9074, but it
438
+ hasn't been tried on a real account, so it isn't named in the message;
439
+ nor is it named when the claim already rode a client's device, where it
440
+ would only tell the user to do what they did. No device id is ever
441
+ changed or rotated to get round a 9095 or a 9074, and no account is
442
+ given another account's device or its own client's device unless a
443
+ client is signed in as that account.
444
+ - **A claim's code 0 is not the answer (0.2.22):** Trae answers a
445
+ claim code 0 for one that registered nothing, so the status is read back
446
+ after every claim and only a day that is in is reported as `claimed`.
447
+ Since 0.2.22 one that isn't is an `ineligible` — the day's answer, not a
448
+ `failed` to be tried again: a `failed` is retried with backoff for the
449
+ rest of the day and each retry is another claim, which for an account
450
+ Trae keeps answering code 0 would turn one claim a day into many, and a
451
+ status that lags a claim that did land would read as not in once and
452
+ claim again. Nothing is claimed again that day; the status alone is
453
+ read, so a lag that catches up is still reported as `done`. The credit a
454
+ claim granted is its `credits`, or its `extra_credits` when it names
455
+ only that; Trae CN's claim usually names neither
456
+ (`{"code":0,"message":"success"}`), and then the read-back's `credits`
457
+ is the credit. The two are never added: the status carries
458
+ `extra_credits` beside `credits` (100 and 100 on a real account) while
459
+ the day gives 100.
416
460
 
417
461
  Only `api.trae.cn`'s `/trae/api/` pages are sent this way; any other URL
418
462
  that isn't a chat request is still refused (400). Needs 0.1.4 or later.
package/index.mjs CHANGED
@@ -826,9 +826,16 @@ const ownClient = {
826
826
  paths: () => clientStoragePaths(process.platform, homedir(), process.env),
827
827
  }
828
828
 
829
+ // CLIENT_PROFILES are the folder names a Trae CN client profile is kept
830
+ // under: the IDE's own, and TRAE SOLO CN's, which is a client of the same
831
+ // realm and signs in its own account. A profile started with its own
832
+ // --user-data-dir is found only if it is one of these; there is no list of
833
+ // them to read, and nothing is guessed from a folder's contents.
834
+ const CLIENT_PROFILES = ["Trae CN", "TRAE SOLO CN", "trae-cn"]
835
+
829
836
  function clientStoragePaths(platform, home, env) {
830
837
  const file = (root, name) => join(root, name, "User", "globalStorage", "storage.json")
831
- if (platform === "darwin") return [file(join(home, "Library", "Application Support"), "Trae CN")]
838
+ if (platform === "darwin") return CLIENT_PROFILES.filter((n) => n !== "trae-cn").map((n) => file(join(home, "Library", "Application Support"), n))
832
839
  if (platform === "win32") {
833
840
  // the two roots are often the same folder: APPDATA and the home's own
834
841
  // AppData\Roaming. They are compared as one system spells them, so a
@@ -840,19 +847,28 @@ function clientStoragePaths(platform, home, env) {
840
847
  if (roots.some((was) => spell(was) === spell(r))) continue
841
848
  roots.push(r)
842
849
  }
843
- return roots.flatMap((r) => ["Trae CN", "trae-cn"].map((n) => file(r, n)))
850
+ return roots.flatMap((r) => CLIENT_PROFILES.map((n) => file(r, n)))
844
851
  }
845
- if (platform === "linux") return ["trae-cn", "Trae CN"].map((n) => file(env.XDG_CONFIG_HOME || join(home, ".config"), n))
852
+ if (platform === "linux") return CLIENT_PROFILES.map((n) => file(env.XDG_CONFIG_HOME || join(home, ".config"), n))
846
853
  return []
847
854
  }
848
855
 
849
- // clientDevice is the device id of this machine's Trae CN client when the
850
- // client is signed in as uid, else undefined. It only reads: nothing of
851
- // the client's is written, kept or sent but that id, and the client's own
852
- // token is read past, never used. A file it can't read or make out, or one
853
- // with no single device, names no device, and the account keeps its own.
854
- // Never an id made up or changed: an account the client isn't signed in
855
- // as never gets the client's id.
856
+ // clientDevice is the device id of a Trae CN client profile on this machine
857
+ // that is signed in as uid, else undefined. It only reads: nothing of the
858
+ // client's is written, kept or sent but that id, and the client's own token
859
+ // is read past, never used. Never an id made up or changed: an account no
860
+ // profile is signed in as never gets a client's id.
861
+ //
862
+ // A machine can hold more than one profile — Trae CN and TRAE SOLO CN each
863
+ // write one, and a client started with its own --user-data-dir writes
864
+ // another — and each profile's storage.json names the account it is signed
865
+ // in as, with that account's device. So every profile is read and the one
866
+ // signed in as uid answers with its device, which is what lets two accounts
867
+ // each check in on a real client's device. A profile that isn't signed in
868
+ // as uid, that can't be read or made out, or that names no single device
869
+ // is passed over rather than ending the search: before, the first profile
870
+ // read decided for all of them, so a second account's own profile was never
871
+ // seen (and one unreadable file hid every profile after it).
856
872
  async function clientDevice(uid) {
857
873
  if (!uid) return undefined
858
874
  for (const p of ownClient.paths()) {
@@ -862,14 +878,19 @@ async function clientDevice(uid) {
862
878
  } catch {
863
879
  continue // not installed here
864
880
  }
881
+ let doc
882
+ try {
883
+ doc = JSON.parse(text)
884
+ } catch {
885
+ continue // not a file we can make out: the next profile may be
886
+ }
865
887
  try {
866
- const doc = JSON.parse(text)
867
888
  const ids = Object.keys(doc ?? {}).filter((k) => k.startsWith(DC_KEY)).map((k) => k.slice(DC_KEY.length))
868
- if (ids.length !== 1 || !/^\d{8,20}$/.test(ids[0])) return undefined
889
+ if (ids.length !== 1 || !/^\d{8,20}$/.test(ids[0])) continue
869
890
  const who = clientAuth(doc[AUTH_KEY])?.userId
870
- return who != null && String(who) === String(uid) ? ids[0] : undefined
891
+ if (who != null && String(who) === String(uid)) return ids[0]
871
892
  } catch {
872
- return undefined
893
+ continue // a sign-in that doesn't decode: the next profile may be
873
894
  }
874
895
  }
875
896
  return undefined
@@ -2572,13 +2593,16 @@ const MAX = "-max"
2572
2593
  // (light, high, extra_high) and the one it defaults to. The request takes the
2573
2594
  // same names, as reasoning_effort, so a level an agent picks is sent as Trae
2574
2595
  // spells it. OpenCode knows the three as low/high/xhigh, so the map goes both
2575
- // ways.
2596
+ // ways. Trae's client names two more, medium and max (its bundle's
2597
+ // reasoning_effort.{low,medium,high,extra_high,max}), handed out per model:
2598
+ // its app offers DeepSeek V4.1 Flash max (#434), so they are spelled here too,
2599
+ // and still offered only for a model whose entry names them.
2576
2600
  //
2577
2601
  // An entry without the config takes no level: Trae serves it at its own
2578
2602
  // default and nothing is sent. The plugin used to send none for every model,
2579
2603
  // which read as "Trae takes no level" — it takes them, per model, and an
2580
2604
  // entry that names none is the exception rather than the rule.
2581
- const LEVELS = { light: "low", high: "high", extra_high: "xhigh" }
2605
+ const LEVELS = { light: "low", medium: "medium", high: "high", extra_high: "xhigh", max: "max" }
2582
2606
 
2583
2607
  // levelsOf is an entry's levels in OpenCode's names, in Trae's order: [] when
2584
2608
  // the entry names none, null when it names ones this map can't spell (so a
@@ -2874,34 +2898,78 @@ const makePlugin = (site) => async ({ client }) => {
2874
2898
  return new Response(text, { status: res.status, headers })
2875
2899
  }
2876
2900
 
2877
- // checkin asks Trae CN's own pages as magpie's check-in host does, with
2878
- // the same account, device and renewal as any own page. Trae Global has
2879
- // no such page.
2901
+ // checkinStatus reads the check-in's status page: its body, or an outcome
2902
+ // to give back as it is (a turned-away sign-in, Trae's own error, a reply
2903
+ // that isn't one). checkin reads it before the claim, and again after it.
2904
+ const checkinStatus = async (getAuth, a0) => {
2905
+ const res = await ownRequest(getAuth, a0, site.hosts.auth + site.checkin.status.path, "POST", "{}")
2906
+ const text = await res.text()
2907
+ let body
2908
+ try { body = JSON.parse(text) } catch {}
2909
+ if (!body || typeof body !== "object" || Array.isArray(body)) return { outcome: { outcome: "failed", message: `check-in status: unreadable reply ${text.trim().slice(0, 120) || "(empty)"}` } }
2910
+ const e = errorOf(body)
2911
+ if (res.status === 401 || lapsedCode(e.code, site)) return { outcome: { outcome: "failed", message: e.message || `${a0.name || a0.uid}: Trae's sign-in has expired` } }
2912
+ if (res.status !== 200 || (e.code && String(e.code) !== "0")) return { outcome: { outcome: "failed", message: e.message || statusLine(res.status) } }
2913
+ return { body }
2914
+ }
2915
+
2880
2916
  const checkin = async (getAuth) => {
2881
2917
  if (!site.checkin) return { outcome: "inactive", message: `${site.realm} has no daily check-in` }
2882
2918
  const a0 = fromAuth(await getAuth(), site)
2883
2919
  if (!a0) return { outcome: "failed", message: "not signed in" }
2884
- const base = site.hosts.auth + site.checkin.status.path
2885
- const status = await ownRequest(getAuth, a0, base, "POST", "{}")
2886
- const statusText = await status.text()
2887
- let body
2888
- try { body = JSON.parse(statusText) } catch {}
2889
- if (!body || typeof body !== "object" || Array.isArray(body)) return { outcome: "failed", message: `check-in status: unreadable reply ${statusText.trim().slice(0, 120) || "(empty)"}` }
2890
- const e = errorOf(body)
2891
- if (status.status === 401 || lapsedCode(e.code, site)) return { outcome: "failed", message: e.message || `${a0.name || a0.uid}: Trae's sign-in has expired` }
2892
- if (status.status !== 200 || (e.code && String(e.code) !== "0")) return { outcome: "failed", message: e.message || statusLine(status.status) }
2893
- if (body?.checked_in) return { outcome: "done", ...(Number(body.credits) > 0 ? { credit: Number(body.credits) } : {}) }
2894
- if (!body?.enable) return { outcome: "inactive" }
2920
+ const first = await checkinStatus(getAuth, a0)
2921
+ if (first.outcome) return first.outcome
2922
+ const body = first.body
2923
+ if (body.checked_in) return { outcome: "done", ...(Number(body.credits) > 0 ? { credit: Number(body.credits) } : {}) }
2924
+ if (!body.enable) return { outcome: "inactive" }
2895
2925
  const claim = await ownRequest(getAuth, a0, site.hosts.auth + site.checkin.claim.path, "POST", "{}")
2896
2926
  const claimText = await claim.text()
2897
2927
  let answer
2898
2928
  try { answer = JSON.parse(claimText) } catch {}
2899
2929
  const ce = errorOf(answer)
2900
2930
  if (claim.status === 401 || lapsedCode(ce.code, site)) return { outcome: "failed", message: ce.message || `${a0.name || a0.uid}: Trae's sign-in has expired` }
2931
+ // 9095: this device is in for today, whoever took it — the day's answer
2901
2932
  if (String(ce.code) === "9095") return { outcome: "ineligible", message: ce.message || "this device has checked in today" }
2902
- if (String(ce.code) === "9074") return { outcome: "ineligible", message: `${ce.message} — check in in the Trae CN app` }
2933
+ // 9074: Trae did not take this claim for this account's device. Its own
2934
+ // words say the pool is busy, which measurements do not bear out: the
2935
+ // same account, token and headers were taken on a 16-digit id where a
2936
+ // 19-digit one got 9074 (plugins#194), and on 2026-10-23 the same
2937
+ // account and headers were taken on a device id Trae had never seen
2938
+ // while this account's own id answered 9074. So the message says what
2939
+ // Trae said and that the device is what it refused, rather than
2940
+ // sending people to check in in Trae CN's app, which is not where the
2941
+ // problem is. No device id is made up or rotated to get round this:
2942
+ // the account keeps its own until a client of its own is here to sign
2943
+ // it in. Nothing is said of signing one in: it is the one thing seen
2944
+ // to change a 9074, but it hasn't been tried on a real account, and
2945
+ // on a claim that already rode a client's device it is what the user
2946
+ // did — the advice would only send them back to do it again.
2947
+ if (String(ce.code) === "9074") return { outcome: "ineligible", message: `${ce.message} (9074) — Trae CN did not take this check-in for this account's device` }
2903
2948
  if (claim.status !== 200 || (ce.code && String(ce.code) !== "0")) return { outcome: "failed", message: ce.message || statusLine(claim.status) }
2904
2949
  if (!answer || typeof answer !== "object" || Array.isArray(answer)) return { outcome: "failed", message: `check-in claim: unreadable reply ${claimText.trim().slice(0, 120) || "(empty)"}` }
2950
+ // a claim's own code 0 is not proof the day is in: Trae answers it for
2951
+ // a claim that registered nothing, which cli2api works around by asking
2952
+ // the status again (internal/providers/trae/checkin.go). So success is
2953
+ // what the status page says after the claim, and one that didn't land
2954
+ // is never reported as claimed.
2955
+ //
2956
+ // Nor is it a `failed`. magpie retries a failed check-in with backoff
2957
+ // for the rest of the day, up to every 30 minutes, and each retry is
2958
+ // another claim: a claim answered code 0 while the status still says
2959
+ // the day is out is the very case that would multiply, since code 0
2960
+ // for a claim that registered nothing is this plugin's own premise —
2961
+ // an account that claimed once a day would claim many times. A status
2962
+ // that lags a claim that did land reads as not in once, which is a
2963
+ // false failure and a second claim. So once a claim has been sent,
2964
+ // what the read-back says is the day's answer and nothing is claimed
2965
+ // again: `ineligible`, as a 9095 is. A read-back that itself comes to
2966
+ // nothing — the network, an answer that isn't one — is the same: the
2967
+ // claim was made, and whether the day is in is not known, so it is
2968
+ // read again on the next press rather than claimed again.
2969
+ const noMore = (o) => (o.outcome === "failed" ? { outcome: "ineligible", message: `${o.message} — nothing is claimed again today` } : o)
2970
+ const back = await checkinStatus(getAuth, a0)
2971
+ if (back.outcome) return noMore(back.outcome)
2972
+ if (!back.body.checked_in) return { outcome: "ineligible", message: `${site.realm}: the claim answered success, but the day's check-in is not in — nothing is claimed again today` }
2905
2973
  // The credit is the day's credits, never credits plus extra_credits.
2906
2974
  // Trae CN's real claim answers {"code":0,"message":"success"}, with no
2907
2975
  // credit at all (plugins#194), so the status's credits stand in. Its
@@ -2912,7 +2980,7 @@ const makePlugin = (site) => async ({ client }) => {
2912
2980
  // taken first; one naming extra_credits alone is that (#260). Either
2913
2981
  // may be absent, and Number(undefined) is NaN, which reads as none.
2914
2982
  const n = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0)
2915
- const credit = n(answer.credits) || n(answer.extra_credits) || n(body.credits)
2983
+ const credit = n(answer.credits) || n(answer.extra_credits) || n(back.body.credits)
2916
2984
  return { outcome: "claimed", ...(credit > 0 ? { credit } : {}) }
2917
2985
  }
2918
2986
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-trae-auth",
3
- "version": "0.2.21",
3
+ "version": "0.2.23",
4
4
  "description": "OpenCode provider plugins for Trae: Trae CN (trae.cn) and Trae Global (trae.ai), free tier included, with Trae's own browser sign-in",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",