agent-dag 1.46.3 → 1.48.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.
@@ -16,6 +16,10 @@
16
16
  import { readFile, writeFile, mkdir } from "node:fs/promises";
17
17
  import { cswapBin, cswapVersion, installHint } from "./cswap-install.mjs";
18
18
  import { run, runDetached } from "./exec.mjs";
19
+ // The CLI identity oracle, already written and already trusted by the account
20
+ // admin routes. #721 needs the same answer, so it reuses the same function
21
+ // rather than shelling out a second way to ask one question.
22
+ import { currentIdentity } from "./cswap-admin.mjs";
19
23
  import { dirname, join } from "node:path";
20
24
  import { existsSync } from "node:fs";
21
25
  import { homedir, platform } from "node:os";
@@ -398,6 +402,17 @@ async function readRoster(now, gen) {
398
402
  // fetched at all.
399
403
  nudgeCollector(rows, Object.keys(seq.accounts), now, seq.activeAccountNumber);
400
404
 
405
+ // Who the CLI says is signed in, asked ONLY when the store claims the active
406
+ // account is in trouble — see authTrouble. That is the one case where the
407
+ // stored verdict and the live truth can disagree, and it is rare: a healthy
408
+ // machine never spends this subprocess. Never fatal, because a CLI that
409
+ // cannot be reached is not evidence either way.
410
+ const activeNum = seq.activeAccountNumber != null ? String(seq.activeAccountNumber) : null;
411
+ const activeRow = activeNum ? rows[activeNum] : null;
412
+ const identity = (activeRow?.consecutiveFailures ?? 0) > 0
413
+ ? await currentIdentity().catch(() => null)
414
+ : null;
415
+
401
416
  const order = Array.isArray(seq.sequence) && seq.sequence.length
402
417
  ? seq.sequence.map(String)
403
418
  : Object.keys(seq.accounts).sort((a, b) => Number(a) - Number(b));
@@ -417,6 +432,8 @@ async function readRoster(now, gen) {
417
432
  const good = matches ? row.lastGood : null;
418
433
 
419
434
  const fetchedAtMs = matches && typeof row.fetchedAt === "number" ? row.fetchedAt * 1000 : null;
435
+ const isActive = String(seq.activeAccountNumber) === num;
436
+ const trouble = authTrouble(row, { matches, isActive, identity, email: acct.email });
420
437
 
421
438
  const lanes = [
422
439
  lane("five_hour", "5h", good?.five_hour),
@@ -442,23 +459,76 @@ async function readRoster(now, gen) {
442
459
  // plan and, for a healthy active account, the deck's freshen tick. The
443
460
  // plan alone would promise "next in 15m" while the panel actually
444
461
  // updates in three.
445
- nextAt: nextReadAt(row, matches, fetchedAtMs, String(seq.activeAccountNumber) === num, now),
462
+ nextAt: nextReadAt(row, matches, fetchedAtMs, isActive, now),
446
463
  stale: fetchedAtMs == null || now - fetchedAtMs > STALE_AFTER_MS,
447
464
  // Surfaced rather than hidden: a rate-limited or re-login-needed account
448
465
  // is exactly the one the user is about to try switching to.
449
466
  //
450
- // Keyed off consecutiveFailures, not lastError: claude-swap keeps
451
- // lastError as history and only advances fetchedAt on success, so an
452
- // account that hit a 429 an hour ago and has been fine since still
453
- // carries the string. Reading it directly pins a red badge on a healthy
454
- // account forever.
455
- error: (matches && row.consecutiveFailures > 0) ? (row.lastError ?? "error") : null,
467
+ // Through authTrouble rather than read straight off the row: see #721.
468
+ // consecutiveFailures says the COLLECTOR is failing, which for the active
469
+ // account is not the same claim as the user being signed out and the
470
+ // CLI can settle that.
471
+ error: trouble?.error ?? null,
472
+ // True when the collector cannot read this account but the user is signed
473
+ // in as it anyway. The panel says so quietly instead of offering to log
474
+ // them in again.
475
+ staleCopy: trouble?.kind === "stale-copy",
456
476
  });
457
477
  }
458
478
 
459
479
  return finish({ ok: true, accounts, activeNum: seq.activeAccountNumber ?? null, fetchedAt: now });
460
480
  }
461
481
 
482
+ /**
483
+ * What to say about an account whose collector is failing — which is not the
484
+ * same question as whether the user is signed out.
485
+ *
486
+ * TWO FACTS, AND #721 SHIPPED THEM AS ONE. claude-swap keeps its own copy of
487
+ * each account's credentials, taken when `cswap add` captured the slot. When
488
+ * that copy's refresh token dies, claude-swap can no longer collect usage for
489
+ * the row and says `relogin_required`. That is true, and it is about the COPY.
490
+ *
491
+ * It says nothing about whether the user is signed in. Measured on the machine
492
+ * that reported this, at the same instant:
493
+ *
494
+ * claude auth status --json -> loggedIn: true, claude3@sapec.md
495
+ * cswap list --json -> claude3@sapec.md: relogin_required
496
+ * GET /api/quota -> source: cli, 5h 33%, 7d 37%
497
+ *
498
+ * The user had signed in again in a terminal, which refreshes the LIVE
499
+ * credentials and leaves claude-swap's stored copy exactly as dead as it was.
500
+ * The deck held live quota numbers for that account and printed "login expired"
501
+ * beside them, and the button it offered ran `claude auth login` — a full
502
+ * re-login of the account the user was mid-session in, to fix a problem they
503
+ * did not have.
504
+ *
505
+ * So: for the ACTIVE account the CLI is the authority, because it is the one
506
+ * thing that can answer about the live credentials rather than about a copy.
507
+ * When it says the user is signed in as this account, there is no login
508
+ * failure to report — only a collector that cannot see it, which is quieter,
509
+ * true, and fixed by re-capturing the slot rather than by signing in again.
510
+ *
511
+ * `identity` is null when the CLI could not be asked at all. That is not
512
+ * evidence of anything, so the stored verdict stands: refusing to show a real
513
+ * expiry because a subprocess failed is the opposite mistake.
514
+ */
515
+ export function authTrouble(row, { matches, isActive, identity, email } = {}) {
516
+ if (!matches || !((row?.consecutiveFailures ?? 0) > 0)) return null;
517
+
518
+ const signedInHere = isActive
519
+ && identity
520
+ && typeof identity.email === "string"
521
+ && email
522
+ && identity.email.toLowerCase() === String(email).toLowerCase();
523
+
524
+ if (signedInHere) {
525
+ // Deliberately not the `error` field: this is not the user's problem to
526
+ // fix under a red badge, and it must not offer to sign them in again.
527
+ return { kind: "stale-copy", error: null };
528
+ }
529
+ return { kind: "auth", error: row.lastError ?? "error" };
530
+ }
531
+
462
532
  /**
463
533
  * claude-swap's last good usage for whichever account is active right now.
464
534
  *
@@ -37,6 +37,8 @@ const CODE_VERDICT_MS = 60_000;
37
37
  // How long a shared account stays importable. Long enough to walk to the other
38
38
  // machine, short enough that a copy left in clipboard history goes stale.
39
39
  export const SHARE_TTL_MS = 10 * 60_000;
40
+ // How many accounts one bundle may carry. See shareAccounts.
41
+ const MAX_SHARE_ACCOUNTS = 50;
40
42
  const SHARE_PREFIX = "ccdeck1:";
41
43
 
42
44
  // ── serialization ────────────────────────────────────────────────────────────
@@ -163,10 +165,14 @@ export async function readStore() {
163
165
  return {
164
166
  slots: Object.keys(accounts),
165
167
  emails: Object.fromEntries(Object.entries(accounts).map(([k, v]) => [k, v?.email ?? ""])),
168
+ // The other half of the identity claude-swap keys an account by. One
169
+ // address under two organizations is two accounts on purpose, and a
170
+ // bundle carrying both must not report them as one - see identityKey.
171
+ orgs: Object.fromEntries(Object.entries(accounts).map(([k, v]) => [k, v?.organizationUuid ?? ""])),
166
172
  activeNum: seq?.activeAccountNumber ?? null,
167
173
  };
168
174
  } catch {
169
- return { slots: [], emails: {}, activeNum: null };
175
+ return { slots: [], emails: {}, orgs: {}, activeNum: null };
170
176
  }
171
177
  }
172
178
 
@@ -738,39 +744,352 @@ export function unwrapShare(blob, now = Date.now()) {
738
744
  return { ok: true, payload: env.payload };
739
745
  }
740
746
 
747
+ /**
748
+ * claude-swap's own identity for an account, as one comparable string.
749
+ *
750
+ * `(email, organizationUuid)`, which is the composite `transfer.py` keys its
751
+ * duplicate check and its already-here check on. Matching on the address alone
752
+ * would fold one address's two organizations into a single row, and the whole
753
+ * reason cswap carries the org is that they are two accounts.
754
+ *
755
+ * The address is lower-cased because both sides of every comparison here come
756
+ * from the same store or the same bundle, so folding case can only join what a
757
+ * human would call the same account, never split one.
758
+ */
759
+ export function identityKey(email, org) {
760
+ return `${String(email ?? "").trim().toLowerCase()} ${String(org ?? "")}`;
761
+ }
762
+
763
+ /**
764
+ * N single-account envelopes, folded into the one bundle cswap will take back.
765
+ *
766
+ * `cswap export --account` names ONE account, so a chosen subset cannot come
767
+ * out of a single call and the deck has to do the folding. It is deliberately
768
+ * not a new format: the head envelope is spread whole and only `accounts` and
769
+ * `activeAccountNumber` are replaced, so `version`, `exportedFrom`,
770
+ * `swapVersion` and any field a later claude-swap adds arrive on the far side
771
+ * exactly as that claude-swap wrote them. Nothing here hard-codes its
772
+ * FORMAT_VERSION, because a constant copied out of another project's source is
773
+ * a constant that drifts.
774
+ *
775
+ * `activeAccountNumber` is re-guarded rather than carried: cswap only records
776
+ * it when that slot is in the payload, and a subset can drop the slot the head
777
+ * envelope pointed at. An import that referenced a missing account would be
778
+ * seeding an active slot that never arrived.
779
+ *
780
+ * A duplicate identity is DROPPED rather than passed on. `import_accounts`
781
+ * refuses a whole envelope over one repeated `(email, org)` pair, so carrying
782
+ * it would trade five shared accounts for a bundle that imports none.
783
+ */
784
+ export function mergeExports(texts) {
785
+ const envelopes = [];
786
+ for (const text of texts) {
787
+ let env;
788
+ try { env = JSON.parse(text); } catch { return { ok: false, reason: "unreadable_export" }; }
789
+ if (!env || typeof env !== "object" || !Array.isArray(env.accounts)) {
790
+ return { ok: false, reason: "unreadable_export" };
791
+ }
792
+ envelopes.push(env);
793
+ }
794
+ if (!envelopes.length) return { ok: false, reason: "nothing_to_share" };
795
+
796
+ const head = envelopes[0];
797
+ // Every part came out of one binary in one pass, so a disagreement here is
798
+ // not a version to reconcile - it is a sign the parts are not what we think.
799
+ if (envelopes.some(e => e.version !== head.version)) return { ok: false, reason: "mixed_versions" };
800
+
801
+ const accounts = [];
802
+ const dropped = [];
803
+ const seen = new Set();
804
+ for (const env of envelopes) {
805
+ for (const a of env.accounts) {
806
+ const key = identityKey(a?.email, a?.organizationUuid);
807
+ if (seen.has(key)) { dropped.push({ num: String(a?.number ?? ""), email: a?.email ?? "" }); continue; }
808
+ seen.add(key);
809
+ accounts.push(a);
810
+ }
811
+ }
812
+ if (!accounts.length) return { ok: false, reason: "nothing_to_share" };
813
+
814
+ const nums = new Set(accounts.map(a => String(a?.number ?? "")));
815
+ const active = envelopes
816
+ .map(e => e.activeAccountNumber)
817
+ .find(n => n != null && nums.has(String(n)));
818
+ return {
819
+ ok: true,
820
+ dropped,
821
+ envelope: { ...head, activeAccountNumber: active ?? null, accounts },
822
+ };
823
+ }
824
+
825
+ /**
826
+ * One account, or several, packaged for another deck.
827
+ *
828
+ * claude-swap's envelope carries each account's OAuth token in the clear - its
829
+ * own module header says so ("No encryption is built in"). The wrapper adds an
830
+ * expiry so a copy left behind in clipboard history stops working, and nothing
831
+ * more: it is not encryption and is not presented as any. A bundle makes that
832
+ * larger, not different - five accounts is five tokens on the clipboard -
833
+ * which is why the dialog states the count before the copy rather than after.
834
+ *
835
+ * Say the limit of the expiry out loud, because the UI used to imply the
836
+ * opposite. `exp` is a plain number inside plain base64'd JSON with NO key, MAC
837
+ * or signature over it, so anyone holding the text can decode it, write a later
838
+ * `exp`, re-encode, and import it. unwrapShare's check is therefore a check
839
+ * against staleness, not against an adversary - and it cannot be made into one
840
+ * here. A MAC needs a secret both decks hold, and two decks that already shared
841
+ * a secret would not need this function; and even a perfect signature would
842
+ * only stop THIS import path, since the payload it wraps is the credential
843
+ * itself and `cswap import` accepts it unwrapped. The honest fix for a share
844
+ * that got away is to sign the account out and back in. See share-expiry-
845
+ * forgeable.test.ts, which pins the forgery rather than leaving it implied.
846
+ *
847
+ * Only the accounts asked for are exported. Reading the whole store and then
848
+ * dropping the unwanted rows would be one spawn instead of several, and it
849
+ * would pull a refresh token this deck was never asked to move into this
850
+ * process; it would also lose the failure, because a whole-store export skips a
851
+ * slot with no backup credentials in silence while `--account` on that slot is
852
+ * a hard error naming it. A count that is quietly short is the one outcome a
853
+ * share must never have.
854
+ *
855
+ * The default export shape is used deliberately, never --full, which would
856
+ * embed the entire ~/.claude.json including every project and MCP server.
857
+ */
858
+ export async function shareAccounts(nums) {
859
+ const asked = Array.isArray(nums) ? nums : [nums];
860
+ // One spawn per account, so the length of this list is a length of time the
861
+ // request holds. A store never has fifty accounts; a caller that sends nine
862
+ // hundred numbers is not a person picking from a panel, and the ceiling
863
+ // costs nothing to the one who is.
864
+ if (asked.length > MAX_SHARE_ACCOUNTS) return { ok: false, reason: "too_many" };
865
+ const wanted = [];
866
+ for (const raw of asked) {
867
+ const n = Number(raw);
868
+ if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
869
+ if (!wanted.includes(n)) wanted.push(n);
870
+ }
871
+ if (!wanted.length) return { ok: false, reason: "bad_account" };
872
+
873
+ // Names for the accounts that fail, read from the store before anything is
874
+ // spawned - because the failure sentence must never be built from the
875
+ // export's own output.
876
+ const store = await readStore();
877
+ const texts = [];
878
+ const failed = [];
879
+ for (const n of wanted) {
880
+ const r = await run(await cswapBin(), ["export", "-", "--account", String(n)], { timeout: CSWAP_TIMEOUT_MS });
881
+ if (!r.ok || !r.stdout.trim()) {
882
+ // The failure sentence is built from stderr ALONE for this one command,
883
+ // because its stdout is the credential. `failureText` concatenates
884
+ // `${stderr}\n${stdout}` and `firstUseful` takes the LAST non-empty line -
885
+ // right for every other cswap command, and here it means any stdout at all
886
+ // outranks the real error. claude-swap writes its diagnostics to stderr
887
+ // specifically so stdout stays pure JSON in pipe mode, and it writes the
888
+ // envelope as its last act; a non-zero exit after a partial write would
889
+ // therefore put the tail of `json.dumps(envelope, indent=2)` in front of the
890
+ // user, and one of those lines is the refresh token on its own.
891
+ //
892
+ // Nothing is lost by dropping it: the ENOENT branch keys off `r.code`, which
893
+ // `run` sets, and cmd.exe's "is not recognized" is stderr's.
894
+ failed.push({
895
+ num: String(n),
896
+ email: store.emails?.[String(n)] || "",
897
+ detail: failureText({ ...r, stdout: "" }, "cswap export"),
898
+ });
899
+ continue;
900
+ }
901
+ texts.push(r.stdout);
902
+ }
903
+
904
+ // Nothing came out at all. There is no partial bundle to hand over, so this
905
+ // is the plain failure the single-account share has always reported.
906
+ if (!texts.length) {
907
+ return { ok: false, reason: "export_failed", detail: failed[0]?.detail ?? "", failed };
908
+ }
909
+
910
+ const merged = mergeExports(texts);
911
+ if (!merged.ok) return { ok: false, reason: merged.reason, failed };
912
+ for (const d of merged.dropped) {
913
+ failed.push({ ...d, detail: "another slot already holds this address in this organization" });
914
+ }
915
+
916
+ const shared = merged.envelope.accounts.map(a => ({ num: String(a?.number ?? ""), email: a?.email ?? "" }));
917
+ return {
918
+ ok: true,
919
+ blob: wrapShare(JSON.stringify(merged.envelope)),
920
+ expiresAt: Date.now() + SHARE_TTL_MS,
921
+ // What the bundle CARRIES, never what was asked for. The copy row counts
922
+ // this list, so a bundle that came up short says so.
923
+ shared,
924
+ failed,
925
+ };
926
+ }
927
+
928
+ /**
929
+ * The one-account case, which is a bundle of one and takes the same path.
930
+ *
931
+ * That path PARSES what cswap wrote, where this used to hand its stdout on
932
+ * opaquely — the cost of `shared` being able to promise that the count on the
933
+ * copy button is the count in the blob. So an export shape the fold cannot read
934
+ * now fails the single share too, not just the bundle. Deliberate: one path
935
+ * means the two cannot drift into two envelope shapes, and a share that
936
+ * silently carried something this deck could not account for is the failure
937
+ * the count exists to prevent.
938
+ */
741
939
  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") };
