agent-dag 1.47.0 → 3.0.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.
@@ -18,6 +18,7 @@
18
18
  // goes through one mutex.
19
19
  import { AsyncLocalStorage } from "node:async_hooks";
20
20
  import { existsSync } from "node:fs";
21
+ import { brotliCompressSync, brotliDecompressSync, constants as zlibConstants } from "node:zlib";
21
22
  import { readFile } from "node:fs/promises";
22
23
  import { homedir } from "node:os";
23
24
  import { join } from "node:path";
@@ -37,7 +38,48 @@ const CODE_VERDICT_MS = 60_000;
37
38
  // How long a shared account stays importable. Long enough to walk to the other
38
39
  // machine, short enough that a copy left in clipboard history goes stale.
39
40
  export const SHARE_TTL_MS = 10 * 60_000;
40
- const SHARE_PREFIX = "ccdeck1:";
41
+ // How many accounts one bundle may carry. See shareAccounts.
42
+ const MAX_SHARE_ACCOUNTS = 50;
43
+
44
+ /**
45
+ * The prefix a share is written with, and the one it used to be written with.
46
+ *
47
+ * `ccdeck1:` is base64 of the envelope JSON. `ccdeck2:` is base64 of the same
48
+ * JSON compressed, and the difference is not cosmetic — it was reported from
49
+ * the panel as "the text is very large", and on a real store it is:
50
+ *
51
+ * 1 account 2200 characters -> 1024
52
+ * 3 accounts 6168 characters -> 1816
53
+ *
54
+ * Because what a bundle mostly contains is not credentials. An account's two
55
+ * OAuth tokens are 216 characters between them; the envelope around them is
56
+ * two thousand, and every one of its key names — `refreshTokenExpiresAt`,
57
+ * `organizationRateLimitTier`, `claudeCodeTrialDurationDays` — repeats
58
+ * verbatim for every account added. That is exactly what a compressor is for,
59
+ * which is why the saving grows with the number of accounts rather than
60
+ * shrinking.
61
+ *
62
+ * Brotli rather than gzip: 10% smaller here, in node:zlib since v11, no
63
+ * dependency either way.
64
+ *
65
+ * BOTH prefixes are read, so a blob copied before this change still imports.
66
+ * Only `ccdeck2:` is written, which does mean a deck older than this cannot
67
+ * read a new share — it will say the text does not look like a shared account.
68
+ * That cost is paid once and it is smallest now: the feature shipped in
69
+ * 1.48.0 and the format has had no time to spread.
70
+ */
71
+ const SHARE_PREFIX = "ccdeck2:";
72
+ const SHARE_PREFIX_V1 = "ccdeck1:";
73
+
74
+ /**
75
+ * The most an imported blob may decompress to.
76
+ *
77
+ * A few hundred bytes of brotli can name gigabytes of output, and this input
78
+ * arrives by paste from wherever the user found it. 50 accounts at ~1.6 KB of
79
+ * envelope each is under 100 KB, so 2 MB is twenty times the largest bundle
80
+ * this deck will ever produce and still nothing to allocate by accident.
81
+ */
82
+ const SHARE_MAX_BYTES = 2 << 20;
41
83
 
42
84
  // ── serialization ────────────────────────────────────────────────────────────
43
85
 
@@ -163,10 +205,14 @@ export async function readStore() {
163
205
  return {
164
206
  slots: Object.keys(accounts),
165
207
  emails: Object.fromEntries(Object.entries(accounts).map(([k, v]) => [k, v?.email ?? ""])),
208
+ // The other half of the identity claude-swap keys an account by. One
209
+ // address under two organizations is two accounts on purpose, and a
210
+ // bundle carrying both must not report them as one - see identityKey.
211
+ orgs: Object.fromEntries(Object.entries(accounts).map(([k, v]) => [k, v?.organizationUuid ?? ""])),
166
212
  activeNum: seq?.activeAccountNumber ?? null,
167
213
  };
168
214
  } catch {
169
- return { slots: [], emails: {}, activeNum: null };
215
+ return { slots: [], emails: {}, orgs: {}, activeNum: null };
170
216
  }
