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