940
+ return shareAccounts([num]);
941
+ }
942
+
943
+ /**
944
+ * The identities a bundle carries, or `[]` when it cannot be read.
945
+ *
946
+ * The payload is the credential, so this takes the two fields it needs and
947
+ * nothing else: no caller ever receives the parsed envelope, and a bundle that
948
+ * will not parse degrades to an unnamed import rather than to an error, since
949
+ * cswap is the one entitled to refuse it.
950
+ */
951
+ export function bundleAccounts(payload) {
952
+ let env;
953
+ try { env = JSON.parse(payload); } catch { return []; }
954
+ if (!env || typeof env !== "object" || !Array.isArray(env.accounts)) return [];
955
+ const out = [];
956
+ for (const a of env.accounts) {
957
+ if (!a || typeof a !== "object") continue;
958
+ const email = typeof a.email === "string" ? a.email.trim() : "";
959
+ if (!email) continue;
960
+ out.push({ email, org: typeof a.organizationUuid === "string" ? a.organizationUuid : "" });
759
961
  }
760
- return { ok: true, blob: wrapShare(r.stdout), expiresAt: Date.now() + SHARE_TTL_MS };
962
+ // All of them or none. `wanted` is what the result list counts against, so a
963
+ // bundle read as three when it holds four reports "1 of 3 imported" about a
964
+ // paste of four - the missing one arrives and is never named, and a non-empty
965
+ // list keeps the store-diff fallback from running to catch it. claude-swap
966
+ // itself refuses an envelope whose entry has no address, so this is a guard
967
+ // against a shape neither project has today rather than a live case.
968
+ return out.length === env.accounts.length ? out : [];
969
+ }
970
+
971
+ /**
972
+ * The same bundle, cut down to one account.
973
+ *
974
+ * What "update anyway" sends. `--force` overwrites every account it matches, so
975
+ * a forced import of the whole bundle would rewrite credentials the user never
976
+ * pointed at; narrowing first is what keeps an overwrite a named act. Written
977
+ * as a filter over the original envelope rather than a fresh one, for the same
978
+ * reason mergeExports spreads its head: those fields belong to claude-swap.
979
+ */
980
+ export function narrowBundle(payload, key) {
981
+ let env;
982
+ try { env = JSON.parse(payload); } catch { return { ok: false, reason: "corrupt" }; }
983
+ if (!env || typeof env !== "object" || !Array.isArray(env.accounts)) return { ok: false, reason: "corrupt" };
984
+ const accounts = env.accounts.filter(a => identityKey(a?.email, a?.organizationUuid) === key);
985
+ if (!accounts.length) return { ok: false, reason: "not_in_bundle" };
986
+ const nums = new Set(accounts.map(a => String(a?.number ?? "")));
987
+ const active = env.activeAccountNumber != null && nums.has(String(env.activeAccountNumber))
988
+ ? env.activeAccountNumber
989
+ : null;
990
+ return { ok: true, payload: JSON.stringify({ ...env, activeAccountNumber: active, accounts }) };
991
+ }
992
+
993
+ /**
994
+ * What happened to each account in the bundle, decided by the store.
995
+ *
996
+ * The store is the fact. `cswap import` narrates itself per account on stderr,
997
+ * and parsing that as the primary answer would make a reworded release report
998
+ * imports that did not happen - the failure mode `newSlot` already refuses for
999
+ * the same reason. So the slot map before and after the run decides the two
1000
+ * outcomes that matter: an identity holding a slot it did not hold arrived, and
1001
+ * one absent from both never came.
1002
+ *
1003
+ * stderr is then read for one thing only, and one no store diff can show: an
1004
+ * account already present whose credentials were REWRITTEN in place, which
1005
+ * moves no slot. `Replaced` is claude-swap's dead-token auto-heal (its #136),
1006
+ * `Overwrote` is a `--force`. If either line is ever reworded the nuance is
1007
+ * lost and the row reads "already here", which is still true - the degradation
1008
+ * is a less specific report, never a wrong one.
1009
+ *
1010
+ * And it is applied ONLY where the address names exactly one row in the bundle.
1011
+ * cswap's line carries no organization, so with one address held under two of
1012
+ * them a single `Replaced me@x.com` would mark both rows healed and one of
1013
+ * those would be false - which is the thing the paragraph above promises this
1014
+ * never does.
1015
+ */
1016
+ export function importOutcomes(before, after, wanted, stderr = "") {
1017
+ const slotsBy = (store) => new Map(
1018
+ (store?.slots ?? []).map(s => [identityKey(store?.emails?.[s], store?.orgs?.[s]), s]),
1019
+ );
1020
+ const had = slotsBy(before);
1021
+ const now = slotsBy(after);
1022
+
1023
+ // How many rows in this bundle share each address. An address held twice is
1024
+ // an address cswap's own narration cannot resolve.
1025
+ const byAddress = new Map();
1026
+ for (const w of wanted) {
1027
+ const a = String(w.email ?? "").trim().toLowerCase();
1028
+ byAddress.set(a, (byAddress.get(a) ?? 0) + 1);
1029
+ }
1030
+ const rewritten = new Map();
1031
+ for (const line of String(stderr ?? "").split(/\r?\n/)) {
1032
+ const m = /^\s*(Replaced|Overwrote)\s+(\S+)/.exec(line);
1033
+ if (!m) continue;
1034
+ const addr = String(m[2]).trim().toLowerCase();
1035
+ if ((byAddress.get(addr) ?? 0) !== 1) continue;
1036
+ rewritten.set(addr, m[1] === "Replaced" ? "healed" : "updated");
1037
+ }
1038
+
1039
+ return wanted.map(w => {
1040
+ const key = identityKey(w.email, w.org);
1041
+ const wasHere = had.has(key);
1042
+ const slot = now.get(key) ?? null;
1043
+ if (!wasHere && slot != null) return { email: w.email, org: w.org, num: slot, state: "imported" };
1044
+ if (wasHere) {
1045
+ return {
1046
+ email: w.email,
1047
+ org: w.org,
1048
+ num: had.get(key),
1049
+ state: rewritten.get(String(w.email ?? "").trim().toLowerCase()) ?? "present",
1050
+ };
1051
+ }
1052
+ return { email: w.email, org: w.org, num: null, state: "failed" };
1053
+ });
761
1054
  }