171
217
  }
172
218
 
@@ -718,16 +764,37 @@ async function restoreActive(num) {
718
764
  */
719
765
  export function wrapShare(payload, now = Date.now(), ttlMs = SHARE_TTL_MS) {
720
766
  const body = JSON.stringify({ v: 1, exp: now + ttlMs, payload });
721
- return SHARE_PREFIX + Buffer.from(body, "utf8").toString("base64");
767
+ const packed = brotliCompressSync(Buffer.from(body, "utf8"), {
768
+ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 },
769
+ });
770
+ return SHARE_PREFIX + packed.toString("base64");
722
771
  }
723
772
 
724
- /** The inverse. Returns `{ok:true, payload}` or `{ok:false, reason}`. */
773
+ /**
774
+ * The inverse, for either prefix. Returns `{ok:true, payload}` or
775
+ * `{ok:false, reason}`.
776
+ *
777
+ * `v` inside the envelope is unchanged at 1 and deliberately so: it versions
778
+ * the SHAPE of the envelope, and that shape did not change. The prefix versions
779
+ * the encoding. Folding the two would have made an old blob unreadable for no
780
+ * reason, since its contents are exactly what this still expects.
781
+ */
725
782
  export function unwrapShare(blob, now = Date.now()) {
726
783
  const text = String(blob ?? "").trim();
727
- if (!text.startsWith(SHARE_PREFIX)) return { ok: false, reason: "not_a_share" };
784
+ const v2 = text.startsWith(SHARE_PREFIX);
785
+ if (!v2 && !text.startsWith(SHARE_PREFIX_V1)) return { ok: false, reason: "not_a_share" };
728
786
  let env;
729
787
  try {
730
- env = JSON.parse(Buffer.from(text.slice(SHARE_PREFIX.length), "base64").toString("utf8"));
788
+ // Sliced by the prefix that actually matched. The two are the same length
789
+ // today and writing it this way is what keeps that from being load-bearing.
790
+ const bytes = Buffer.from(text.slice((v2 ? SHARE_PREFIX : SHARE_PREFIX_V1).length), "base64");
791
+ // maxOutputLength is the whole reason a bounded decompress is safe to point
792
+ // at pasted text; without it a short blob can name an allocation that ends
793
+ // the process.
794
+ const body = v2
795
+ ? brotliDecompressSync(bytes, { maxOutputLength: SHARE_MAX_BYTES })
796
+ : bytes;
797
+ env = JSON.parse(body.toString("utf8"));
731
798
  } catch {
732
799
  return { ok: false, reason: "corrupt" };
733
800
  }
@@ -738,39 +805,352 @@ export function unwrapShare(blob, now = Date.now()) {
738
805
  return { ok: true, payload: env.payload };
739
806
  }
740
807
 
808
+ /**
809
+ * claude-swap's own identity for an account, as one comparable string.
810
+ *
811
+ * `(email, organizationUuid)`, which is the composite `transfer.py` keys its
812
+ * duplicate check and its already-here check on. Matching on the address alone
813
+ * would fold one address's two organizations into a single row, and the whole
814
+ * reason cswap carries the org is that they are two accounts.
815
+ *
816
+ * The address is lower-cased because both sides of every comparison here come
817
+ * from the same store or the same bundle, so folding case can only join what a
818
+ * human would call the same account, never split one.
819
+ */
820
+ export function identityKey(email, org) {
821
+ return `${String(email ?? "").trim().toLowerCase()} ${String(org ?? "")}`;
822
+ }
823
+
824
+ /**
825
+ * N single-account envelopes, folded into the one bundle cswap will take back.
826
+ *
827
+ * `cswap export --account` names ONE account, so a chosen subset cannot come
828
+ * out of a single call and the deck has to do the folding. It is deliberately
829
+ * not a new format: the head envelope is spread whole and only `accounts` and
830
+ * `activeAccountNumber` are replaced, so `version`, `exportedFrom`,
831
+ * `swapVersion` and any field a later claude-swap adds arrive on the far side
832
+ * exactly as that claude-swap wrote them. Nothing here hard-codes its
833
+ * FORMAT_VERSION, because a constant copied out of another project's source is
834
+ * a constant that drifts.
835
+ *
836
+ * `activeAccountNumber` is re-guarded rather than carried: cswap only records
837
+ * it when that slot is in the payload, and a subset can drop the slot the head
838
+ * envelope pointed at. An import that referenced a missing account would be
839
+ * seeding an active slot that never arrived.
840
+ *
841
+ * A duplicate identity is DROPPED rather than passed on. `import_accounts`
842
+ * refuses a whole envelope over one repeated `(email, org)` pair, so carrying
843
+ * it would trade five shared accounts for a bundle that imports none.
844
+ */
845
+ export function mergeExports(texts) {
846
+ const envelopes = [];
847
+ for (const text of texts) {
848
+ let env;
849
+ try { env = JSON.parse(text); } catch { return { ok: false, reason: "unreadable_export" }; }
850
+ if (!env || typeof env !== "object" || !Array.isArray(env.accounts)) {
851
+ return { ok: false, reason: "unreadable_export" };
852
+ }
853
+ envelopes.push(env);
854
+ }
855
+ if (!envelopes.length) return { ok: false, reason: "nothing_to_share" };
856
+
857
+ const head = envelopes[0];
858
+ // Every part came out of one binary in one pass, so a disagreement here is
859
+ // not a version to reconcile - it is a sign the parts are not what we think.
860
+ if (envelopes.some(e => e.version !== head.version)) return { ok: false, reason: "mixed_versions" };
861
+
862
+ const accounts = [];
863
+ const dropped = [];
864
+ const seen = new Set();
865
+ for (const env of envelopes) {
866
+ for (const a of env.accounts) {
867
+ const key = identityKey(a?.email, a?.organizationUuid);
868
+ if (seen.has(key)) { dropped.push({ num: String(a?.number ?? ""), email: a?.email ?? "" }); continue; }
869
+ seen.add(key);
870
+ accounts.push(a);
871
+ }
872
+ }
873
+ if (!accounts.length) return { ok: false, reason: "nothing_to_share" };
874
+
875
+ const nums = new Set(accounts.map(a => String(a?.number ?? "")));
876
+ const active = envelopes
877
+ .map(e => e.activeAccountNumber)
878
+ .find(n => n != null && nums.has(String(n)));
879
+ return {
880
+ ok: true,
881
+ dropped,
882
+ envelope: { ...head, activeAccountNumber: active ?? null, accounts },
883
+ };
884
+ }
885
+
886
+ /**
887
+ * One account, or several, packaged for another deck.
888
+ *
889
+ * claude-swap's envelope carries each account's OAuth token in the clear - its
890
+ * own module header says so ("No encryption is built in"). The wrapper adds an
891
+ * expiry so a copy left behind in clipboard history stops working, and nothing
892
+ * more: it is not encryption and is not presented as any. A bundle makes that
893
+ * larger, not different - five accounts is five tokens on the clipboard -
894
+ * which is why the dialog states the count before the copy rather than after.
895
+ *
896
+ * Say the limit of the expiry out loud, because the UI used to imply the
897
+ * opposite. `exp` is a plain number inside plain base64'd JSON with NO key, MAC
898
+ * or signature over it, so anyone holding the text can decode it, write a later
899
+ * `exp`, re-encode, and import it. unwrapShare's check is therefore a check
900
+ * against staleness, not against an adversary - and it cannot be made into one
901
+ * here. A MAC needs a secret both decks hold, and two decks that already shared
902
+ * a secret would not need this function; and even a perfect signature would
903
+ * only stop THIS import path, since the payload it wraps is the credential
904
+ * itself and `cswap import` accepts it unwrapped. The honest fix for a share
905
+ * that got away is to sign the account out and back in. See share-expiry-
906
+ * forgeable.test.ts, which pins the forgery rather than leaving it implied.
907
+ *
908
+ * Only the accounts asked for are exported. Reading the whole store and then
909
+ * dropping the unwanted rows would be one spawn instead of several, and it
910
+ * would pull a refresh token this deck was never asked to move into this
911
+ * process; it would also lose the failure, because a whole-store export skips a
912
+ * slot with no backup credentials in silence while `--account` on that slot is
913
+ * a hard error naming it. A count that is quietly short is the one outcome a
914
+ * share must never have.
915
+ *
916
+ * The default export shape is used deliberately, never --full, which would
917
+ * embed the entire ~/.claude.json including every project and MCP server.
918
+ */
919
+ export async function shareAccounts(nums) {
920
+ const asked = Array.isArray(nums) ? nums : [nums];
921
+ // One spawn per account, so the length of this list is a length of time the
922
+ // request holds. A store never has fifty accounts; a caller that sends nine
923
+ // hundred numbers is not a person picking from a panel, and the ceiling
924
+ // costs nothing to the one who is.
925
+ if (asked.length > MAX_SHARE_ACCOUNTS) return { ok: false, reason: "too_many" };
926
+ const wanted = [];
927
+ for (const raw of asked) {
928
+ const n = Number(raw);
929
+ if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
930
+ if (!wanted.includes(n)) wanted.push(n);
931
+ }
932
+ if (!wanted.length) return { ok: false, reason: "bad_account" };
933
+
934
+ // Names for the accounts that fail, read from the store before anything is
935
+ // spawned - because the failure sentence must never be built from the
936
+ // export's own output.
937
+ const store = await readStore();
938
+ const texts = [];
939
+ const failed = [];
940
+ for (const n of wanted) {
941
+ const r = await run(await cswapBin(), ["export", "-", "--account", String(n)], { timeout: CSWAP_TIMEOUT_MS });
942
+ if (!r.ok || !r.stdout.trim()) {
943
+ // The failure sentence is built from stderr ALONE for this one command,
944
+ // because its stdout is the credential. `failureText` concatenates
945
+ // `${stderr}\n${stdout}` and `firstUseful` takes the LAST non-empty line -
946
+ // right for every other cswap command, and here it means any stdout at all
947
+ // outranks the real error. claude-swap writes its diagnostics to stderr
948
+ // specifically so stdout stays pure JSON in pipe mode, and it writes the
949
+ // envelope as its last act; a non-zero exit after a partial write would
950
+ // therefore put the tail of `json.dumps(envelope, indent=2)` in front of the
951
+ // user, and one of those lines is the refresh token on its own.
952
+ //
953
+ // Nothing is lost by dropping it: the ENOENT branch keys off `r.code`, which
954
+ // `run` sets, and cmd.exe's "is not recognized" is stderr's.
955
+ failed.push({
956
+ num: String(n),
957
+ email: store.emails?.[String(n)] || "",
958
+ detail: failureText({ ...r, stdout: "" }, "cswap export"),
959
+ });
960
+ continue;
961
+ }
962
+ texts.push(r.stdout);
963
+ }
964
+
965
+ // Nothing came out at all. There is no partial bundle to hand over, so this
966
+ // is the plain failure the single-account share has always reported.
967
+ if (!texts.length) {
968
+ return { ok: false, reason: "export_failed", detail: failed[0]?.detail ?? "", failed };
969
+ }
970
+
971
+ const merged = mergeExports(texts);
972
+ if (!merged.ok) return { ok: false, reason: merged.reason, failed };
973
+ for (const d of merged.dropped) {
974
+ failed.push({ ...d, detail: "another slot already holds this address in this organization" });
975
+ }
976
+
977
+ const shared = merged.envelope.accounts.map(a => ({ num: String(a?.number ?? ""), email: a?.email ?? "" }));
978
+ return {
979
+ ok: true,
980
+ blob: wrapShare(JSON.stringify(merged.envelope)),
981
+ expiresAt: Date.now() + SHARE_TTL_MS,
982
+ // What the bundle CARRIES, never what was asked for. The copy row counts
983
+ // this list, so a bundle that came up short says so.
984
+ shared,
985
+ failed,
986
+ };
987
+ }
988
+
989
+ /**
990
+ * The one-account case, which is a bundle of one and takes the same path.
991
+ *
992
+ * That path PARSES what cswap wrote, where this used to hand its stdout on
993
+ * opaquely — the cost of `shared` being able to promise that the count on the
994
+ * copy button is the count in the blob. So an export shape the fold cannot read
995
+ * now fails the single share too, not just the bundle. Deliberate: one path
996
+ * means the two cannot drift into two envelope shapes, and a share that
997
+ * silently carried something this deck could not account for is the failure
998
+ * the count exists to prevent.
999
+ */
741
1000
  export async function shareAccount(num) {
742
- const n = Number(num);
743
- if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
744
- const r = await run(await cswapBin(), ["export", "-", "--account", String(n)], { timeout: CSWAP_TIMEOUT_MS });
745
- if (!r.ok || !r.stdout.trim()) {
746
- // The failure sentence is built from stderr ALONE for this one command,
747
- // because its stdout is the credential. `failureText` concatenates
748
- // `${stderr}\n${stdout}` and `firstUseful` takes the LAST non-empty line
749
- // right for every other cswap command, and here it means any stdout at all
750
- // outranks the real error. claude-swap writes its diagnostics to stderr
751
- // specifically so stdout stays pure JSON in pipe mode, and it writes the
752
- // envelope as its last act; a non-zero exit after a partial write would
753
- // therefore put the tail of `json.dumps(envelope, indent=2)` in front of the
754
- // user, and one of those lines is the refresh token on its own.
755
- //
756
- // Nothing is lost by dropping it: the ENOENT branch keys off `r.code`, which
757
- // `run` sets, and cmd.exe's "is not recognized" is stderr's.
758
- return { ok: false, reason: "export_failed", detail: failureText({ ...r, stdout: "" }, "cswap export") };
1001
+ return shareAccounts([num]);
1002
+ }
1003
+
1004
+ /**
1005
+ * The identities a bundle carries, or `[]` when it cannot be read.
1006
+ *
1007
+ * The payload is the credential, so this takes the two fields it needs and
1008
+ * nothing else: no caller ever receives the parsed envelope, and a bundle that
1009
+ * will not parse degrades to an unnamed import rather than to an error, since
1010
+ * cswap is the one entitled to refuse it.
1011
+ */
1012
+ export function bundleAccounts(payload) {
1013
+ let env;
1014
+ try { env = JSON.parse(payload); } catch { return []; }
1015
+ if (!env || typeof env !== "object" || !Array.isArray(env.accounts)) return [];
1016
+ const out = [];
1017
+ for (const a of env.accounts) {
1018
+ if (!a || typeof a !== "object") continue;
1019
+ const email = typeof a.email === "string" ? a.email.trim() : "";
1020
+ if (!email) continue;
1021
+ out.push({ email, org: typeof a.organizationUuid === "string" ? a.organizationUuid : "" });
1022
+ }
1023
+ // All of them or none. `wanted` is what the result list counts against, so a
1024
+ // bundle read as three when it holds four reports "1 of 3 imported" about a
1025
+ // paste of four - the missing one arrives and is never named, and a non-empty
1026
+ // list keeps the store-diff fallback from running to catch it. claude-swap
1027
+ // itself refuses an envelope whose entry has no address, so this is a guard
1028
+ // against a shape neither project has today rather than a live case.
1029
+ return out.length === env.accounts.length ? out : [];
1030
+ }
1031
+
1032
+ /**
1033
+ * The same bundle, cut down to one account.
1034
+ *
1035
+ * What "update anyway" sends. `--force` overwrites every account it matches, so
1036
+ * a forced import of the whole bundle would rewrite credentials the user never
1037
+ * pointed at; narrowing first is what keeps an overwrite a named act. Written
1038
+ * as a filter over the original envelope rather than a fresh one, for the same
1039
+ * reason mergeExports spreads its head: those fields belong to claude-swap.
1040
+ */
1041
+ export function narrowBundle(payload, key) {
1042
+ let env;
1043
+ try { env = JSON.parse(payload); } catch { return { ok: false, reason: "corrupt" }; }
1044
+ if (!env || typeof env !== "object" || !Array.isArray(env.accounts)) return { ok: false, reason: "corrupt" };
1045
+ const accounts = env.accounts.filter(a => identityKey(a?.email, a?.organizationUuid) === key);
1046
+ if (!accounts.length) return { ok: false, reason: "not_in_bundle" };
1047
+ const nums = new Set(accounts.map(a => String(a?.number ?? "")));
1048
+ const active = env.activeAccountNumber != null && nums.has(String(env.activeAccountNumber))
1049
+ ? env.activeAccountNumber
1050
+ : null;
1051
+ return { ok: true, payload: JSON.stringify({ ...env, activeAccountNumber: active, accounts }) };
1052
+ }
1053
+
1054
+ /**
1055
+ * What happened to each account in the bundle, decided by the store.
1056
+ *
1057
+ * The store is the fact. `cswap import` narrates itself per account on stderr,
1058
+ * and parsing that as the primary answer would make a reworded release report
1059
+ * imports that did not happen - the failure mode `newSlot` already refuses for
1060
+ * the same reason. So the slot map before and after the run decides the two
1061
+ * outcomes that matter: an identity holding a slot it did not hold arrived, and
1062
+ * one absent from both never came.
1063
+ *
1064
+ * stderr is then read for one thing only, and one no store diff can show: an
1065
+ * account already present whose credentials were REWRITTEN in place, which
1066
+ * moves no slot. `Replaced` is claude-swap's dead-token auto-heal (its #136),
1067
+ * `Overwrote` is a `--force`. If either line is ever reworded the nuance is
1068
+ * lost and the row reads "already here", which is still true - the degradation
1069
+ * is a less specific report, never a wrong one.
1070
+ *
1071
+ * And it is applied ONLY where the address names exactly one row in the bundle.
1072
+ * cswap's line carries no organization, so with one address held under two of
1073
+ * them a single `Replaced me@x.com` would mark both rows healed and one of
1074
+ * those would be false - which is the thing the paragraph above promises this
1075
+ * never does.
1076
+ */
1077
+ export function importOutcomes(before, after, wanted, stderr = "") {
1078
+ const slotsBy = (store) => new Map(
1079
+ (store?.slots ?? []).map(s => [identityKey(store?.emails?.[s], store?.orgs?.[s]), s]),
1080
+ );
1081
+ const had = slotsBy(before);
1082
+ const now = slotsBy(after);
1083
+
1084
+ // How many rows in this bundle share each address. An address held twice is
1085
+ // an address cswap's own narration cannot resolve.
1086
+ const byAddress = new Map();
1087
+ for (const w of wanted) {
1088
+ const a = String(w.email ?? "").trim().toLowerCase();
1089
+ byAddress.set(a, (byAddress.get(a) ?? 0) + 1);
1090
+ }
1091
+ const rewritten = new Map();
1092
+ for (const line of String(stderr ?? "").split(/\r?\n/)) {
1093
+ const m = /^\s*(Replaced|Overwrote)\s+(\S+)/.exec(line);
1094
+ if (!m) continue;
1095
+ const addr = String(m[2]).trim().toLowerCase();
1096
+ if ((byAddress.get(addr) ?? 0) !== 1) continue;
1097
+ rewritten.set(addr, m[1] === "Replaced" ? "healed" : "updated");
759
1098
  }
760
- return { ok: true, blob: wrapShare(r.stdout), expiresAt: Date.now() + SHARE_TTL_MS };
1099
+
1100
+ return wanted.map(w => {
1101
+ const key = identityKey(w.email, w.org);
1102
+ const wasHere = had.has(key);
1103
+ const slot = now.get(key) ?? null;
1104
+ if (!wasHere && slot != null) return { email: w.email, org: w.org, num: slot, state: "imported" };
1105
+ if (wasHere) {
1106
+ return {
1107
+ email: w.email,
1108
+ org: w.org,
1109
+ num: had.get(key),
1110
+ state: rewritten.get(String(w.email ?? "").trim().toLowerCase()) ?? "present",
1111
+ };
1112
+ }
1113
+ return { email: w.email, org: w.org, num: null, state: "failed" };
1114
+ });
761
1115
  }
762
1116
 
763
- export async function importAccount(blob) {
1117
+ /**
1118
+ * A share, or a bundle of them, taken into this deck's store.
1119
+ *
1120
+ * Non-destructive by default and deliberately so: without `--force` claude-swap
1121
+ * adds what is missing, leaves a healthy account exactly as it is, and replaces
1122
+ * only a slot its own usage row has quarantined as refresh-token-dead. That is
1123
+ * already the rule a person would ask for - leave what works, fix what does
1124
+ * not - so the default run never passes the flag.
1125
+ *
1126
+ * `force` is honoured ONLY together with `only`, which names a single account.
1127
+ * A forced import of a whole bundle would rewrite every matching credential on
1128
+ * this machine, and a fresh token replaced by a stale one is not recoverable
1129
+ * from here - the fix is a re-login. Requiring the pair is what makes the
1130
+ * clobber something a person chose while looking at the address.
1131
+ */
1132
+ export async function importAccount(blob, { force = false, only = null } = {}) {
764
1133
  const un = unwrapShare(blob);
765
1134
  if (!un.ok) return { ok: false, reason: un.reason };
766
1135
 
1136
+ let payload = un.payload;
1137
+ const narrowing = only != null;
1138
+ if (narrowing) {
1139
+ const cut = narrowBundle(payload, identityKey(only?.email, only?.org));
1140
+ if (!cut.ok) return { ok: false, reason: cut.reason };
1141
+ payload = cut.payload;
1142
+ }
1143
+ const overwrite = force === true && narrowing;
1144
+ const wanted = bundleAccounts(payload);
1145
+
767
1146
  return withStoreLock(async () => {
768
1147
  const before = await readStore();
769
- const child = runInteractive(await cswapBin(), ["import", "-"], { timeout: CSWAP_TIMEOUT_MS });
770
- child.write(un.payload);
1148
+ const args = overwrite ? ["import", "-", "--force"] : ["import", "-"];
1149
+ const child = runInteractive(await cswapBin(), args, { timeout: CSWAP_TIMEOUT_MS });
1150
+ child.write(payload);
771
1151
  // cswap reads stdin to EOF, so the pipe has to close for it to proceed.
772
1152
  //
773
- // This used to write a raw EOT byte and then call `endStdin(child)` a
1153
+ // This used to write a raw EOT byte and then call `endStdin(child)` - a
774
1154
  // helper that was never written. EOT only means end-of-file on a TTY, so
775
1155
  // the byte did nothing to a pipe, and the call threw ReferenceError before
776
1156
  // cswap ever saw the payload: the route answered 500 and the dialog fell
@@ -782,15 +1162,32 @@ export async function importAccount(blob) {
782
1162
  if (!r.ok) return { ok: false, reason: "import_failed", detail: failureText(r, "cswap import") };
783
1163
 
784
1164
  const after = await readStore();
785
- const slot = newSlot(before, after);
786
1165
  invalidateClaudeAccountsCache();
787
- if (slot != null) runDetached(await cswapBin(), ["list"]);
788
- // Who arrived, so the dialog can name them instead of saying "an account".
789
- const email = slot != null ? (after.emails[slot] || null) : null;
790
- // No new slot is not an error: without --force, cswap skips an account it
791
- // already holds. Saying which happened is the difference between "it
792
- // worked" and "why is nothing different".
793
- return { ok: true, added: slot != null, num: slot, email, output: firstUseful(r.stdout) };
1166
+
1167
+ // An unreadable envelope was still imported, or still refused, by cswap -
1168
+ // only the naming is lost. Fall back to the store's own new slots so the
1169
+ // dialog can say what arrived even then.
1170
+ const results = wanted.length
1171
+ ? importOutcomes(before, after, wanted, r.stderr)
1172
+ : (() => {
1173
+ const had = new Set(before.slots ?? []);
1174
+ return (after.slots ?? []).filter(s => !had.has(s))
1175
+ .map(s => ({ email: after.emails?.[s] || "", org: after.orgs?.[s] || "", num: s, state: "imported" }));
1176
+ })();
1177
+
1178
+ const arrived = results.filter(x => x.state === "imported");
1179
+ if (arrived.length) runDetached(await cswapBin(), ["list"]);
1180
+ return {
1181
+ ok: true,
1182
+ results,
1183
+ // Nothing new is not an error: without --force, cswap skips an account it
1184
+ // already holds. Saying which happened is the difference between "it
1185
+ // worked" and "why is nothing different".
1186
+ added: arrived.length > 0,
1187
+ num: arrived.length === 1 ? arrived[0].num : null,
1188
+ email: arrived.length === 1 ? arrived[0].email : null,
1189
+ output: firstUseful(r.stdout),
1190
+ };
794
1191
  });
795
1192
  }
796
1193
 
@@ -807,6 +1204,58 @@ export function removePromptMatches(line, num) {
807
1204
  return Boolean(m) && m[1] === String(num);
808
1205
  }
809
1206
 
1207
+ /**
1208
+ * Re-capture the active slot's credentials, which is what unfreezes a row whose
1209
+ * stored copy died.
1210
+ *
1211
+ * WHY THIS EXISTS AND WHY IT IS NOT A LOGIN. claude-swap keeps its own copy of
1212
+ * each account's credentials, taken when the slot was added. When that copy's
1213
+ * refresh token dies it quarantines the row: no further collection is
1214
+ * attempted, so the numbers freeze and every Refresh re-reads a store that
1215
+ * cannot change. #721 fixed the sentence the panel said about that state; this
1216
+ * is the button that ends it.
1217
+ *
1218
+ * `cswap add` on an account already in the store is an idempotent credential
1219
+ * refresh — registerSignedIn above says so in its own words: "No new slot means
1220
+ * the account was already managed and cswap refreshed its credentials in
1221
+ * place." It captures whatever is signed in RIGHT NOW, which for the active
1222
+ * slot is the account the row belongs to, with the working credentials the user
1223
+ * already has. Nobody is signed in or out, no browser opens, and the active
1224
+ * account does not change.
1225
+ *
1226
+ * It only ever repairs the ACTIVE slot, because "what is signed in right now"
1227
+ * is the only thing `cswap add` can see. The panel offers it nowhere else.
1228
+ */
1229
+ export async function recaptureActive() {
1230
+ return withStoreLock(async () => {
1231
+ // Who is actually signed in, asked before and after, because `cswap add`
1232
+ // captures the live credentials and this is the one check that the thing it
1233
+ // captured is the thing the user meant.
1234
+ const before = await currentIdentity();
1235
+ if (!before?.email) return { ok: false, reason: "not_signed_in" };
1236
+
1237
+ const add = await run(await cswapBin(), ["add"], { timeout: CSWAP_TIMEOUT_MS });
1238
+ if (!add.ok) return { ok: false, reason: "add_failed", error: addFailureText(add) };
1239
+
1240
+ invalidateClaudeAccountsCache();
1241
+ // AWAITED, NOT DETACHED, AND THAT IS THE WHOLE DIFFERENCE THE PRESS MAKES.
1242
+ // `cswap add` clears the strike instantly, so a detached collection left a
1243
+ // window where the badge was gone but the numbers were still twenty hours
1244
+ // old and the row still said "due" in amber — a press that looked like it
1245
+ // had done nothing, which is the complaint this button exists to answer.
1246
+ // Waiting costs a few seconds and returns a row that has actually moved.
1247
+ //
1248
+ // Its failure is not the press's failure: the credentials are captured
1249
+ // either way, and claude-swap's own schedule will collect within minutes.
1250
+ // So a timeout here still reports success, with `collected: false` for a
1251
+ // caller that wants to say so.
1252
+ const collect = await run(await cswapBin(), ["list"], { timeout: CSWAP_TIMEOUT_MS })
1253
+ .catch(() => null);
1254
+ invalidateClaudeAccountsCache();
1255
+ return { ok: true, email: before.email, collected: collect?.ok === true };
1256
+ });
1257
+ }
1258
+
810
1259
  export async function removeAccount(num) {
811
1260
  const n = Number(num);
812
1261
  if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };