sidekick-shared 0.27.6 → 0.28.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.
Files changed (269) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +58 -18
  3. package/dist/browser.d.ts +13 -2
  4. package/dist/browser.js +30 -2
  5. package/dist/claudePaths.d.ts +11 -0
  6. package/dist/claudePaths.js +61 -0
  7. package/dist/claudeProfiles.d.ts +2 -9
  8. package/dist/claudeProfiles.js +10 -25
  9. package/dist/codexPaths.d.ts +22 -0
  10. package/dist/codexPaths.js +67 -0
  11. package/dist/codexProfiles.d.ts +1 -4
  12. package/dist/codexProfiles.js +5 -43
  13. package/dist/doctor.js +2 -2
  14. package/dist/esm/accountChangeSignal.js +15 -0
  15. package/dist/esm/accountChanges.js +152 -0
  16. package/dist/esm/accountHealth.js +288 -0
  17. package/dist/esm/accountLaunch.js +145 -0
  18. package/dist/esm/accountLease.js +60 -0
  19. package/dist/esm/accountManager.js +606 -0
  20. package/dist/esm/accountRegistry.js +214 -0
  21. package/dist/esm/accountStatus.js +51 -0
  22. package/dist/esm/accountSwitch.js +74 -0
  23. package/dist/esm/accountSync.js +156 -0
  24. package/dist/esm/accountSyncTypes.js +4 -0
  25. package/dist/esm/accounts.js +1291 -0
  26. package/dist/esm/aggregation/EventAggregator.js +2034 -0
  27. package/dist/esm/aggregation/FrequencyTracker.js +69 -0
  28. package/dist/esm/aggregation/HeatmapTracker.js +89 -0
  29. package/dist/esm/aggregation/PatternExtractor.js +168 -0
  30. package/dist/esm/aggregation/costProvenance.js +31 -0
  31. package/dist/esm/aggregation/snapshot.js +161 -0
  32. package/dist/esm/aggregation/types.js +5 -0
  33. package/dist/esm/analytics/codeImpact.js +18 -0
  34. package/dist/esm/analytics/compactionLedger.js +37 -0
  35. package/dist/esm/analytics/qualityScore.js +112 -0
  36. package/dist/esm/autoSwitch.js +185 -0
  37. package/dist/esm/browser.js +34 -0
  38. package/dist/esm/claudeCredentialDoctor.js +239 -0
  39. package/dist/esm/claudeCredentialLedger.js +91 -0
  40. package/dist/esm/claudeCredentials.js +111 -0
  41. package/dist/esm/claudeLiveGuard.js +43 -0
  42. package/dist/esm/claudePaths.js +22 -0
  43. package/dist/esm/claudeProfiles.js +142 -0
  44. package/dist/esm/claudeRefreshLock.js +106 -0
  45. package/dist/esm/codexAuth.js +101 -0
  46. package/dist/esm/codexCredentialDoctor.js +265 -0
  47. package/dist/esm/codexCredentialStore.js +164 -0
  48. package/dist/esm/codexKeepAlive.js +112 -0
  49. package/dist/esm/codexPaths.js +172 -0
  50. package/dist/esm/codexProfiles.js +1251 -0
  51. package/dist/esm/codexQuota.js +564 -0
  52. package/dist/esm/codexQuotaWatcher.js +363 -0
  53. package/dist/esm/context/composer.js +71 -0
  54. package/dist/esm/context/sessionContext.js +546 -0
  55. package/dist/esm/credentialIO.js +283 -0
  56. package/dist/esm/credentials.js +49 -0
  57. package/dist/esm/doctor.js +305 -0
  58. package/dist/esm/ensureDefaultAccounts.js +62 -0
  59. package/dist/esm/errorHistory.js +56 -0
  60. package/dist/esm/extractors/errorTaxonomy.js +43 -0
  61. package/dist/esm/extractors/gatherAssets.js +32 -0
  62. package/dist/esm/extractors/sessionAssets.js +176 -0
  63. package/dist/esm/extractors/sources/claudeAssets.js +158 -0
  64. package/dist/esm/extractors/sources/codexAssets.js +234 -0
  65. package/dist/esm/extractors/tokenUsage.js +41 -0
  66. package/dist/esm/extractors/toolCall.js +142 -0
  67. package/dist/esm/failingTools.js +65 -0
  68. package/dist/esm/fileHead.js +27 -0
  69. package/dist/esm/formatters/eventHighlighter.js +332 -0
  70. package/dist/esm/formatters/noiseClassifier.js +209 -0
  71. package/dist/esm/formatters/sessionDump.js +328 -0
  72. package/dist/esm/formatters/toolSummary.js +226 -0
  73. package/dist/esm/formatting.js +82 -0
  74. package/dist/esm/handoffUrl.js +23 -0
  75. package/dist/esm/historicalStore.js +305 -0
  76. package/dist/esm/humanPrompt.js +88 -0
  77. package/dist/esm/index.js +213 -0
  78. package/dist/esm/keepAlive.js +189 -0
  79. package/dist/esm/liveCredentialGuard.js +33 -0
  80. package/dist/esm/modelAliases.js +51 -0
  81. package/dist/esm/modelCatalog.js +139 -0
  82. package/dist/esm/modelContext.js +280 -0
  83. package/dist/esm/modelInfo.js +832 -0
  84. package/dist/esm/multiProviderQuotaService.js +450 -0
  85. package/dist/esm/node.js +46 -0
  86. package/dist/esm/observedContextWindows.js +165 -0
  87. package/dist/esm/observedSessionCollector.js +939 -0
  88. package/dist/esm/parsers/changelogParser.js +71 -0
  89. package/dist/esm/parsers/codexHistory.js +89 -0
  90. package/dist/esm/parsers/codexParser.js +906 -0
  91. package/dist/esm/parsers/debugLogParser.js +126 -0
  92. package/dist/esm/parsers/jsonl.js +66 -0
  93. package/dist/esm/parsers/mcpToolName.js +28 -0
  94. package/dist/esm/parsers/openCodeParser.js +625 -0
  95. package/dist/esm/parsers/planExtractor.js +335 -0
  96. package/dist/esm/parsers/sessionActivityDetector.js +246 -0
  97. package/dist/esm/parsers/sessionPathResolver.js +713 -0
  98. package/dist/esm/parsers/subagentScanner.js +360 -0
  99. package/dist/esm/parsers/subagentStatsCache.js +45 -0
  100. package/dist/esm/parsers/subagentTraceParser.js +325 -0
  101. package/dist/esm/paths.js +134 -0
  102. package/dist/esm/peakHours.js +194 -0
  103. package/dist/esm/phrases.js +1330 -0
  104. package/dist/esm/pricingCatalog.js +247 -0
  105. package/dist/esm/processDetection.js +200 -0
  106. package/dist/esm/projectMigration.js +59 -0
  107. package/dist/esm/promptHistory.js +1221 -0
  108. package/dist/esm/providerFailure.js +362 -0
  109. package/dist/esm/providerIds.js +6 -0
  110. package/dist/esm/providerQuota.js +1 -0
  111. package/dist/esm/providerServiceStatus.js +145 -0
  112. package/dist/esm/providerServiceStatusTypes.js +1 -0
  113. package/dist/esm/providerStatus.js +117 -0
  114. package/dist/esm/providers/claudeCode.js +575 -0
  115. package/dist/esm/providers/claudeListing.js +121 -0
  116. package/dist/esm/providers/codex.js +1034 -0
  117. package/dist/esm/providers/codexDatabase.js +297 -0
  118. package/dist/esm/providers/codexRollouts.js +215 -0
  119. package/dist/esm/providers/detect.js +185 -0
  120. package/dist/esm/providers/diagnostics.js +83 -0
  121. package/dist/esm/providers/directoryListingCache.js +206 -0
  122. package/dist/esm/providers/factory.js +54 -0
  123. package/dist/esm/providers/openCode.js +2008 -0
  124. package/dist/esm/providers/openCodeDatabase.js +419 -0
  125. package/dist/esm/providers/rolloutWalker.js +252 -0
  126. package/dist/esm/providers/types.js +12 -0
  127. package/dist/esm/quota.js +182 -0
  128. package/dist/esm/quotaHistory.js +364 -0
  129. package/dist/esm/quotaPoller.js +230 -0
  130. package/dist/esm/quotaPresentation.js +97 -0
  131. package/dist/esm/quotaResolver.js +231 -0
  132. package/dist/esm/quotaSnapshots.js +130 -0
  133. package/dist/esm/quotaThresholds.js +85 -0
  134. package/dist/esm/readers/decisions.js +24 -0
  135. package/dist/esm/readers/handoff.js +20 -0
  136. package/dist/esm/readers/helpers.js +27 -0
  137. package/dist/esm/readers/history.js +9 -0
  138. package/dist/esm/readers/notes.js +48 -0
  139. package/dist/esm/readers/plans.js +225 -0
  140. package/dist/esm/readers/tasks.js +19 -0
  141. package/dist/esm/report/htmlHelpers.js +280 -0
  142. package/dist/esm/report/htmlReportGenerator.js +704 -0
  143. package/dist/esm/report/index.js +7 -0
  144. package/dist/esm/report/logo.js +2 -0
  145. package/dist/esm/report/openBrowser.js +19 -0
  146. package/dist/esm/report/sessionReportInputs.js +33 -0
  147. package/dist/esm/report/transcriptParser.js +83 -0
  148. package/dist/esm/report/types.js +4 -0
  149. package/dist/esm/schemas/accountManager.js +204 -0
  150. package/dist/esm/schemas/accountStatus.js +25 -0
  151. package/dist/esm/schemas/assistantTurn.js +74 -0
  152. package/dist/esm/schemas/index.js +25 -0
  153. package/dist/esm/schemas/observedSessionV1.js +77 -0
  154. package/dist/esm/schemas/providerFailure.js +37 -0
  155. package/dist/esm/schemas/quota.js +132 -0
  156. package/dist/esm/schemas/quotaHistory.js +40 -0
  157. package/dist/esm/schemas/sessionEvent.js +216 -0
  158. package/dist/esm/schemas/stateFile.js +75 -0
  159. package/dist/esm/schemas/tokenEstimation.js +7 -0
  160. package/dist/esm/schemas/transcript.js +86 -0
  161. package/dist/esm/schemas/usageNormalization.js +38 -0
  162. package/dist/esm/search/advancedFilter.js +197 -0
  163. package/dist/esm/search/sessionSearch.js +66 -0
  164. package/dist/esm/sessionFingerprint.js +40 -0
  165. package/dist/esm/sessionMonitor.js +198 -0
  166. package/dist/esm/sessionPreviews.js +522 -0
  167. package/dist/esm/sessionPromptHistory.js +254 -0
  168. package/dist/esm/sessionPromptSignals.js +364 -0
  169. package/dist/esm/sessionStats.js +160 -0
  170. package/dist/esm/sessionTokenTotals.js +98 -0
  171. package/dist/esm/sessionTranscripts.js +49 -0
  172. package/dist/esm/stateFile.js +119 -0
  173. package/dist/esm/statusline/BurnRateCalculator.js +40 -0
  174. package/dist/esm/statusline/claudeStatuslinePayload.js +148 -0
  175. package/dist/esm/statusline/formatter.js +122 -0
  176. package/dist/esm/statusline/index.js +8 -0
  177. package/dist/esm/terminalSync.js +95 -0
  178. package/dist/esm/testing/credentialGuard.js +197 -0
  179. package/dist/esm/testing/index.js +12 -0
  180. package/dist/esm/testing/sessionLogFixtures.js +128 -0
  181. package/dist/esm/tokenEstimation.js +67 -0
  182. package/dist/esm/tokenSummary.js +113 -0
  183. package/dist/esm/toolCategory.js +82 -0
  184. package/dist/esm/transcript.js +257 -0
  185. package/dist/esm/turns/assistantTurn.js +373 -0
  186. package/dist/esm/types/codex.js +23 -0
  187. package/dist/esm/types/decisionLog.js +5 -0
  188. package/dist/esm/types/historicalData.js +47 -0
  189. package/dist/esm/types/knowledgeNote.js +15 -0
  190. package/dist/esm/types/observedSessionV1.js +257 -0
  191. package/dist/esm/types/opencode.js +12 -0
  192. package/dist/esm/types/plan.js +7 -0
  193. package/dist/esm/types/sessionEvent.js +10 -0
  194. package/dist/esm/types/taskPersistence.js +12 -0
  195. package/dist/esm/usage/billingBlocks.js +148 -0
  196. package/dist/esm/usage/claudeUsageDedupe.js +189 -0
  197. package/dist/esm/usage/importSessionHistory.js +84 -0
  198. package/dist/esm/usage/subagentUsage.js +18 -0
  199. package/dist/esm/usage/usageEvents.js +402 -0
  200. package/dist/esm/usage/usageReports.js +202 -0
  201. package/dist/esm/usageNormalization.js +95 -0
  202. package/dist/esm/watchers/eventBridge.js +219 -0
  203. package/dist/esm/watchers/factory.js +63 -0
  204. package/dist/esm/watchers/jsonlTail.js +289 -0
  205. package/dist/esm/watchers/jsonlWatcher.js +435 -0
  206. package/dist/esm/watchers/providerReaderWatcher.js +118 -0
  207. package/dist/esm/watchers/sqliteWatcher.js +285 -0
  208. package/dist/esm/watchers/types.js +4 -0
  209. package/dist/esm/workspaceRoots.js +56 -0
  210. package/dist/esm/writers/atomic.js +375 -0
  211. package/dist/esm/writers/decisions.js +51 -0
  212. package/dist/esm/writers/notes.js +46 -0
  213. package/dist/esm/writers/tasks.js +63 -0
  214. package/dist/esm/zaiQuota.js +323 -0
  215. package/dist/esm/zaiQuotaApi.js +271 -0
  216. package/dist/esm/zaiQuotaWatcher.js +265 -0
  217. package/dist/fileHead.d.ts +22 -0
  218. package/dist/fileHead.js +33 -0
  219. package/dist/index.d.ts +16 -7
  220. package/dist/index.js +49 -15
  221. package/dist/node.d.ts +13 -1
  222. package/dist/node.js +43 -2
  223. package/dist/parsers/codexHistory.js +2 -2
  224. package/dist/parsers/codexParser.d.ts +12 -0
  225. package/dist/parsers/codexParser.js +34 -12
  226. package/dist/parsers/debugLogParser.js +2 -2
  227. package/dist/parsers/sessionPathResolver.js +5 -4
  228. package/dist/promptHistory.d.ts +32 -0
  229. package/dist/promptHistory.js +115 -59
  230. package/dist/providerFailure.d.ts +12 -6
  231. package/dist/providerFailure.js +45 -0
  232. package/dist/providers/claudeCode.js +7 -99
  233. package/dist/providers/claudeListing.d.ts +22 -0
  234. package/dist/providers/claudeListing.js +158 -0
  235. package/dist/providers/codex.d.ts +3 -1
  236. package/dist/providers/codex.js +26 -227
  237. package/dist/providers/codexRollouts.d.ts +44 -0
  238. package/dist/providers/codexRollouts.js +261 -0
  239. package/dist/providers/detect.js +4 -4
  240. package/dist/readers/plans.js +3 -3
  241. package/dist/schemas/index.d.ts +2 -0
  242. package/dist/schemas/index.js +7 -2
  243. package/dist/schemas/providerFailure.d.ts +154 -0
  244. package/dist/schemas/providerFailure.js +40 -0
  245. package/dist/sessionPromptHistory.d.ts +12 -7
  246. package/dist/sessionPromptHistory.js +15 -3
  247. package/dist/sessionPromptSignals.d.ts +9 -0
  248. package/dist/sessionPromptSignals.js +13 -0
  249. package/dist/testing/credentialGuard.d.ts +23 -0
  250. package/dist/testing/credentialGuard.js +233 -0
  251. package/dist/testing/index.d.ts +14 -0
  252. package/dist/testing/index.js +22 -0
  253. package/dist/testing/sessionLogFixtures.d.ts +28 -0
  254. package/dist/testing/sessionLogFixtures.js +169 -0
  255. package/dist/tokenEstimation.d.ts +2 -0
  256. package/dist/tokenEstimation.js +21 -11
  257. package/dist/toolCategory.d.ts +18 -0
  258. package/dist/toolCategory.js +86 -0
  259. package/dist/types/codex.d.ts +4 -0
  260. package/dist/usage/usageEvents.d.ts +16 -0
  261. package/dist/usage/usageEvents.js +53 -9
  262. package/dist/watchers/jsonlTail.d.ts +26 -1
  263. package/dist/watchers/jsonlTail.js +136 -28
  264. package/dist/watchers/jsonlWatcher.js +7 -0
  265. package/dist/watchers/providerReaderWatcher.js +1 -0
  266. package/dist/watchers/types.d.ts +6 -0
  267. package/dist/workspaceRoots.d.ts +23 -0
  268. package/dist/workspaceRoots.js +93 -0
  269. package/package.json +22 -2
@@ -0,0 +1,1291 @@
1
+ /**
2
+ * Multi-account management for Claude Code logins.
3
+ *
4
+ * Handles saving, listing, switching, syncing, and removing Claude accounts.
5
+ * Both the VS Code extension and CLI consume this module.
6
+ *
7
+ * Model: Claude Code rotates the refresh token on every refresh, so every
8
+ * refresh-token chain may have exactly one copy a `claude` process can use.
9
+ *
10
+ * - The live home (`CLAUDE_CONFIG_DIR` or `~/.claude`) is the only CLI-usable
11
+ * copy of the account that is logged in right now. Its profile home holds
12
+ * identity and health metadata but never a credential; Sidekick mirrors the
13
+ * live credential into a private backup Claude Code never reads.
14
+ * - An inactive account's profile home holds its single CLI-usable copy (the
15
+ * Claude login only; MCP tokens follow the live store).
16
+ * - Writes into the live store go through one guarded installer that refuses
17
+ * downgrades, and a switch saves the outgoing login before replacing it.
18
+ */
19
+ import * as fs from 'fs';
20
+ import * as path from 'path';
21
+ import { readActiveCredentials, readClaudeCredentialStore, writeActiveCredentials, writeLiveClaudeCredentialStoreUnguarded, deleteStoredCredentials, } from './credentialIO';
22
+ import { atomicWriteJsonSync as atomicWriteJson, withFileLockSync } from './writers/atomic';
23
+ import { getAccountsDir, getActiveSavedAccount, listSavedAccountProfiles, readSavedAccountRegistry, removeSavedAccountProfileUnlocked, replaceSavedAccountProfiles, setActiveSavedAccount, setActiveSavedAccountUnlocked, upsertSavedAccountProfile, upsertSavedAccountProfileUnlocked, withSavedAccountRegistryLock, } from './accountRegistry';
24
+ import { getClaudeConfigPath, getClaudeCredentialBackupPath, getClaudeProfileDir, getClaudeProfileHome, getClaudeProfilesDir, getLiveClaudeHome, isClaudeProfileAuthenticated, isLiveClaudeHomeSidekickProfile, readLiveClaudeIdentity, } from './claudeProfiles';
25
+ import { claudeCredentialFreshness, claudeLoginOnly, claudeRefreshFingerprint, mergeClaudeLoginIntoLive, parseClaudeCredentialBlob, } from './claudeCredentials';
26
+ import { decideLiveClaudeWrite, } from './claudeLiveGuard';
27
+ import { ClaudeRefreshLockBusyError, withClaudeRefreshLocksSync } from './claudeRefreshLock';
28
+ import { acquireAccountLease, isAccountLeaseHeld, releaseAccountLease } from './accountLease';
29
+ import { getAccountHealth, readHealthSidecar, writeClaudeHealthSidecar, } from './accountHealth';
30
+ import { finishSwitchResult, switchFailure, writeLastSwitch, } from './accountSwitch';
31
+ import { emptyProviderSyncReport } from './accountSyncTypes';
32
+ import { detectRunningAccountConsumers, detectRunningAccountConsumersSync, } from './processDetection';
33
+ // ── Paths ────────────────────────────────────────────────────────────────
34
+ function getCredentialsDir() {
35
+ return path.join(getAccountsDir(), 'credentials');
36
+ }
37
+ function getConfigsDir() {
38
+ return path.join(getAccountsDir(), 'configs');
39
+ }
40
+ function getClaudeMigrationMarkerPath() {
41
+ return path.join(getAccountsDir(), 'claude', '.profiles-migrated-v1');
42
+ }
43
+ function getSwitchJournalPath() {
44
+ return path.join(getAccountsDir(), 'claude', 'switch-journal.json');
45
+ }
46
+ // ── Directory bootstrap ──────────────────────────────────────────────────
47
+ function ensureDirs() {
48
+ for (const dir of [getAccountsDir(), getCredentialsDir(), getConfigsDir()]) {
49
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
50
+ }
51
+ }
52
+ function readJsonOrNull(filePath) {
53
+ try {
54
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ function readOauthAccountFromConfigPath(filePath) {
61
+ const parsed = readJsonOrNull(filePath);
62
+ return parsed?.oauthAccount ?? null;
63
+ }
64
+ function isLiveClaudeAccount(uuid) {
65
+ return readLiveClaudeIdentity()?.uuid === uuid;
66
+ }
67
+ /** Store a profile's identity (`oauthAccount`) in its home and the flat backup. */
68
+ export function storeClaudeProfileIdentity(uuid, oauthAccount) {
69
+ atomicWriteJson(path.join(getClaudeProfileHome(uuid), '.claude.json'), { oauthAccount });
70
+ try {
71
+ atomicWriteJson(path.join(getConfigsDir(), `${uuid}.config.json`), oauthAccount);
72
+ }
73
+ catch {
74
+ /* the profile home is canonical; the flat copy is a fallback */
75
+ }
76
+ }
77
+ // ── Private credential backups ───────────────────────────────────────────
78
+ function readClaudeBackup(uuid) {
79
+ return readJsonOrNull(getClaudeCredentialBackupPath(uuid));
80
+ }
81
+ /** Write and verify the private backup. Throws: for the live account it is the only saved copy. */
82
+ function writeClaudeBackup(uuid, credentials) {
83
+ const target = getClaudeCredentialBackupPath(uuid);
84
+ atomicWriteJson(target, credentials);
85
+ if (JSON.stringify(readJsonOrNull(target)) !== JSON.stringify(credentials)) {
86
+ throw new Error(`The credential backup for ${uuid} did not verify after writing.`);
87
+ }
88
+ }
89
+ function fresherOf(a, b) {
90
+ const aCreds = parseClaudeCredentialBlob(a);
91
+ const bCreds = parseClaudeCredentialBlob(b);
92
+ if (!aCreds)
93
+ return bCreds ? b : null;
94
+ if (!bCreds)
95
+ return a;
96
+ return claudeCredentialFreshness(bCreds) > claudeCredentialFreshness(aCreds) ? b : a;
97
+ }
98
+ /**
99
+ * The single chokepoint for storing a Claude credential in a profile. Always
100
+ * writes the private backup and the secret-free health sidecar. Writes the
101
+ * profile-home store (suffixed Keychain item on macOS, file elsewhere) only
102
+ * when the account is NOT the live login: the live store is that account's one
103
+ * CLI-usable copy, and a second one would fight it over the refresh token.
104
+ * Caller holds the swap lock.
105
+ */
106
+ export function storeClaudeProfileCredentialsUnlocked(uuid, credentials, source) {
107
+ const login = claudeLoginOnly(credentials);
108
+ if (!login || !parseClaudeCredentialBlob(login)) {
109
+ throw new Error('Refusing to store something that is not a Claude login.');
110
+ }
111
+ writeClaudeBackup(uuid, credentials);
112
+ if (!isLiveClaudeAccount(uuid)) {
113
+ writeActiveCredentials(login, getClaudeProfileHome(uuid));
114
+ }
115
+ writeClaudeHealthSidecar(uuid, credentials, source);
116
+ }
117
+ /** {@link storeClaudeProfileCredentialsUnlocked} under the swap lock. */
118
+ export function storeClaudeProfileCredentials(uuid, credentials, source) {
119
+ withClaudeAuthSwapLock(() => storeClaudeProfileCredentialsUnlocked(uuid, credentials, source));
120
+ }
121
+ function writeClaudeProfileMirrorUnlocked(uuid, credentials, oauthAccount, source) {
122
+ storeClaudeProfileIdentity(uuid, oauthAccount);
123
+ storeClaudeProfileCredentialsUnlocked(uuid, credentials, source);
124
+ }
125
+ function readFlatClaudeOauthAccount(uuid) {
126
+ return readJsonOrNull(path.join(getConfigsDir(), `${uuid}.config.json`));
127
+ }
128
+ function readProfileClaudeOauthAccount(uuid) {
129
+ return readOauthAccountFromConfigPath(path.join(getClaudeProfileHome(uuid), '.claude.json'));
130
+ }
131
+ /**
132
+ * The saved credential and identity of a profile: the fresher of its
133
+ * profile-home store and its private backup. Read-only — it never
134
+ * re-materialises a backup into a CLI-visible place.
135
+ */
136
+ export function readStoredClaudeAccount(uuid) {
137
+ const store = readClaudeCredentialStore(getClaudeProfileHome(uuid));
138
+ const credentials = fresherOf(store.status === 'ok' ? store.blob : null, readClaudeBackup(uuid));
139
+ const oauthAccount = readProfileClaudeOauthAccount(uuid) ?? readFlatClaudeOauthAccount(uuid);
140
+ return credentials && oauthAccount ? { credentials, oauthAccount } : null;
141
+ }
142
+ function readCurrentLiveOauthAccount() {
143
+ return readOauthAccountFromConfigPath(getClaudeConfigPath());
144
+ }
145
+ function mergeOauthAccountIntoLiveConfig(oauthAccount) {
146
+ let configObj = {};
147
+ const configPath = getClaudeConfigPath();
148
+ try {
149
+ configObj = JSON.parse(fs.readFileSync(configPath, 'utf8'));
150
+ }
151
+ catch {
152
+ // File may not exist yet.
153
+ }
154
+ configObj.oauthAccount = oauthAccount;
155
+ atomicWriteJson(configPath, configObj);
156
+ }
157
+ // ── The guarded live installer ───────────────────────────────────────────
158
+ function liveStateFrom(read) {
159
+ return read.status === 'ok' ? { status: 'ok', blob: read.blob } : { status: read.status };
160
+ }
161
+ /**
162
+ * The only writer of the live Claude credential store. Asks the guard, merges
163
+ * (the live MCP tokens stay; the candidate's Claude login replaces live's),
164
+ * writes, and verifies. `rollback` restores `candidate` verbatim.
165
+ */
166
+ function installIntoLiveClaudeStore(candidate, candidateAccountUuid, intent, context) {
167
+ const live = liveStateFrom(context.live);
168
+ const decision = decideLiveClaudeWrite({
169
+ intent,
170
+ live,
171
+ liveAccountUuid: context.liveAccountUuid,
172
+ candidate,
173
+ candidateAccountUuid,
174
+ capturedOutgoing: context.capturedOutgoing,
175
+ written: context.written,
176
+ });
177
+ if (!decision.allow) {
178
+ return { ok: false, code: decision.code, reason: decision.reason, wrote: false };
179
+ }
180
+ const blob = intent === 'rollback'
181
+ ? candidate
182
+ : mergeClaudeLoginIntoLive(live.status === 'ok' ? live.blob : null, candidate);
183
+ try {
184
+ writeLiveClaudeCredentialStoreUnguarded(blob);
185
+ }
186
+ catch (err) {
187
+ return { ok: false, code: 'write-failed', reason: `${err}`, wrote: true, attempted: blob };
188
+ }
189
+ return { ok: true, installed: blob };
190
+ }
191
+ // ── Registry operations (legacy v1 view over the shared v2 registry) ───────
192
+ export function readAccountRegistry() {
193
+ const registry = readSavedAccountRegistry();
194
+ if (!registry)
195
+ return null;
196
+ const claudeAccounts = registry.accounts
197
+ .filter((account) => account.providerId === 'claude-code')
198
+ .map((account) => ({
199
+ uuid: account.providerAccountId ?? account.id,
200
+ email: account.email ?? account.metadata?.email ?? 'unknown',
201
+ label: account.label,
202
+ addedAt: account.addedAt,
203
+ }));
204
+ return {
205
+ version: 1,
206
+ activeAccountUuid: registry.activeByProvider['claude-code'],
207
+ accounts: claudeAccounts,
208
+ };
209
+ }
210
+ export function writeAccountRegistry(registry) {
211
+ replaceSavedAccountProfiles('claude-code', mapClaudeProfiles(registry), registry.activeAccountUuid);
212
+ }
213
+ function mapClaudeProfiles(registry) {
214
+ const existing = new Map(listSavedAccountProfiles('claude-code').map((profile) => [
215
+ profile.providerAccountId ?? profile.id,
216
+ profile,
217
+ ]));
218
+ return registry.accounts.map((account) => ({
219
+ id: account.uuid,
220
+ providerId: 'claude-code',
221
+ providerAccountId: account.uuid,
222
+ email: account.email,
223
+ label: account.label,
224
+ addedAt: account.addedAt,
225
+ metadata: {
226
+ ...existing.get(account.uuid)?.metadata,
227
+ email: account.email,
228
+ },
229
+ }));
230
+ }
231
+ // ── Read active Claude account ───────────────────────────────────────────
232
+ export function readActiveClaudeAccount() {
233
+ return readLiveClaudeIdentity();
234
+ }
235
+ function findClaudeProfile(uuid) {
236
+ return (listSavedAccountProfiles('claude-code').find((profile) => (profile.providerAccountId ?? profile.id) === uuid) ?? null);
237
+ }
238
+ function findClaudeProfileForIdentity(identity) {
239
+ const profiles = listSavedAccountProfiles('claude-code');
240
+ return (profiles.find((p) => (p.providerAccountId ?? p.id) === identity.uuid) ??
241
+ profiles.find((p) => (p.email ?? p.metadata?.email) === identity.email) ??
242
+ null);
243
+ }
244
+ function profileKey(profile) {
245
+ return profile.providerAccountId ?? profile.id;
246
+ }
247
+ // ── Add current account ──────────────────────────────────────────────────
248
+ export function addCurrentAccount(label) {
249
+ const active = readActiveClaudeAccount();
250
+ if (!active) {
251
+ return {
252
+ success: false,
253
+ error: 'No active Claude account found. Sign in with `claude` first.',
254
+ };
255
+ }
256
+ const credBlob = readActiveCredentials();
257
+ if (!credBlob) {
258
+ return { success: false, error: 'Could not read Claude credentials.' };
259
+ }
260
+ const configBlob = readCurrentLiveOauthAccount();
261
+ if (!configBlob) {
262
+ return { success: false, error: 'Could not read Claude config file.' };
263
+ }
264
+ ensureDirs();
265
+ try {
266
+ // The live account: backup and metadata only (see the chokepoint).
267
+ withClaudeAuthSwapLock(() => writeClaudeProfileMirrorUnlocked(active.uuid, credBlob, configBlob, 'login'));
268
+ }
269
+ catch (err) {
270
+ return { success: false, error: `Failed to back up Claude credentials: ${err}` };
271
+ }
272
+ // One read-modify-write of the registry under the lock so a concurrent add
273
+ // from another process is never dropped.
274
+ return withSavedAccountRegistryLock(() => {
275
+ const existing = findClaudeProfile(active.uuid);
276
+ const id = existing?.id ?? active.uuid;
277
+ upsertSavedAccountProfileUnlocked({
278
+ id,
279
+ providerId: 'claude-code',
280
+ providerAccountId: active.uuid,
281
+ email: active.email,
282
+ label: label !== undefined ? label || undefined : existing?.label,
283
+ addedAt: existing?.addedAt ?? new Date().toISOString(),
284
+ metadata: {
285
+ ...existing?.metadata,
286
+ email: active.email,
287
+ origin: existing?.metadata?.origin ?? 'manual',
288
+ },
289
+ });
290
+ setActiveSavedAccountUnlocked('claude-code', id);
291
+ return { success: true };
292
+ });
293
+ }
294
+ // ── Live-state sync ──────────────────────────────────────────────────────
295
+ /**
296
+ * Serializes live Claude credential swaps across processes so two concurrent
297
+ * switchers cannot interleave their backup/write/rollback sequences. Claude
298
+ * Code's own refreshes are kept out separately by borrowing its refresh lock
299
+ * (claudeRefreshLock.ts). Lock ordering: this lock first, then the refresh
300
+ * locks, the registry lock (inside setActiveSavedAccount), and the ledger
301
+ * lock — never the reverse. Never spawn a `claude` process while holding it.
302
+ */
303
+ export function withClaudeAuthSwapLock(operation) {
304
+ const lockDir = path.join(getAccountsDir(), 'claude');
305
+ fs.mkdirSync(lockDir, { recursive: true, mode: 0o700 });
306
+ return withFileLockSync(path.join(lockDir, 'auth-swap.lock'), operation);
307
+ }
308
+ function readSwitchJournal() {
309
+ const raw = readJsonOrNull(getSwitchJournalPath());
310
+ return raw && typeof raw.to === 'string' ? raw : null;
311
+ }
312
+ function writeSwitchJournal(journal) {
313
+ atomicWriteJson(getSwitchJournalPath(), journal);
314
+ }
315
+ function clearSwitchJournal() {
316
+ try {
317
+ fs.rmSync(getSwitchJournalPath(), { force: true });
318
+ }
319
+ catch {
320
+ /* the next sync clears it */
321
+ }
322
+ }
323
+ /** Give a switched-away account its single CLI-usable copy (it is inactive now). */
324
+ function materializeInactiveProfile(uuid, credentials) {
325
+ const login = claudeLoginOnly(credentials);
326
+ if (!login || isLiveClaudeAccount(uuid))
327
+ return;
328
+ writeActiveCredentials(login, getClaudeProfileHome(uuid));
329
+ }
330
+ /**
331
+ * A switch that died mid-way left its journal behind (sync runs under the swap
332
+ * lock, so a journal it can see belongs to no running switch). Finish or roll
333
+ * back from the live fingerprint — never from `.claude.json` alone, which may
334
+ * still name the other account.
335
+ */
336
+ function resolveSwitchJournalUnlocked(report) {
337
+ const journal = readSwitchJournal();
338
+ if (!journal)
339
+ return;
340
+ const live = readClaudeCredentialStore();
341
+ if (live.status === 'error') {
342
+ report.warnings.push('An interrupted account switch is pending; the live login could not be read yet.');
343
+ return;
344
+ }
345
+ const liveFp = live.status === 'ok' ? claudeRefreshFingerprint(parseClaudeCredentialBlob(live.blob)) : null;
346
+ if (liveFp !== null && liveFp === journal.toFp) {
347
+ // The target was installed: finish the commit.
348
+ const toOauth = readProfileClaudeOauthAccount(journal.to) ?? readFlatClaudeOauthAccount(journal.to);
349
+ try {
350
+ if (toOauth && readLiveClaudeIdentity()?.uuid !== journal.to) {
351
+ mergeOauthAccountIntoLiveConfig(toOauth);
352
+ }
353
+ if (journal.from) {
354
+ const backup = readClaudeBackup(journal.from);
355
+ if (claudeRefreshFingerprint(parseClaudeCredentialBlob(backup)) === journal.fromFp) {
356
+ materializeInactiveProfile(journal.from, backup);
357
+ }
358
+ }
359
+ deleteStoredCredentials(getClaudeProfileHome(journal.to));
360
+ const target = findClaudeProfile(journal.to);
361
+ if (target)
362
+ setActiveSavedAccount('claude-code', target.id, { silent: true });
363
+ report.journal = 'finished';
364
+ }
365
+ catch (err) {
366
+ report.warnings.push(`Could not finish an interrupted account switch: ${err}`);
367
+ return;
368
+ }
369
+ }
370
+ else if (liveFp === journal.fromFp) {
371
+ // Nothing was installed (or it was rolled back): the outgoing login is live.
372
+ report.journal = 'rolled-back';
373
+ }
374
+ else {
375
+ report.journal = 'cleared';
376
+ report.warnings.push('An interrupted account switch was found, but the live login matches neither side; it was left as is.');
377
+ }
378
+ clearSwitchJournal();
379
+ }
380
+ /** The profile whose recorded refresh-token fingerprint is `fp`, other than `exceptUuid`. */
381
+ function findProfileHoldingFingerprint(fp, exceptUuid) {
382
+ return (listSavedAccountProfiles('claude-code').find((profile) => {
383
+ const uuid = profileKey(profile);
384
+ return uuid !== exceptUuid && readHealthSidecar('claude-code', profile.id)?.refreshFp === fp;
385
+ }) ?? null);
386
+ }
387
+ /**
388
+ * Enforce "the live account keeps no CLI-usable copy": if its profile home
389
+ * still holds a credential (older builds mirrored every fold there), remove
390
+ * it. When that copy is strictly newer than live, live is the dying link of
391
+ * the chain, so the copy is installed first (guarded `repair`). The fresher
392
+ * of the two stays in the private backup.
393
+ */
394
+ function demoteLiveDuplicateUnlocked(uuid, report) {
395
+ const home = getClaudeProfileHome(uuid);
396
+ const probe = readClaudeCredentialStore(home);
397
+ if (probe.status === 'absent')
398
+ return;
399
+ if (probe.status === 'error') {
400
+ report.warnings.push("The live account's profile copy could not be read; it was left in place.");
401
+ return;
402
+ }
403
+ try {
404
+ withClaudeRefreshLocksSync([getLiveClaudeHome(), home], () => {
405
+ const live = readClaudeCredentialStore();
406
+ const duplicate = readClaudeCredentialStore(home);
407
+ if (live.status === 'error' || duplicate.status === 'error') {
408
+ report.warnings.push('A credential store could not be read; the profile copy was left in place.');
409
+ return;
410
+ }
411
+ if (duplicate.status === 'absent')
412
+ return;
413
+ const liveCreds = live.status === 'ok' ? parseClaudeCredentialBlob(live.blob) : null;
414
+ const dupBlob = duplicate.status === 'ok' ? duplicate.blob : null;
415
+ const dupCreds = parseClaudeCredentialBlob(dupBlob);
416
+ if (dupCreds &&
417
+ claudeRefreshFingerprint(dupCreds) !== claudeRefreshFingerprint(liveCreds) &&
418
+ claudeCredentialFreshness(dupCreds) > claudeCredentialFreshness(liveCreds)) {
419
+ const install = installIntoLiveClaudeStore(dupBlob, uuid, 'repair', {
420
+ live,
421
+ liveAccountUuid: uuid,
422
+ });
423
+ if (install.ok) {
424
+ writeClaudeBackup(uuid, install.installed);
425
+ writeClaudeHealthSidecar(uuid, install.installed, 'live-sync');
426
+ report.repaired = uuid;
427
+ }
428
+ else {
429
+ // Keep the newer copy somewhere Claude Code cannot use it.
430
+ writeClaudeBackup(uuid, fresherOf(readClaudeBackup(uuid), dupBlob));
431
+ report.warnings.push(`The live Claude login looks older than its saved copy and could not be repaired (${install.reason}).`);
432
+ if (install.wrote)
433
+ return;
434
+ }
435
+ }
436
+ else if (liveCreds && live.status === 'ok') {
437
+ writeClaudeBackup(uuid, fresherOf(readClaudeBackup(uuid), live.blob));
438
+ }
439
+ if (deleteStoredCredentials(home))
440
+ report.demoted = uuid;
441
+ else
442
+ report.warnings.push("The live account's profile copy could not be removed.");
443
+ });
444
+ }
445
+ catch (err) {
446
+ if (err instanceof ClaudeRefreshLockBusyError) {
447
+ report.warnings.push("Claude Code was refreshing; the live account's profile copy is removed on the next sync.");
448
+ }
449
+ else {
450
+ report.warnings.push(`Could not remove the live account's profile copy: ${err}`);
451
+ }
452
+ }
453
+ }
454
+ /**
455
+ * Reconcile the live login with the saved profiles: finish an interrupted
456
+ * switch, register an unknown login (label = email), refresh the matching
457
+ * profile's backup when the live credential is newer, remove any CLI-usable
458
+ * copy of the live account from its profile home, and re-point the active
459
+ * pointer. Caller holds the swap lock. Never throws; problems land in
460
+ * `warnings`.
461
+ */
462
+ export function syncClaudeLiveStateUnlocked(source = 'live-sync') {
463
+ const report = emptyProviderSyncReport();
464
+ if (isLiveClaudeHomeSidekickProfile()) {
465
+ report.skipped = 'managed-home';
466
+ report.warnings.push('Unset CLAUDE_CONFIG_DIR before syncing; it points at a sidekick profile home.');
467
+ return report;
468
+ }
469
+ try {
470
+ resolveSwitchJournalUnlocked(report);
471
+ const identity = readActiveClaudeAccount();
472
+ if (!identity) {
473
+ report.skipped = 'logged-out';
474
+ return report;
475
+ }
476
+ let profile = findClaudeProfileForIdentity(identity);
477
+ if (!profile) {
478
+ profile = {
479
+ id: identity.uuid,
480
+ providerId: 'claude-code',
481
+ providerAccountId: identity.uuid,
482
+ email: identity.email,
483
+ label: identity.email,
484
+ addedAt: new Date().toISOString(),
485
+ metadata: { email: identity.email, origin: 'live-sync' },
486
+ };
487
+ ensureDirs();
488
+ upsertSavedAccountProfile(profile);
489
+ report.registered = { id: profile.id, email: identity.email };
490
+ }
491
+ else if (profile.email !== identity.email && identity.email) {
492
+ profile = {
493
+ ...profile,
494
+ email: identity.email,
495
+ metadata: { ...profile.metadata, email: identity.email },
496
+ };
497
+ upsertSavedAccountProfile(profile);
498
+ }
499
+ const uuid = profileKey(profile);
500
+ const live = readClaudeCredentialStore();
501
+ if (live.status === 'error') {
502
+ report.warnings.push('The live Claude credential store could not be read.');
503
+ }
504
+ else if (live.status === 'corrupt') {
505
+ report.warnings.push('The live Claude credential store is unreadable (not valid JSON); sign in again with `claude`.');
506
+ }
507
+ else if (live.status === 'ok') {
508
+ const liveCreds = parseClaudeCredentialBlob(live.blob);
509
+ const liveFp = claudeRefreshFingerprint(liveCreds);
510
+ const owner = liveFp ? findProfileHoldingFingerprint(liveFp, uuid) : null;
511
+ if (owner) {
512
+ // `.claude.json` names one account but the credential is another's
513
+ // (a switch or login mid-way): folding would file the chain under
514
+ // the wrong account, and demoting could delete the right one.
515
+ report.warnings.push(`The live credential belongs to ${owner.label ?? owner.email ?? owner.id}, not ${identity.email}; nothing was folded.`);
516
+ }
517
+ else if (liveCreds) {
518
+ const backupCreds = parseClaudeCredentialBlob(readClaudeBackup(uuid));
519
+ const liveFreshness = claudeCredentialFreshness(liveCreds);
520
+ const backupFreshness = claudeCredentialFreshness(backupCreds);
521
+ const needsFold = !backupCreds ||
522
+ liveFreshness > backupFreshness ||
523
+ (liveFreshness === backupFreshness &&
524
+ (claudeRefreshFingerprint(backupCreds) !== liveFp ||
525
+ !fs.existsSync(path.join(getClaudeProfileHome(uuid), '.claude.json'))));
526
+ if (needsFold) {
527
+ try {
528
+ writeClaudeProfileMirrorUnlocked(uuid, live.blob, readCurrentLiveOauthAccount() ?? {
529
+ emailAddress: identity.email,
530
+ accountUuid: identity.uuid,
531
+ }, source);
532
+ report.folded = profile.id;
533
+ }
534
+ catch (err) {
535
+ report.warnings.push(`Could not back up the live Claude credentials: ${err}`);
536
+ }
537
+ }
538
+ demoteLiveDuplicateUnlocked(uuid, report);
539
+ }
540
+ }
541
+ const active = getActiveSavedAccount('claude-code');
542
+ if (!active || active.id !== profile.id) {
543
+ setActiveSavedAccount('claude-code', profile.id, { silent: true });
544
+ report.repointed = profile.id;
545
+ }
546
+ }
547
+ catch (err) {
548
+ report.warnings.push(`Claude account sync failed: ${err}`);
549
+ }
550
+ return report;
551
+ }
552
+ /** {@link syncClaudeLiveStateUnlocked} under the swap lock. */
553
+ export function syncClaudeLiveState(source = 'live-sync') {
554
+ try {
555
+ return withClaudeAuthSwapLock(() => syncClaudeLiveStateUnlocked(source));
556
+ }
557
+ catch (err) {
558
+ return { warnings: [`Could not acquire the account-switch lock: ${err}`] };
559
+ }
560
+ }
561
+ /** Unregistered login homes that authenticated are kept this long for a late finalize. */
562
+ const AUTHENTICATED_PENDING_LOGIN_TTL_MS = 24 * 60 * 60 * 1000;
563
+ /**
564
+ * Remove isolated login homes that never finished: ones that never
565
+ * authenticated (the user closed the browser, the login timed out) once they
566
+ * are older than `olderThanMs`, and ones that authenticated but were never
567
+ * finalized (the host quit) after a day. Their Keychain item goes with them.
568
+ * Returns the removed login ids.
569
+ */
570
+ export function cleanupAbandonedClaudeLogins(olderThanMs = 6 * 60 * 60 * 1000, now = Date.now()) {
571
+ const removed = [];
572
+ let entries = [];
573
+ try {
574
+ entries = fs.readdirSync(getClaudeProfilesDir());
575
+ }
576
+ catch {
577
+ return removed;
578
+ }
579
+ const registered = new Set(listSavedAccountProfiles('claude-code').map((p) => p.id));
580
+ for (const loginId of entries) {
581
+ if (registered.has(loginId))
582
+ continue;
583
+ const dir = getClaudeProfileDir(loginId);
584
+ const pendingPath = path.join(dir, 'profile.json');
585
+ const pending = readJsonOrNull(pendingPath);
586
+ if (!pending)
587
+ continue;
588
+ const home = getClaudeProfileHome(loginId);
589
+ const threshold = isClaudeProfileAuthenticated(home)
590
+ ? Math.max(olderThanMs, AUTHENTICATED_PENDING_LOGIN_TTL_MS)
591
+ : olderThanMs;
592
+ const addedAt = pending.addedAt ? Date.parse(pending.addedAt) : NaN;
593
+ const age = Number.isNaN(addedAt) ? Number.POSITIVE_INFINITY : now - addedAt;
594
+ if (age < threshold)
595
+ continue;
596
+ try {
597
+ deleteStoredCredentials(home);
598
+ fs.rmSync(dir, { recursive: true, force: true });
599
+ removed.push(loginId);
600
+ }
601
+ catch {
602
+ /* best effort */
603
+ }
604
+ }
605
+ return removed;
606
+ }
607
+ // ── Keep-alive and launch support ────────────────────────────────────────
608
+ /**
609
+ * Why keep-alive must not run the CLI for this Claude profile, or null when it
610
+ * may. A refresh inside a profile home rotates that home's refresh token, so
611
+ * the home must hold the only live copy of its chain. Caller holds the swap lock.
612
+ */
613
+ function claudeKeepAliveBlockerUnlocked(profile) {
614
+ const uuid = profileKey(profile);
615
+ if (isLiveClaudeAccount(uuid))
616
+ return 'live account; the CLI refreshes it in normal use';
617
+ const store = readClaudeCredentialStore(getClaudeProfileHome(uuid));
618
+ if (store.status === 'error')
619
+ return 'its saved login could not be read';
620
+ const storeCreds = store.status === 'ok' ? parseClaudeCredentialBlob(store.blob) : null;
621
+ if (!storeCreds)
622
+ return 'no profile copy to refresh (keep-alive never restores backups)';
623
+ const live = readClaudeCredentialStore();
624
+ if (live.status === 'error')
625
+ return 'the live login could not be read';
626
+ const liveCreds = live.status === 'ok' ? parseClaudeCredentialBlob(live.blob) : null;
627
+ if (liveCreds && claudeRefreshFingerprint(liveCreds) === claudeRefreshFingerprint(storeCreds)) {
628
+ return 'same refresh token as the live login';
629
+ }
630
+ if (readHealthSidecar('claude-code', profile.id)?.source === 'live-sync') {
631
+ return 'copied from the live login; waits for a switch away from it';
632
+ }
633
+ const backupCreds = parseClaudeCredentialBlob(readClaudeBackup(uuid));
634
+ if (claudeCredentialFreshness(backupCreds) > claudeCredentialFreshness(storeCreds)) {
635
+ return 'its saved backup is newer than the profile copy';
636
+ }
637
+ return null;
638
+ }
639
+ function findClaudeProfileById(accountId) {
640
+ return listSavedAccountProfiles('claude-code').find((p) => p.id === accountId) ?? null;
641
+ }
642
+ /** {@link beginClaudeKeepAlive} without taking the lease (dry runs). */
643
+ export function getClaudeKeepAliveBlocker(accountId) {
644
+ try {
645
+ return withClaudeAuthSwapLock(() => {
646
+ const profile = findClaudeProfileById(accountId);
647
+ return profile ? claudeKeepAliveBlockerUnlocked(profile) : 'account not found';
648
+ });
649
+ }
650
+ catch (err) {
651
+ return `could not acquire the account-switch lock: ${err}`;
652
+ }
653
+ }
654
+ /**
655
+ * Check a Claude profile for keep-alive and take its lease, both under the
656
+ * swap lock, so no switch can make it live (or copy it into live) while the
657
+ * CLI refreshes it. Pair with {@link finishClaudeKeepAlive}.
658
+ */
659
+ export function beginClaudeKeepAlive(accountId, leaseMs) {
660
+ try {
661
+ return withClaudeAuthSwapLock(() => {
662
+ const profile = findClaudeProfileById(accountId);
663
+ if (!profile)
664
+ return { ok: false, reason: 'account not found' };
665
+ const blocker = claudeKeepAliveBlockerUnlocked(profile);
666
+ if (blocker)
667
+ return { ok: false, reason: blocker };
668
+ if (!acquireAccountLease('claude-code', accountId, leaseMs)) {
669
+ return { ok: false, reason: 'another keep-alive run holds this account' };
670
+ }
671
+ return { ok: true };
672
+ });
673
+ }
674
+ catch (err) {
675
+ return { ok: false, reason: `could not acquire the account-switch lock: ${err}` };
676
+ }
677
+ }
678
+ /** Record what the CLI refreshed (backup + sidecar) and release the lease. */
679
+ export function finishClaudeKeepAlive(accountId) {
680
+ try {
681
+ withClaudeAuthSwapLock(() => {
682
+ const profile = findClaudeProfileById(accountId);
683
+ if (!profile)
684
+ return;
685
+ const uuid = profileKey(profile);
686
+ if (isLiveClaudeAccount(uuid))
687
+ return;
688
+ const store = readClaudeCredentialStore(getClaudeProfileHome(uuid));
689
+ if (store.status !== 'ok')
690
+ return;
691
+ const backup = readClaudeBackup(uuid);
692
+ const storeCreds = parseClaudeCredentialBlob(store.blob);
693
+ if (storeCreds &&
694
+ claudeCredentialFreshness(storeCreds) >
695
+ claudeCredentialFreshness(parseClaudeCredentialBlob(backup))) {
696
+ const updated = mergeClaudeLoginIntoLive(backup, store.blob);
697
+ writeClaudeBackup(uuid, updated);
698
+ writeClaudeHealthSidecar(profile.id, updated, 'keepalive');
699
+ }
700
+ });
701
+ }
702
+ catch {
703
+ /* the next sync or probe records the refresh */
704
+ }
705
+ finally {
706
+ releaseAccountLease('claude-code', accountId);
707
+ }
708
+ }
709
+ /**
710
+ * Give an inactive profile a CLI-usable copy from its backup when its profile
711
+ * home has none (it was live when it was last seen, or a switch could not
712
+ * write it). User-initiated launches only; keep-alive never does this.
713
+ */
714
+ export function restoreClaudeProfileFromBackup(accountId) {
715
+ try {
716
+ return withClaudeAuthSwapLock(() => {
717
+ const profile = findClaudeProfileById(accountId);
718
+ if (!profile)
719
+ return 'none';
720
+ const uuid = profileKey(profile);
721
+ if (isLiveClaudeAccount(uuid))
722
+ return 'live';
723
+ const store = readClaudeCredentialStore(getClaudeProfileHome(uuid));
724
+ if (store.status !== 'absent')
725
+ return store.status === 'ok' ? 'present' : 'none';
726
+ const backup = readClaudeBackup(uuid);
727
+ if (!parseClaudeCredentialBlob(backup))
728
+ return 'none';
729
+ materializeInactiveProfile(uuid, backup);
730
+ return 'restored';
731
+ });
732
+ }
733
+ catch {
734
+ return 'none';
735
+ }
736
+ }
737
+ // ── Switch to account ────────────────────────────────────────────────────
738
+ export function switchToAccount(uuid, options = {}) {
739
+ const consumers = detectRunningAccountConsumersSync('claude-code');
740
+ return runClaudeSwitch(uuid, options, consumers);
741
+ }
742
+ /** {@link switchToAccount} with the process probe off the event loop. */
743
+ export async function switchToAccountAsync(uuid, options = {}) {
744
+ const consumers = await detectRunningAccountConsumers('claude-code');
745
+ return runClaudeSwitch(uuid, options, consumers);
746
+ }
747
+ function runClaudeSwitch(uuid, options, consumers) {
748
+ try {
749
+ // Keep the non-throwing result contract when the lock cannot be acquired.
750
+ return withClaudeAuthSwapLock(() => switchToAccountUnlocked(uuid, options, consumers));
751
+ }
752
+ catch (err) {
753
+ return switchFailure('claude-code', uuid, `Could not acquire the account-switch lock: ${err}`);
754
+ }
755
+ }
756
+ function switchToAccountUnlocked(uuid, options, consumers) {
757
+ const target = findClaudeProfile(uuid);
758
+ if (!target) {
759
+ return switchFailure('claude-code', uuid, `Account ${uuid} not found in registry.`);
760
+ }
761
+ const targetName = target.label ?? target.email ?? uuid;
762
+ // Refuse to treat one of our own profile homes as the live home: a shell
763
+ // that eval'd `sidekick accounts env` would otherwise overwrite that profile.
764
+ if (isLiveClaudeHomeSidekickProfile()) {
765
+ return switchFailure('claude-code', uuid, `CLAUDE_CONFIG_DIR points at a sidekick profile home (${path.resolve(getLiveClaudeHome())}); unset it before switching accounts.`);
766
+ }
767
+ // Phase 1: register the outgoing login if sidekick has never seen it, finish
768
+ // any interrupted switch, and enforce the one-copy rule. Only then is the
769
+ // "previous" account known: it is whatever was really live.
770
+ const sync = syncClaudeLiveStateUnlocked('live-sync');
771
+ const previousAccountId = getActiveSavedAccount('claude-code')?.id ?? null;
772
+ const base = {
773
+ success: true,
774
+ provider: 'claude-code',
775
+ accountId: target.id,
776
+ previousAccountId,
777
+ verified: false,
778
+ verification: 'none',
779
+ warnings: [...sync.warnings],
780
+ hints: [],
781
+ runningConsumers: consumers,
782
+ email: target.email,
783
+ };
784
+ if (readActiveClaudeAccount()?.uuid === uuid) {
785
+ if (previousAccountId !== target.id)
786
+ setActiveSavedAccount('claude-code', target.id);
787
+ return finishSwitchResult({
788
+ ...base,
789
+ alreadyActive: true,
790
+ verified: true,
791
+ verification: 'store',
792
+ health: getAccountHealth('claude-code', target.id),
793
+ hints: [`${targetName} is already the active Claude account.`],
794
+ });
795
+ }
796
+ const stored = readStoredClaudeAccount(uuid);
797
+ if (!stored) {
798
+ return finishSwitchResult({
799
+ ...base,
800
+ success: false,
801
+ needsLogin: true,
802
+ error: `Stored credentials for "${targetName}" not found; sign in again.`,
803
+ });
804
+ }
805
+ // Preflight on the credential that would be installed: never install one we
806
+ // already know is dead.
807
+ const health = getAccountHealth('claude-code', target.id, { probe: 'store' });
808
+ base.health = health;
809
+ if ((health.state === 'expired' || health.state === 'missing') && !options.force) {
810
+ return finishSwitchResult({
811
+ ...base,
812
+ success: false,
813
+ needsLogin: true,
814
+ error: `Stored credentials for "${targetName}" have ${health.state === 'missing' ? 'not been saved' : 'expired'}; sign in again.`,
815
+ });
816
+ }
817
+ if (health.state === 'expiring' && health.reason) {
818
+ base.warnings.push(`"${targetName}": ${health.reason}`);
819
+ }
820
+ if (!options.force && isAccountLeaseHeld('claude-code', target.id)) {
821
+ return finishSwitchResult({
822
+ ...base,
823
+ success: false,
824
+ error: `Keep-alive is refreshing "${targetName}" right now; try again in a few seconds.`,
825
+ });
826
+ }
827
+ try {
828
+ return withClaudeRefreshLocksSync([getLiveClaudeHome(), getClaudeProfileHome(uuid)], () => installSwitchTargetUnlocked(target, targetName, base, previousAccountId));
829
+ }
830
+ catch (err) {
831
+ return finishSwitchResult({
832
+ ...base,
833
+ success: false,
834
+ error: err instanceof ClaudeRefreshLockBusyError
835
+ ? `${err.message} Nothing was changed.`
836
+ : `The switch failed before anything was changed: ${err}`,
837
+ });
838
+ }
839
+ }
840
+ /**
841
+ * Capture → journal → install → verify → commit, holding the swap lock and
842
+ * Claude Code's refresh locks for the live home and the target's home.
843
+ */
844
+ function installSwitchTargetUnlocked(target, targetName, base, previousAccountId) {
845
+ const uuid = profileKey(target);
846
+ const fail = (error, extra = {}) => finishSwitchResult({ ...base, success: false, error, ...extra });
847
+ // Re-read inside the refresh locks: a keep-alive or CLI may have moved either store.
848
+ const stored = readStoredClaudeAccount(uuid);
849
+ const targetCreds = parseClaudeCredentialBlob(stored?.credentials);
850
+ if (!stored || !targetCreds) {
851
+ return fail(`Stored credentials for "${targetName}" not found; sign in again.`, {
852
+ needsLogin: true,
853
+ });
854
+ }
855
+ let live = readClaudeCredentialStore();
856
+ if (live.status === 'error') {
857
+ return fail('The live Claude login could not be read, so nothing was changed. Try again.');
858
+ }
859
+ const liveIdentity = readActiveClaudeAccount();
860
+ const outgoing = liveIdentity ? findClaudeProfileForIdentity(liveIdentity) : null;
861
+ const outgoingUuid = outgoing ? profileKey(outgoing) : null;
862
+ let liveCreds = live.status === 'ok' ? parseClaudeCredentialBlob(live.blob) : null;
863
+ if (outgoingUuid === uuid) {
864
+ // The live login is this profile under a rotated account id (matched by
865
+ // email): installing its saved copy would be a downgrade, not a switch.
866
+ return finishSwitchResult({
867
+ ...base,
868
+ alreadyActive: true,
869
+ verified: true,
870
+ verification: 'store',
871
+ health: getAccountHealth('claude-code', target.id),
872
+ hints: [`${targetName} is already the active Claude account.`],
873
+ });
874
+ }
875
+ const liveFp = claudeRefreshFingerprint(liveCreds);
876
+ const liveOwner = liveFp && outgoingUuid ? findProfileHoldingFingerprint(liveFp, outgoingUuid) : null;
877
+ if (liveOwner) {
878
+ // Capturing would file that chain under the wrong account.
879
+ return fail(`The live credential belongs to ${liveOwner.label ?? liveOwner.email ?? liveOwner.id}, but the live identity says ${liveIdentity?.email}; run \`claude\` once (or sign in again) and retry. Nothing was changed.`);
880
+ }
881
+ // Capture the outgoing login, verified, before anything replaces it. This
882
+ // is mandatory even with `force`: losing it would lose that account's chain.
883
+ let captured = false;
884
+ const capture = (blob) => {
885
+ if (!outgoingUuid) {
886
+ return 'The live login has no account identity to save it under; run `claude` once and retry.';
887
+ }
888
+ try {
889
+ writeClaudeBackup(outgoingUuid, blob);
890
+ return null;
891
+ }
892
+ catch (err) {
893
+ return `The outgoing login could not be saved, so nothing was changed: ${err}`;
894
+ }
895
+ };
896
+ if (liveCreds && live.status === 'ok') {
897
+ const error = capture(live.blob);
898
+ if (error)
899
+ return fail(error);
900
+ captured = true;
901
+ }
902
+ const fromFp = claudeRefreshFingerprint(liveCreds);
903
+ const toFp = claudeRefreshFingerprint(targetCreds);
904
+ writeSwitchJournal({
905
+ from: outgoingUuid,
906
+ to: uuid,
907
+ fromFp,
908
+ toFp,
909
+ at: new Date().toISOString(),
910
+ });
911
+ // The live store must not have moved since the capture.
912
+ const recheck = readClaudeCredentialStore();
913
+ if (recheck.status === 'error') {
914
+ clearSwitchJournal();
915
+ return fail('The live Claude login could not be re-read, so nothing was changed. Try again.');
916
+ }
917
+ const recheckCreds = recheck.status === 'ok' ? parseClaudeCredentialBlob(recheck.blob) : null;
918
+ if (claudeRefreshFingerprint(recheckCreds) !== fromFp &&
919
+ recheck.status === 'ok' &&
920
+ recheckCreds) {
921
+ const error = capture(recheck.blob);
922
+ if (error) {
923
+ clearSwitchJournal();
924
+ return fail(error);
925
+ }
926
+ captured = true;
927
+ live = recheck;
928
+ liveCreds = recheckCreds;
929
+ writeSwitchJournal({
930
+ from: outgoingUuid,
931
+ to: uuid,
932
+ fromFp: claudeRefreshFingerprint(liveCreds),
933
+ toFp,
934
+ at: new Date().toISOString(),
935
+ });
936
+ }
937
+ let originalConfig = null;
938
+ try {
939
+ originalConfig = fs.readFileSync(getClaudeConfigPath(), 'utf8');
940
+ }
941
+ catch {
942
+ /* no existing config to back up */
943
+ }
944
+ const install = installIntoLiveClaudeStore(stored.credentials, uuid, 'switch', {
945
+ live,
946
+ liveAccountUuid: liveIdentity?.uuid ?? null,
947
+ capturedOutgoing: captured,
948
+ });
949
+ const rollback = (written) => {
950
+ // Nothing usable was displaced when live held no login: there is nothing
951
+ // to put back, and the result must not claim otherwise.
952
+ let restored = live.status === 'ok';
953
+ if (live.status === 'ok') {
954
+ const current = readClaudeCredentialStore();
955
+ const untouched = current.status === 'ok' && JSON.stringify(current.blob) === JSON.stringify(live.blob);
956
+ if (!untouched) {
957
+ const undo = installIntoLiveClaudeStore(live.blob, outgoingUuid ?? uuid, 'rollback', {
958
+ live: current,
959
+ liveAccountUuid: readActiveClaudeAccount()?.uuid ?? null,
960
+ written,
961
+ });
962
+ restored = undo.ok;
963
+ }
964
+ }
965
+ if (originalConfig !== null) {
966
+ try {
967
+ fs.writeFileSync(getClaudeConfigPath(), originalConfig);
968
+ }
969
+ catch {
970
+ restored = false;
971
+ }
972
+ }
973
+ try {
974
+ setActiveSavedAccount('claude-code', previousAccountId, { silent: true });
975
+ }
976
+ catch {
977
+ /* rollback failed */
978
+ }
979
+ clearSwitchJournal();
980
+ return restored;
981
+ };
982
+ const restoredSuffix = (restored) => restored
983
+ ? 'the previous login was restored.'
984
+ : captured
985
+ ? 'the previous login could not be restored automatically; it is saved, run `sidekick accounts doctor`.'
986
+ : 'the live store held no usable login before the switch; run `sidekick accounts doctor`.';
987
+ if (!install.ok) {
988
+ if (!install.wrote) {
989
+ clearSwitchJournal();
990
+ return fail(install.reason);
991
+ }
992
+ const restored = rollback(install.attempted);
993
+ return fail(`Failed to write Claude credentials (${install.reason}); ${restoredSuffix(restored)}`);
994
+ }
995
+ try {
996
+ mergeOauthAccountIntoLiveConfig(stored.oauthAccount);
997
+ }
998
+ catch (err) {
999
+ const restored = rollback(install.installed);
1000
+ return fail(`Failed to write the Claude identity (${err}); ${restoredSuffix(restored)}`);
1001
+ }
1002
+ // Verify by re-reading the live store.
1003
+ const installed = readClaudeCredentialStore(undefined, { keychainOnly: true });
1004
+ const installedCreds = installed.status === 'ok' ? parseClaudeCredentialBlob(installed.blob) : null;
1005
+ const verified = installedCreds !== null &&
1006
+ installedCreds.accessToken === targetCreds.accessToken &&
1007
+ readActiveClaudeAccount()?.uuid === uuid;
1008
+ if (!verified) {
1009
+ const restored = rollback(install.installed);
1010
+ return fail(`The live Claude credential store did not reflect the switch to "${targetName}"; ${restoredSuffix(restored)}`, { verification: 'failed' });
1011
+ }
1012
+ // Pointer before the rest of the commit: from here on the switch happened.
1013
+ try {
1014
+ setActiveSavedAccount('claude-code', target.id);
1015
+ }
1016
+ catch (err) {
1017
+ const restored = rollback(install.installed);
1018
+ return fail(`Failed to update account registry (${err}); ${restoredSuffix(restored)}`);
1019
+ }
1020
+ // Commit: the outgoing account becomes inactive and gets its single CLI copy;
1021
+ // the target is live and keeps none.
1022
+ if (outgoingUuid && captured && live.status === 'ok') {
1023
+ try {
1024
+ materializeInactiveProfile(outgoingUuid, live.blob);
1025
+ writeClaudeHealthSidecar(outgoingUuid, live.blob, 'switch');
1026
+ }
1027
+ catch (err) {
1028
+ base.warnings.push(`The previous login is saved, but its profile copy could not be written yet (${err}).`);
1029
+ }
1030
+ }
1031
+ try {
1032
+ writeClaudeBackup(uuid, install.installed);
1033
+ writeClaudeHealthSidecar(uuid, install.installed, 'switch');
1034
+ }
1035
+ catch {
1036
+ /* the next sync refreshes the live account's backup */
1037
+ }
1038
+ if (!deleteStoredCredentials(getClaudeProfileHome(uuid))) {
1039
+ base.warnings.push(`"${targetName}"'s profile copy could not be removed; the next sync retries.`);
1040
+ }
1041
+ clearSwitchJournal();
1042
+ let undoToken;
1043
+ try {
1044
+ undoToken = writeLastSwitch('claude-code', previousAccountId, target.id).token;
1045
+ }
1046
+ catch {
1047
+ base.warnings.push('The switch succeeded but could not be recorded for undo.');
1048
+ }
1049
+ return finishSwitchResult({
1050
+ ...base,
1051
+ verified: true,
1052
+ verification: 'store',
1053
+ undoToken,
1054
+ hints: [`New claude sessions use ${target.email ?? targetName}.`],
1055
+ });
1056
+ }
1057
+ /**
1058
+ * Install a saved profile's credentials into the live home without changing
1059
+ * which account is active: used right after a re-login of the live account,
1060
+ * whose freshly minted token must replace the (possibly dead) live one. The
1061
+ * guard only lets a strictly fresher credential of the same account through;
1062
+ * when live is already at least as new, nothing is written.
1063
+ */
1064
+ export function applyClaudeProfileToLiveHome(uuid) {
1065
+ if (isLiveClaudeHomeSidekickProfile()) {
1066
+ return {
1067
+ success: false,
1068
+ error: `CLAUDE_CONFIG_DIR points at a sidekick profile home (${path.resolve(getLiveClaudeHome())}); the login was saved, but unset it before making the account live.`,
1069
+ };
1070
+ }
1071
+ try {
1072
+ return withClaudeAuthSwapLock(() => {
1073
+ const stored = readStoredClaudeAccount(uuid);
1074
+ if (!stored) {
1075
+ return { success: false, error: `Stored credentials for ${uuid} not found.` };
1076
+ }
1077
+ const profileHome = getClaudeProfileHome(uuid);
1078
+ return withClaudeRefreshLocksSync([getLiveClaudeHome(), profileHome], () => {
1079
+ const live = readClaudeCredentialStore();
1080
+ const liveUuid = readActiveClaudeAccount()?.uuid ?? null;
1081
+ const install = installIntoLiveClaudeStore(stored.credentials, uuid, 'relogin', {
1082
+ live,
1083
+ liveAccountUuid: liveUuid,
1084
+ });
1085
+ if (!install.ok) {
1086
+ if (install.code === 'not-newer') {
1087
+ return { success: true, warning: install.reason };
1088
+ }
1089
+ return {
1090
+ success: false,
1091
+ error: `Could not install the refreshed credentials: ${install.reason}`,
1092
+ };
1093
+ }
1094
+ mergeOauthAccountIntoLiveConfig(stored.oauthAccount);
1095
+ writeClaudeBackup(uuid, install.installed);
1096
+ writeClaudeHealthSidecar(uuid, install.installed, 'login');
1097
+ // The account is live now: its profile home keeps no CLI copy.
1098
+ deleteStoredCredentials(profileHome);
1099
+ return { success: true };
1100
+ });
1101
+ });
1102
+ }
1103
+ catch (err) {
1104
+ return { success: false, error: `Could not install the refreshed credentials: ${err}` };
1105
+ }
1106
+ }
1107
+ /**
1108
+ * @deprecated The home `claude` should use for the active account. The live
1109
+ * account's profile home holds no credential, so this is the live home for the
1110
+ * live account; kept for API compatibility.
1111
+ */
1112
+ export function resolveActiveClaudeHome() {
1113
+ const active = getActiveSavedAccount('claude-code');
1114
+ if (!active)
1115
+ return getLiveClaudeHome();
1116
+ const uuid = active.providerAccountId ?? active.id;
1117
+ return isLiveClaudeAccount(uuid) ? getLiveClaudeHome() : getClaudeProfileHome(uuid);
1118
+ }
1119
+ /**
1120
+ * @deprecated Re-install the active profile's credential into the live home.
1121
+ * It never follows the registry pointer across accounts (after a native
1122
+ * `claude /login` the pointer can be stale): when the live login is another
1123
+ * account it refuses — use {@link switchToAccount}. For the same account it is
1124
+ * a guarded re-login that writes only a strictly fresher credential.
1125
+ */
1126
+ export function applyActiveClaudeToLiveHome() {
1127
+ const active = getActiveSavedAccount('claude-code');
1128
+ if (!active) {
1129
+ return { success: false, error: 'No active Claude account found.' };
1130
+ }
1131
+ const uuid = active.providerAccountId ?? active.id;
1132
+ const liveUuid = readActiveClaudeAccount()?.uuid;
1133
+ if (liveUuid && liveUuid !== uuid) {
1134
+ return {
1135
+ success: false,
1136
+ error: 'The live Claude login belongs to another account; use switchToAccount() to change accounts.',
1137
+ };
1138
+ }
1139
+ if (!readStoredClaudeAccount(uuid)) {
1140
+ return { success: false, error: `Stored credentials for ${active.email ?? uuid} not found.` };
1141
+ }
1142
+ return applyClaudeProfileToLiveHome(uuid);
1143
+ }
1144
+ export function reconcileClaudeAuthState() {
1145
+ try {
1146
+ const markerPath = getClaudeMigrationMarkerPath();
1147
+ if (fs.existsSync(markerPath))
1148
+ return;
1149
+ const registry = readSavedAccountRegistry();
1150
+ if (registry) {
1151
+ for (const profile of registry.accounts.filter((account) => account.providerId === 'claude-code')) {
1152
+ try {
1153
+ const uuid = profile.providerAccountId ?? profile.id;
1154
+ // The live account keeps no profile-home copy; nothing to migrate.
1155
+ if (isLiveClaudeAccount(uuid))
1156
+ continue;
1157
+ if (readActiveCredentials(getClaudeProfileHome(uuid)) &&
1158
+ readProfileClaudeOauthAccount(uuid)) {
1159
+ continue;
1160
+ }
1161
+ const credentials = readClaudeBackup(uuid);
1162
+ const oauthAccount = readFlatClaudeOauthAccount(uuid);
1163
+ if (!credentials || !oauthAccount)
1164
+ continue;
1165
+ withClaudeAuthSwapLock(() => writeClaudeProfileMirrorUnlocked(uuid, credentials, oauthAccount, 'migration'));
1166
+ }
1167
+ catch {
1168
+ // Migration is best-effort per account.
1169
+ }
1170
+ }
1171
+ }
1172
+ fs.mkdirSync(path.dirname(markerPath), { recursive: true, mode: 0o700 });
1173
+ fs.writeFileSync(markerPath, new Date().toISOString() + '\n', {
1174
+ encoding: 'utf8',
1175
+ mode: 0o600,
1176
+ });
1177
+ }
1178
+ catch {
1179
+ // Reconciliation must never break startup.
1180
+ }
1181
+ }
1182
+ // ── Remove account ───────────────────────────────────────────────────────
1183
+ export function removeAccount(uuid) {
1184
+ // One read-modify-write of the registry under the lock (see addCurrentAccount).
1185
+ return withSavedAccountRegistryLock(() => {
1186
+ const profile = findClaudeProfile(uuid);
1187
+ if (!profile) {
1188
+ return { success: false, error: `Account ${uuid} not found.` };
1189
+ }
1190
+ // Remove backed-up files and the profile home (credential store included).
1191
+ for (const filePath of [
1192
+ getClaudeCredentialBackupPath(uuid),
1193
+ path.join(getConfigsDir(), `${uuid}.config.json`),
1194
+ ]) {
1195
+ try {
1196
+ fs.unlinkSync(filePath);
1197
+ }
1198
+ catch {
1199
+ /* ok */
1200
+ }
1201
+ }
1202
+ try {
1203
+ deleteStoredCredentials(getClaudeProfileHome(uuid));
1204
+ fs.rmSync(getClaudeProfileDir(profile.id), { recursive: true, force: true });
1205
+ }
1206
+ catch {
1207
+ /* ok */
1208
+ }
1209
+ removeSavedAccountProfileUnlocked('claude-code', profile.id);
1210
+ return { success: true };
1211
+ });
1212
+ }
1213
+ // ── Query helpers ────────────────────────────────────────────────────────
1214
+ export function listAccounts() {
1215
+ return listSavedAccountProfiles('claude-code').map((account) => ({
1216
+ uuid: account.providerAccountId ?? account.id,
1217
+ email: account.email ?? account.metadata?.email ?? 'unknown',
1218
+ label: account.label,
1219
+ addedAt: account.addedAt,
1220
+ }));
1221
+ }
1222
+ export function getActiveAccount() {
1223
+ const active = getActiveSavedAccount('claude-code');
1224
+ if (!active)
1225
+ return null;
1226
+ return {
1227
+ uuid: active.providerAccountId ?? active.id,
1228
+ email: active.email ?? active.metadata?.email ?? 'unknown',
1229
+ label: active.label,
1230
+ addedAt: active.addedAt,
1231
+ };
1232
+ }
1233
+ /**
1234
+ * Resolves the *currently logged-in* Claude account for display, preferring the
1235
+ * live `.claude.json` identity over the saved registry pointer (which only
1236
+ * sidekick's own switch flow updates and therefore goes stale after a native
1237
+ * `claude /login`).
1238
+ *
1239
+ * Safe self-heal: when the live account matches a saved profile (by account UUID,
1240
+ * email as fallback) that isn't the current active pointer, the pointer is
1241
+ * re-pointed so registry-keyed data tracks reality too. Never creates or deletes
1242
+ * profiles; an unknown live account is shown as-is with no label and no write
1243
+ * (the sync engine registers it on the next startup or watch event).
1244
+ */
1245
+ export function resolveActiveClaudeAccount(options = {}) {
1246
+ const selfHeal = options.selfHeal ?? true;
1247
+ const live = readActiveClaudeAccount();
1248
+ if (live) {
1249
+ const match = findClaudeProfileForIdentity(live);
1250
+ if (match) {
1251
+ const active = getActiveSavedAccount('claude-code');
1252
+ // Hot paths (the status line runs on every prompt) pass selfHeal: false
1253
+ // so a persistently failing registry write never becomes a write per
1254
+ // prompt; the next ordinary command repairs the pointer instead.
1255
+ if (selfHeal && (!active || active.id !== match.id)) {
1256
+ // Self-heal is best-effort: a registry write failure (read-only/full
1257
+ // disk) must never break display or extension activation. We still
1258
+ // return the correct live identity below.
1259
+ // Silent: this is a read path repairing a pointer to the profile that
1260
+ // already matches the live login, so subscribers have nothing new to see.
1261
+ try {
1262
+ setActiveSavedAccount('claude-code', match.id, { silent: true });
1263
+ }
1264
+ catch {
1265
+ /* keep going with the live identity */
1266
+ }
1267
+ }
1268
+ }
1269
+ return {
1270
+ email: live.email,
1271
+ label: match?.label,
1272
+ providerAccountId: live.uuid,
1273
+ registryAccountId: match?.id,
1274
+ source: 'live',
1275
+ };
1276
+ }
1277
+ const active = getActiveAccount();
1278
+ if (active) {
1279
+ return {
1280
+ email: active.email,
1281
+ label: active.label,
1282
+ providerAccountId: active.uuid,
1283
+ registryAccountId: active.uuid,
1284
+ source: 'registry',
1285
+ };
1286
+ }
1287
+ return { source: 'none' };
1288
+ }
1289
+ export function isMultiAccountEnabled() {
1290
+ return listSavedAccountProfiles('claude-code').length >= 1;
1291
+ }