762
1055
 
763
- export async function importAccount(blob) {
1056
+ /**
1057
+ * A share, or a bundle of them, taken into this deck's store.
1058
+ *
1059
+ * Non-destructive by default and deliberately so: without `--force` claude-swap
1060
+ * adds what is missing, leaves a healthy account exactly as it is, and replaces
1061
+ * only a slot its own usage row has quarantined as refresh-token-dead. That is
1062
+ * already the rule a person would ask for - leave what works, fix what does
1063
+ * not - so the default run never passes the flag.
1064
+ *
1065
+ * `force` is honoured ONLY together with `only`, which names a single account.
1066
+ * A forced import of a whole bundle would rewrite every matching credential on
1067
+ * this machine, and a fresh token replaced by a stale one is not recoverable
1068
+ * from here - the fix is a re-login. Requiring the pair is what makes the
1069
+ * clobber something a person chose while looking at the address.
1070
+ */
1071
+ export async function importAccount(blob, { force = false, only = null } = {}) {
764
1072
  const un = unwrapShare(blob);
765
1073
  if (!un.ok) return { ok: false, reason: un.reason };
766
1074
 
1075
+ let payload = un.payload;
1076
+ const narrowing = only != null;
1077
+ if (narrowing) {
1078
+ const cut = narrowBundle(payload, identityKey(only?.email, only?.org));
1079
+ if (!cut.ok) return { ok: false, reason: cut.reason };
1080
+ payload = cut.payload;
1081
+ }
1082
+ const overwrite = force === true && narrowing;
1083
+ const wanted = bundleAccounts(payload);
1084
+
767
1085
  return withStoreLock(async () => {
768
1086
  const before = await readStore();
769
- const child = runInteractive(await cswapBin(), ["import", "-"], { timeout: CSWAP_TIMEOUT_MS });
770
- child.write(un.payload);
1087
+ const args = overwrite ? ["import", "-", "--force"] : ["import", "-"];
1088
+ const child = runInteractive(await cswapBin(), args, { timeout: CSWAP_TIMEOUT_MS });
1089
+ child.write(payload);
771
1090
  // cswap reads stdin to EOF, so the pipe has to close for it to proceed.
772
1091
  //
773
- // This used to write a raw EOT byte and then call `endStdin(child)` a
1092
+ // This used to write a raw EOT byte and then call `endStdin(child)` - a
774
1093
  // helper that was never written. EOT only means end-of-file on a TTY, so
775
1094
  // the byte did nothing to a pipe, and the call threw ReferenceError before
776
1095
  // cswap ever saw the payload: the route answered 500 and the dialog fell
@@ -782,15 +1101,32 @@ export async function importAccount(blob) {
782
1101
  if (!r.ok) return { ok: false, reason: "import_failed", detail: failureText(r, "cswap import") };
783
1102
 
784
1103
  const after = await readStore();
785
- const slot = newSlot(before, after);
786
1104
  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) };
1105
+
1106
+ // An unreadable envelope was still imported, or still refused, by cswap -
1107
+ // only the naming is lost. Fall back to the store's own new slots so the
1108
+ // dialog can say what arrived even then.
1109
+ const results = wanted.length
1110
+ ? importOutcomes(before, after, wanted, r.stderr)
1111
+ : (() => {
1112
+ const had = new Set(before.slots ?? []);
1113
+ return (after.slots ?? []).filter(s => !had.has(s))
1114
+ .map(s => ({ email: after.emails?.[s] || "", org: after.orgs?.[s] || "", num: s, state: "imported" }));
1115
+ })();
1116
+
1117
+ const arrived = results.filter(x => x.state === "imported");
1118
+ if (arrived.length) runDetached(await cswapBin(), ["list"]);
1119
+ return {
1120
+ ok: true,
1121
+ results,
1122
+ // Nothing new is not an error: without --force, cswap skips an account it
1123
+ // already holds. Saying which happened is the difference between "it
1124
+ // worked" and "why is nothing different".
1125
+ added: arrived.length > 0,
1126
+ num: arrived.length === 1 ? arrived[0].num : null,
1127
+ email: arrived.length === 1 ? arrived[0].email : null,
1128
+ output: firstUseful(r.stdout),
1129
+ };
794
1130
  });
795
1131
  }
796
1132
 
@@ -807,6 +1143,58 @@ export function removePromptMatches(line, num) {
807
1143
  return Boolean(m) && m[1] === String(num);
808
1144
  }
809
1145
 
1146
+ /**
1147
+ * Re-capture the active slot's credentials, which is what unfreezes a row whose
1148
+ * stored copy died.
1149
+ *
1150
+ * WHY THIS EXISTS AND WHY IT IS NOT A LOGIN. claude-swap keeps its own copy of
1151
+ * each account's credentials, taken when the slot was added. When that copy's
1152
+ * refresh token dies it quarantines the row: no further collection is
1153
+ * attempted, so the numbers freeze and every Refresh re-reads a store that
1154
+ * cannot change. #721 fixed the sentence the panel said about that state; this
1155
+ * is the button that ends it.
1156
+ *
1157
+ * `cswap add` on an account already in the store is an idempotent credential
1158
+ * refresh — registerSignedIn above says so in its own words: "No new slot means
1159
+ * the account was already managed and cswap refreshed its credentials in
1160
+ * place." It captures whatever is signed in RIGHT NOW, which for the active
1161
+ * slot is the account the row belongs to, with the working credentials the user
1162
+ * already has. Nobody is signed in or out, no browser opens, and the active
1163
+ * account does not change.
1164
+ *
1165
+ * It only ever repairs the ACTIVE slot, because "what is signed in right now"
1166
+ * is the only thing `cswap add` can see. The panel offers it nowhere else.
1167
+ */
1168
+ export async function recaptureActive() {
1169
+ return withStoreLock(async () => {
1170
+ // Who is actually signed in, asked before and after, because `cswap add`
1171
+ // captures the live credentials and this is the one check that the thing it
1172
+ // captured is the thing the user meant.
1173
+ const before = await currentIdentity();
1174
+ if (!before?.email) return { ok: false, reason: "not_signed_in" };
1175
+
1176
+ const add = await run(await cswapBin(), ["add"], { timeout: CSWAP_TIMEOUT_MS });
1177
+ if (!add.ok) return { ok: false, reason: "add_failed", error: addFailureText(add) };
1178
+
1179
+ invalidateClaudeAccountsCache();
1180
+ // AWAITED, NOT DETACHED, AND THAT IS THE WHOLE DIFFERENCE THE PRESS MAKES.
1181
+ // `cswap add` clears the strike instantly, so a detached collection left a
1182
+ // window where the badge was gone but the numbers were still twenty hours
1183
+ // old and the row still said "due" in amber — a press that looked like it
1184
+ // had done nothing, which is the complaint this button exists to answer.
1185
+ // Waiting costs a few seconds and returns a row that has actually moved.
1186
+ //
1187
+ // Its failure is not the press's failure: the credentials are captured
1188
+ // either way, and claude-swap's own schedule will collect within minutes.
1189
+ // So a timeout here still reports success, with `collected: false` for a
1190
+ // caller that wants to say so.
1191
+ const collect = await run(await cswapBin(), ["list"], { timeout: CSWAP_TIMEOUT_MS })
1192
+ .catch(() => null);
1193
+ invalidateClaudeAccountsCache();
1194
+ return { ok: true, email: before.email, collected: collect?.ok === true };
1195
+ });
1196
+ }
1197
+
810
1198
  export async function removeAccount(num) {
811
1199
  const n = Number(num);
812
1200
  if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };