sidekick-shared 0.26.5 → 0.26.6

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 (55) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +56 -51
  3. package/dist/accountChanges.js +21 -2
  4. package/dist/accountHealth.d.ts +72 -0
  5. package/dist/accountHealth.js +286 -0
  6. package/dist/accountLaunch.d.ts +30 -0
  7. package/dist/accountLaunch.js +124 -0
  8. package/dist/accountManager.d.ts +33 -8
  9. package/dist/accountManager.js +183 -50
  10. package/dist/accountRegistry.d.ts +10 -0
  11. package/dist/accountRegistry.js +42 -22
  12. package/dist/accountSwitch.d.ts +43 -0
  13. package/dist/accountSwitch.js +115 -0
  14. package/dist/accountSync.d.ts +30 -0
  15. package/dist/accountSync.js +180 -0
  16. package/dist/accountSyncTypes.d.ts +25 -0
  17. package/dist/accountSyncTypes.js +7 -0
  18. package/dist/accounts.d.ts +58 -6
  19. package/dist/accounts.js +408 -189
  20. package/dist/claudeCredentials.d.ts +38 -0
  21. package/dist/claudeCredentials.js +74 -0
  22. package/dist/claudeProfiles.d.ts +40 -0
  23. package/dist/claudeProfiles.js +102 -10
  24. package/dist/codexAuth.d.ts +32 -0
  25. package/dist/codexAuth.js +127 -0
  26. package/dist/codexPaths.d.ts +21 -0
  27. package/dist/codexPaths.js +117 -0
  28. package/dist/codexProfiles.d.ts +58 -5
  29. package/dist/codexProfiles.js +574 -289
  30. package/dist/credentialIO.d.ts +12 -5
  31. package/dist/credentialIO.js +63 -29
  32. package/dist/credentials.d.ts +8 -0
  33. package/dist/credentials.js +28 -32
  34. package/dist/ensureDefaultAccounts.d.ts +8 -0
  35. package/dist/ensureDefaultAccounts.js +42 -82
  36. package/dist/index.d.ts +27 -10
  37. package/dist/index.js +61 -9
  38. package/dist/keepAlive.d.ts +44 -0
  39. package/dist/keepAlive.js +106 -0
  40. package/dist/processDetection.d.ts +32 -0
  41. package/dist/processDetection.js +210 -0
  42. package/dist/schemas/accountManager.d.ts +283 -1
  43. package/dist/schemas/accountManager.js +122 -1
  44. package/dist/schemas/assistantTurn.d.ts +2 -2
  45. package/dist/schemas/index.d.ts +6 -1
  46. package/dist/schemas/index.js +14 -1
  47. package/dist/schemas/observedSessionV1.d.ts +21 -21
  48. package/dist/schemas/quota.d.ts +18 -18
  49. package/dist/schemas/quotaHistory.d.ts +1 -1
  50. package/dist/schemas/stateFile.d.ts +5 -5
  51. package/dist/terminalSync.d.ts +5 -5
  52. package/dist/terminalSync.js +7 -112
  53. package/dist/writers/atomic.d.ts +2 -0
  54. package/dist/writers/atomic.js +49 -2
  55. package/package.json +1 -1
package/dist/accounts.js CHANGED
@@ -1,9 +1,14 @@
1
1
  "use strict";
2
2
  /**
3
- * Multi-account management for Claude Max subscriptions.
3
+ * Multi-account management for Claude Code logins.
4
4
  *
5
- * Handles saving, listing, switching, and removing Claude accounts.
5
+ * Handles saving, listing, switching, syncing, and removing Claude accounts.
6
6
  * Both the VS Code extension and CLI consume this module.
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.
7
12
  */
8
13
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
9
14
  if (k2 === undefined) k2 = k;
@@ -39,11 +44,20 @@ var __importStar = (this && this.__importStar) || (function () {
39
44
  };
40
45
  })();
41
46
  Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.storeClaudeProfileIdentity = storeClaudeProfileIdentity;
48
+ exports.storeClaudeProfileCredentials = storeClaudeProfileCredentials;
49
+ exports.readStoredClaudeAccount = readStoredClaudeAccount;
42
50
  exports.readAccountRegistry = readAccountRegistry;
43
51
  exports.writeAccountRegistry = writeAccountRegistry;
44
52
  exports.readActiveClaudeAccount = readActiveClaudeAccount;
45
53
  exports.addCurrentAccount = addCurrentAccount;
54
+ exports.withClaudeAuthSwapLock = withClaudeAuthSwapLock;
55
+ exports.syncClaudeLiveStateUnlocked = syncClaudeLiveStateUnlocked;
56
+ exports.syncClaudeLiveState = syncClaudeLiveState;
57
+ exports.cleanupAbandonedClaudeLogins = cleanupAbandonedClaudeLogins;
46
58
  exports.switchToAccount = switchToAccount;
59
+ exports.switchToAccountAsync = switchToAccountAsync;
60
+ exports.applyClaudeProfileToLiveHome = applyClaudeProfileToLiveHome;
47
61
  exports.resolveActiveClaudeHome = resolveActiveClaudeHome;
48
62
  exports.applyActiveClaudeToLiveHome = applyActiveClaudeToLiveHome;
49
63
  exports.reconcileClaudeAuthState = reconcileClaudeAuthState;
@@ -54,29 +68,16 @@ exports.resolveActiveClaudeAccount = resolveActiveClaudeAccount;
54
68
  exports.isMultiAccountEnabled = isMultiAccountEnabled;
55
69
  const fs = __importStar(require("fs"));
56
70
  const path = __importStar(require("path"));
57
- const os = __importStar(require("os"));
58
71
  const credentialIO_1 = require("./credentialIO");
59
72
  const atomic_1 = require("./writers/atomic");
60
73
  const accountRegistry_1 = require("./accountRegistry");
61
74
  const claudeProfiles_1 = require("./claudeProfiles");
75
+ const claudeCredentials_1 = require("./claudeCredentials");
76
+ const accountHealth_1 = require("./accountHealth");
77
+ const accountSwitch_1 = require("./accountSwitch");
78
+ const accountSyncTypes_1 = require("./accountSyncTypes");
79
+ const processDetection_1 = require("./processDetection");
62
80
  // ── Paths ────────────────────────────────────────────────────────────────
63
- function getClaudeDir() {
64
- return path.join(os.homedir(), '.claude');
65
- }
66
- function getClaudeConfigPath() {
67
- // Primary: ~/.claude/.claude.json
68
- const primary = path.join(getClaudeDir(), '.claude.json');
69
- try {
70
- const data = JSON.parse(fs.readFileSync(primary, 'utf8'));
71
- if (data?.oauthAccount)
72
- return primary;
73
- }
74
- catch {
75
- /* fall through */
76
- }
77
- // Fallback: ~/.claude.json (some Claude Code versions store config here)
78
- return path.join(os.homedir(), '.claude.json');
79
- }
80
81
  function getCredentialsDir() {
81
82
  return path.join((0, accountRegistry_1.getAccountsDir)(), 'credentials');
82
83
  }
@@ -104,15 +105,34 @@ function readOauthAccountFromConfigPath(filePath) {
104
105
  const parsed = readJsonOrNull(filePath);
105
106
  return parsed?.oauthAccount ?? null;
106
107
  }
107
- function writeClaudeProfileConfig(uuid, oauthAccount) {
108
+ /** Store a profile's identity (`oauthAccount`) in its home and the flat backup. */
109
+ function storeClaudeProfileIdentity(uuid, oauthAccount) {
108
110
  (0, atomic_1.atomicWriteJsonSync)(path.join((0, claudeProfiles_1.getClaudeProfileHome)(uuid), '.claude.json'), { oauthAccount });
111
+ try {
112
+ (0, atomic_1.atomicWriteJsonSync)(path.join(getConfigsDir(), `${uuid}.config.json`), oauthAccount);
113
+ }
114
+ catch {
115
+ /* the profile home is canonical; the flat copy is a fallback */
116
+ }
109
117
  }
110
- function writeClaudeProfileCredentials(uuid, credentials) {
118
+ /**
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.
122
+ */
123
+ function storeClaudeProfileCredentials(uuid, credentials, source) {
111
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);
127
+ }
128
+ catch {
129
+ /* the profile home is canonical; the flat copy is a fallback */
130
+ }
131
+ (0, accountHealth_1.writeClaudeHealthSidecar)(uuid, credentials, source);
112
132
  }
113
- function writeClaudeProfileMirror(uuid, credentials, oauthAccount) {
114
- writeClaudeProfileConfig(uuid, oauthAccount);
115
- writeClaudeProfileCredentials(uuid, credentials);
133
+ function writeClaudeProfileMirror(uuid, credentials, oauthAccount, source) {
134
+ storeClaudeProfileIdentity(uuid, oauthAccount);
135
+ storeClaudeProfileCredentials(uuid, credentials, source);
116
136
  }
117
137
  function readFlatClaudeCredentials(uuid) {
118
138
  return readJsonOrNull(path.join(getCredentialsDir(), `${uuid}.credentials.json`));
@@ -133,7 +153,7 @@ function readStoredClaudeAccount(uuid) {
133
153
  const flatOauthAccount = readFlatClaudeOauthAccount(uuid);
134
154
  if (flatCredentials && flatOauthAccount) {
135
155
  try {
136
- writeClaudeProfileMirror(uuid, flatCredentials, flatOauthAccount);
156
+ writeClaudeProfileMirror(uuid, flatCredentials, flatOauthAccount, 'migration');
137
157
  }
138
158
  catch {
139
159
  // Flat backups remain the fallback when profile materialization fails.
@@ -143,45 +163,21 @@ function readStoredClaudeAccount(uuid) {
143
163
  return null;
144
164
  }
145
165
  function readCurrentLiveOauthAccount() {
146
- return readOauthAccountFromConfigPath(getClaudeConfigPath());
147
- }
148
- function backupCurrentClaudeLiveHome() {
149
- const currentActive = readActiveClaudeAccount();
150
- if (!currentActive)
151
- return;
152
- const originalCreds = (0, credentialIO_1.readActiveCredentials)();
153
- const originalOauthAccount = readCurrentLiveOauthAccount();
154
- if (originalCreds) {
155
- try {
156
- (0, atomic_1.atomicWriteJsonSync)(path.join(getCredentialsDir(), `${currentActive.uuid}.credentials.json`), originalCreds);
157
- writeClaudeProfileCredentials(currentActive.uuid, originalCreds);
158
- }
159
- catch {
160
- // Best effort only.
161
- }
162
- }
163
- if (originalOauthAccount) {
164
- try {
165
- (0, atomic_1.atomicWriteJsonSync)(path.join(getConfigsDir(), `${currentActive.uuid}.config.json`), originalOauthAccount);
166
- writeClaudeProfileConfig(currentActive.uuid, originalOauthAccount);
167
- }
168
- catch {
169
- // Best effort only.
170
- }
171
- }
166
+ return readOauthAccountFromConfigPath((0, claudeProfiles_1.getClaudeConfigPath)());
172
167
  }
173
168
  function mergeOauthAccountIntoLiveConfig(oauthAccount) {
174
169
  let configObj = {};
170
+ const configPath = (0, claudeProfiles_1.getClaudeConfigPath)();
175
171
  try {
176
- configObj = JSON.parse(fs.readFileSync(getClaudeConfigPath(), 'utf8'));
172
+ configObj = JSON.parse(fs.readFileSync(configPath, 'utf8'));
177
173
  }
178
174
  catch {
179
175
  // File may not exist yet.
180
176
  }
181
177
  configObj.oauthAccount = oauthAccount;
182
- (0, atomic_1.atomicWriteJsonSync)(getClaudeConfigPath(), configObj);
178
+ (0, atomic_1.atomicWriteJsonSync)(configPath, configObj);
183
179
  }
184
- // ── Registry operations ──────────────────────────────────────────────────
180
+ // ── Registry operations (legacy v1 view over the shared v2 registry) ───────
185
181
  function readAccountRegistry() {
186
182
  const registry = (0, accountRegistry_1.readSavedAccountRegistry)();
187
183
  if (!registry)
@@ -203,11 +199,11 @@ function readAccountRegistry() {
203
199
  function writeAccountRegistry(registry) {
204
200
  (0, accountRegistry_1.replaceSavedAccountProfiles)('claude-code', mapClaudeProfiles(registry), registry.activeAccountUuid);
205
201
  }
206
- /** For callers already holding the registry lock (see withSavedAccountRegistryLock). */
207
- function writeAccountRegistryUnlocked(registry) {
208
- (0, accountRegistry_1.replaceSavedAccountProfilesUnlocked)('claude-code', mapClaudeProfiles(registry), registry.activeAccountUuid);
209
- }
210
202
  function mapClaudeProfiles(registry) {
203
+ const existing = new Map((0, accountRegistry_1.listSavedAccountProfiles)('claude-code').map((profile) => [
204
+ profile.providerAccountId ?? profile.id,
205
+ profile,
206
+ ]));
211
207
  return registry.accounts.map((account) => ({
212
208
  id: account.uuid,
213
209
  providerId: 'claude-code',
@@ -216,28 +212,26 @@ function mapClaudeProfiles(registry) {
216
212
  label: account.label,
217
213
  addedAt: account.addedAt,
218
214
  metadata: {
215
+ ...existing.get(account.uuid)?.metadata,
219
216
  email: account.email,
220
217
  },
221
218
  }));
222
219
  }
223
220
  // ── Read active Claude account ───────────────────────────────────────────
224
221
  function readActiveClaudeAccount() {
225
- try {
226
- const content = fs.readFileSync(getClaudeConfigPath(), 'utf8');
227
- const parsed = JSON.parse(content);
228
- const email = parsed?.oauthAccount?.emailAddress;
229
- const uuid = parsed?.oauthAccount?.accountUuid;
230
- if (!email || !uuid)
231
- return null;
232
- return { email, uuid };
233
- }
234
- catch {
235
- return null;
236
- }
222
+ return (0, claudeProfiles_1.readLiveClaudeIdentity)();
223
+ }
224
+ function findClaudeProfile(uuid) {
225
+ return ((0, accountRegistry_1.listSavedAccountProfiles)('claude-code').find((profile) => (profile.providerAccountId ?? profile.id) === uuid) ?? null);
226
+ }
227
+ function findClaudeProfileForIdentity(identity) {
228
+ const profiles = (0, accountRegistry_1.listSavedAccountProfiles)('claude-code');
229
+ return (profiles.find((p) => (p.providerAccountId ?? p.id) === identity.uuid) ??
230
+ profiles.find((p) => (p.email ?? p.metadata?.email) === identity.email) ??
231
+ null);
237
232
  }
238
233
  // ── Add current account ──────────────────────────────────────────────────
239
234
  function addCurrentAccount(label) {
240
- // 1. Read current Claude identity
241
235
  const active = readActiveClaudeAccount();
242
236
  if (!active) {
243
237
  return {
@@ -245,130 +239,277 @@ function addCurrentAccount(label) {
245
239
  error: 'No active Claude account found. Sign in with `claude` first.',
246
240
  };
247
241
  }
248
- // 2. Read current credentials (platform-aware: Keychain on macOS, file on Linux)
249
242
  const credBlob = (0, credentialIO_1.readActiveCredentials)();
250
243
  if (!credBlob) {
251
244
  return { success: false, error: 'Could not read Claude credentials.' };
252
245
  }
253
- // 3. Read oauthAccount section from .claude.json
254
- let configBlob;
255
- try {
256
- const raw = JSON.parse(fs.readFileSync(getClaudeConfigPath(), 'utf8'));
257
- configBlob = raw.oauthAccount;
258
- }
259
- catch {
246
+ const configBlob = readCurrentLiveOauthAccount();
247
+ if (!configBlob) {
260
248
  return { success: false, error: 'Could not read Claude config file.' };
261
249
  }
262
250
  ensureDirs();
263
- // 4–7 are one read-modify-write of the registry: hold the registry lock
264
- // across them so a concurrent add from another process is not dropped
265
- // when this write replaces the whole Claude profile set.
251
+ try {
252
+ writeClaudeProfileMirror(active.uuid, credBlob, configBlob, 'login');
253
+ }
254
+ catch (err) {
255
+ return { success: false, error: `Failed to back up Claude credentials: ${err}` };
256
+ }
257
+ // One read-modify-write of the registry under the lock so a concurrent add
258
+ // from another process is never dropped.
266
259
  return (0, accountRegistry_1.withSavedAccountRegistryLock)(() => {
267
- // 4. Load or create registry
268
- const registry = readAccountRegistry() ?? {
269
- version: 1,
270
- activeAccountUuid: null,
271
- accounts: [],
272
- };
273
- // 5. Check if account already exists — update credentials + label
274
- const existing = registry.accounts.find((a) => a.uuid === active.uuid);
275
- if (existing) {
276
- if (label !== undefined)
277
- existing.label = label;
278
- (0, atomic_1.atomicWriteJsonSync)(path.join(getCredentialsDir(), `${active.uuid}.credentials.json`), credBlob);
279
- (0, atomic_1.atomicWriteJsonSync)(path.join(getConfigsDir(), `${active.uuid}.config.json`), configBlob);
280
- try {
281
- writeClaudeProfileMirror(active.uuid, credBlob, configBlob);
282
- }
283
- catch {
284
- /* flat backups remain available */
285
- }
286
- registry.activeAccountUuid = active.uuid;
287
- writeAccountRegistryUnlocked(registry);
288
- return { success: true };
289
- }
290
- // 6. New account — back up credentials
291
- (0, atomic_1.atomicWriteJsonSync)(path.join(getCredentialsDir(), `${active.uuid}.credentials.json`), credBlob);
292
- (0, atomic_1.atomicWriteJsonSync)(path.join(getConfigsDir(), `${active.uuid}.config.json`), configBlob);
293
- try {
294
- writeClaudeProfileMirror(active.uuid, credBlob, configBlob);
295
- }
296
- catch {
297
- /* flat backups remain available */
298
- }
299
- // 7. Add entry
300
- registry.accounts.push({
301
- uuid: active.uuid,
260
+ const existing = findClaudeProfile(active.uuid);
261
+ const id = existing?.id ?? active.uuid;
262
+ (0, accountRegistry_1.upsertSavedAccountProfileUnlocked)({
263
+ id,
264
+ providerId: 'claude-code',
265
+ providerAccountId: active.uuid,
302
266
  email: active.email,
303
- label,
304
- addedAt: new Date().toISOString(),
267
+ label: label !== undefined ? label || undefined : existing?.label,
268
+ addedAt: existing?.addedAt ?? new Date().toISOString(),
269
+ metadata: {
270
+ ...existing?.metadata,
271
+ email: active.email,
272
+ origin: existing?.metadata?.origin ?? 'manual',
273
+ },
305
274
  });
306
- registry.activeAccountUuid = active.uuid;
307
- writeAccountRegistryUnlocked(registry);
275
+ (0, accountRegistry_1.setActiveSavedAccountUnlocked)('claude-code', id);
308
276
  return { success: true };
309
277
  });
310
278
  }
311
- // ── Switch to account ────────────────────────────────────────────────────
279
+ // ── Live-state sync ──────────────────────────────────────────────────────
312
280
  /**
313
281
  * Serializes live Claude credential swaps across processes so two concurrent
314
282
  * switchers cannot interleave their backup/write/rollback sequences. It cannot
315
283
  * exclude Claude Code's own writes to the live home — only our switchers. Lock
316
284
  * ordering: this lock first, the registry lock (inside setActiveSavedAccount)
317
- * inside it — never the reverse.
285
+ * inside it — never the reverse. Never spawn a child process while holding it.
318
286
  */
319
287
  function withClaudeAuthSwapLock(operation) {
320
288
  const lockDir = path.join((0, accountRegistry_1.getAccountsDir)(), 'claude');
321
289
  fs.mkdirSync(lockDir, { recursive: true, mode: 0o700 });
322
290
  return (0, atomic_1.withFileLockSync)(path.join(lockDir, 'auth-swap.lock'), operation);
323
291
  }
324
- function switchToAccount(uuid) {
292
+ /**
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`.
297
+ */
298
+ function syncClaudeLiveStateUnlocked(source = 'live-sync') {
299
+ const report = (0, accountSyncTypes_1.emptyProviderSyncReport)();
325
300
  try {
326
- // Keep the non-throwing result contract when the lock cannot be acquired.
327
- return withClaudeAuthSwapLock(() => switchToAccountUnlocked(uuid));
301
+ const identity = readActiveClaudeAccount();
302
+ if (!identity) {
303
+ report.skipped = 'logged-out';
304
+ return report;
305
+ }
306
+ let profile = findClaudeProfileForIdentity(identity);
307
+ if (!profile) {
308
+ profile = {
309
+ id: identity.uuid,
310
+ providerId: 'claude-code',
311
+ providerAccountId: identity.uuid,
312
+ email: identity.email,
313
+ label: identity.email,
314
+ addedAt: new Date().toISOString(),
315
+ metadata: { email: identity.email, origin: 'live-sync' },
316
+ };
317
+ ensureDirs();
318
+ (0, accountRegistry_1.upsertSavedAccountProfile)(profile);
319
+ report.registered = { id: profile.id, email: identity.email };
320
+ }
321
+ else if (profile.email !== identity.email && identity.email) {
322
+ profile = {
323
+ ...profile,
324
+ email: identity.email,
325
+ metadata: { ...profile.metadata, email: identity.email },
326
+ };
327
+ (0, accountRegistry_1.upsertSavedAccountProfile)(profile);
328
+ }
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}`);
350
+ }
351
+ }
352
+ }
353
+ else if (liveBlob === null) {
354
+ report.warnings.push('The live Claude credential store could not be read.');
355
+ }
356
+ const active = (0, accountRegistry_1.getActiveSavedAccount)('claude-code');
357
+ if (!active || active.id !== profile.id) {
358
+ (0, accountRegistry_1.setActiveSavedAccount)('claude-code', profile.id, { silent: true });
359
+ report.repointed = profile.id;
360
+ }
328
361
  }
329
362
  catch (err) {
330
- return { success: false, error: `Could not acquire the account-switch lock: ${err}` };
363
+ report.warnings.push(`Claude account sync failed: ${err}`);
331
364
  }
365
+ return report;
332
366
  }
333
- function switchToAccountUnlocked(uuid) {
334
- const registry = readAccountRegistry();
335
- if (!registry) {
336
- return { success: false, error: 'No account registry found. Save an account first.' };
367
+ /** {@link syncClaudeLiveStateUnlocked} under the swap lock. */
368
+ function syncClaudeLiveState(source = 'live-sync') {
369
+ try {
370
+ return withClaudeAuthSwapLock(() => syncClaudeLiveStateUnlocked(source));
337
371
  }
338
- const target = registry.accounts.find((a) => a.uuid === uuid);
339
- if (!target) {
340
- return { success: false, error: `Account ${uuid} not found in registry.` };
372
+ catch (err) {
373
+ return { warnings: [`Could not acquire the account-switch lock: ${err}`] };
341
374
  }
342
- // 1. Back up current credentials + config (for whichever Claude account is live).
343
- let originalCreds = null;
344
- let originalConfig = null;
345
- originalCreds = (0, credentialIO_1.readActiveCredentials)();
375
+ }
376
+ /**
377
+ * Remove isolated login homes that never authenticated (the user closed the
378
+ * browser, the login timed out) once they are older than `olderThanMs`.
379
+ * Returns the removed login ids.
380
+ */
381
+ function cleanupAbandonedClaudeLogins(olderThanMs = 6 * 60 * 60 * 1000, now = Date.now()) {
382
+ const removed = [];
383
+ let entries = [];
346
384
  try {
347
- originalConfig = fs.readFileSync(getClaudeConfigPath(), 'utf8');
385
+ entries = fs.readdirSync((0, claudeProfiles_1.getClaudeProfilesDir)());
348
386
  }
349
387
  catch {
350
- /* no existing config to back up */
388
+ return removed;
389
+ }
390
+ const registered = new Set((0, accountRegistry_1.listSavedAccountProfiles)('claude-code').map((p) => p.id));
391
+ for (const loginId of entries) {
392
+ if (registered.has(loginId))
393
+ continue;
394
+ const dir = (0, claudeProfiles_1.getClaudeProfileDir)(loginId);
395
+ const pendingPath = path.join(dir, 'profile.json');
396
+ const pending = readJsonOrNull(pendingPath);
397
+ if (!pending)
398
+ continue;
399
+ const home = (0, claudeProfiles_1.getClaudeProfileHome)(loginId);
400
+ if ((0, claudeProfiles_1.isClaudeProfileAuthenticated)(home))
401
+ continue;
402
+ const addedAt = pending.addedAt ? Date.parse(pending.addedAt) : NaN;
403
+ const age = Number.isNaN(addedAt) ? Number.POSITIVE_INFINITY : now - addedAt;
404
+ if (age < olderThanMs)
405
+ continue;
406
+ try {
407
+ (0, credentialIO_1.deleteStoredCredentials)(home);
408
+ fs.rmSync(dir, { recursive: true, force: true });
409
+ removed.push(loginId);
410
+ }
411
+ catch {
412
+ /* best effort */
413
+ }
351
414
  }
415
+ return removed;
416
+ }
417
+ // ── Switch to account ────────────────────────────────────────────────────
418
+ function switchToAccount(uuid, options = {}) {
419
+ const consumers = (0, processDetection_1.detectRunningAccountConsumersSync)('claude-code');
420
+ return runClaudeSwitch(uuid, options, consumers);
421
+ }
422
+ /** {@link switchToAccount} with the process probe off the event loop. */
423
+ async function switchToAccountAsync(uuid, options = {}) {
424
+ const consumers = await (0, processDetection_1.detectRunningAccountConsumers)('claude-code');
425
+ return runClaudeSwitch(uuid, options, consumers);
426
+ }
427
+ function runClaudeSwitch(uuid, options, consumers) {
352
428
  try {
353
- backupCurrentClaudeLiveHome();
429
+ // Keep the non-throwing result contract when the lock cannot be acquired.
430
+ return withClaudeAuthSwapLock(() => switchToAccountUnlocked(uuid, options, consumers));
354
431
  }
355
- catch {
356
- /* best effort */
432
+ catch (err) {
433
+ return (0, accountSwitch_1.switchFailure)('claude-code', uuid, `Could not acquire the account-switch lock: ${err}`);
434
+ }
435
+ }
436
+ function switchToAccountUnlocked(uuid, options, consumers) {
437
+ const target = findClaudeProfile(uuid);
438
+ if (!target) {
439
+ return (0, accountSwitch_1.switchFailure)('claude-code', uuid, `Account ${uuid} not found in registry.`);
440
+ }
441
+ const targetName = target.label ?? target.email ?? uuid;
442
+ // Refuse to treat one of our own profile homes as the live home: a shell
443
+ // 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');
453
+ const previousAccountId = (0, accountRegistry_1.getActiveSavedAccount)('claude-code')?.id ?? null;
454
+ const base = {
455
+ success: true,
456
+ provider: 'claude-code',
457
+ accountId: target.id,
458
+ previousAccountId,
459
+ verified: false,
460
+ verification: 'none',
461
+ warnings: [...sync.warnings],
462
+ hints: [],
463
+ runningConsumers: consumers,
464
+ email: target.email,
465
+ };
466
+ if (readActiveClaudeAccount()?.uuid === uuid) {
467
+ if (previousAccountId !== target.id)
468
+ (0, accountRegistry_1.setActiveSavedAccount)('claude-code', target.id);
469
+ return (0, accountSwitch_1.finishSwitchResult)({
470
+ ...base,
471
+ alreadyActive: true,
472
+ verified: true,
473
+ verification: 'store',
474
+ health: (0, accountHealth_1.getAccountHealth)('claude-code', target.id),
475
+ hints: [`${targetName} is already the active Claude account.`],
476
+ });
477
+ }
478
+ // Materialise pre-migration flat backups into the profile home first so the
479
+ // health probe (and the install) read one canonical store.
480
+ const stored = readStoredClaudeAccount(uuid);
481
+ if (!stored) {
482
+ return (0, accountSwitch_1.finishSwitchResult)({
483
+ ...base,
484
+ success: false,
485
+ needsLogin: true,
486
+ error: `Stored credentials for "${targetName}" not found; sign in again.`,
487
+ });
488
+ }
489
+ // Preflight: never install a credential we already know is dead.
490
+ const health = (0, accountHealth_1.getAccountHealth)('claude-code', target.id, { probe: 'store' });
491
+ base.health = health;
492
+ if ((health.state === 'expired' || health.state === 'missing') && !options.force) {
493
+ return (0, accountSwitch_1.finishSwitchResult)({
494
+ ...base,
495
+ success: false,
496
+ needsLogin: true,
497
+ error: `Stored credentials for "${targetName}" have ${health.state === 'missing' ? 'not been saved' : 'expired'}; sign in again.`,
498
+ });
499
+ }
500
+ if (health.state === 'expiring' && health.reason) {
501
+ base.warnings.push(`"${targetName}": ${health.reason}`);
357
502
  }
503
+ // Snapshot the live state for rollback.
504
+ const originalCreds = (0, credentialIO_1.readActiveCredentials)();
505
+ let originalConfig = null;
358
506
  try {
359
- (0, accountRegistry_1.setActiveSavedAccount)('claude-code', uuid);
507
+ originalConfig = fs.readFileSync((0, claudeProfiles_1.getClaudeConfigPath)(), 'utf8');
360
508
  }
361
- catch (err) {
362
- return { success: false, error: `Failed to update account registry: ${err}` };
509
+ catch {
510
+ /* no existing config to back up */
363
511
  }
364
- const applied = applyActiveClaudeToLiveHomeUnlocked();
365
- if (!applied.success) {
366
- try {
367
- (0, accountRegistry_1.setActiveSavedAccount)('claude-code', registry.activeAccountUuid);
368
- }
369
- catch {
370
- /* rollback failed */
371
- }
512
+ const rollback = () => {
372
513
  if (originalCreds) {
373
514
  try {
374
515
  (0, credentialIO_1.writeActiveCredentials)(originalCreds);
@@ -377,22 +518,104 @@ function switchToAccountUnlocked(uuid) {
377
518
  /* rollback failed */
378
519
  }
379
520
  }
380
- if (originalConfig) {
521
+ if (originalConfig !== null) {
381
522
  try {
382
- fs.writeFileSync(getClaudeConfigPath(), originalConfig);
523
+ fs.writeFileSync((0, claudeProfiles_1.getClaudeConfigPath)(), originalConfig);
383
524
  }
384
525
  catch {
385
526
  /* rollback failed */
386
527
  }
387
528
  }
388
- return applied;
529
+ try {
530
+ (0, accountRegistry_1.setActiveSavedAccount)('claude-code', previousAccountId, { silent: true });
531
+ }
532
+ catch {
533
+ /* rollback failed */
534
+ }
535
+ };
536
+ // Install.
537
+ try {
538
+ (0, credentialIO_1.writeActiveCredentials)(stored.credentials);
539
+ mergeOauthAccountIntoLiveConfig(stored.oauthAccount);
540
+ }
541
+ catch (err) {
542
+ rollback();
543
+ return (0, accountSwitch_1.finishSwitchResult)({
544
+ ...base,
545
+ success: false,
546
+ error: `Failed to write Claude credentials: ${err}`,
547
+ });
548
+ }
549
+ // 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;
557
+ 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
+ });
565
+ }
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.
568
+ try {
569
+ (0, accountRegistry_1.setActiveSavedAccount)('claude-code', target.id);
570
+ }
571
+ catch (err) {
572
+ rollback();
573
+ return (0, accountSwitch_1.finishSwitchResult)({
574
+ ...base,
575
+ success: false,
576
+ error: `Failed to update account registry: ${err}`,
577
+ });
578
+ }
579
+ let undoToken;
580
+ try {
581
+ undoToken = (0, accountSwitch_1.writeLastSwitch)('claude-code', previousAccountId, target.id).token;
582
+ }
583
+ catch {
584
+ base.warnings.push('The switch succeeded but could not be recorded for undo.');
585
+ }
586
+ return (0, accountSwitch_1.finishSwitchResult)({
587
+ ...base,
588
+ verified: true,
589
+ verification: 'store',
590
+ undoToken,
591
+ hints: [`New claude sessions use ${target.email ?? targetName}.`],
592
+ });
593
+ }
594
+ /**
595
+ * Install a saved profile's credentials into the live home without changing
596
+ * 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.
598
+ */
599
+ function applyClaudeProfileToLiveHome(uuid) {
600
+ try {
601
+ return withClaudeAuthSwapLock(() => {
602
+ const stored = readStoredClaudeAccount(uuid);
603
+ if (!stored) {
604
+ return { success: false, error: `Stored credentials for ${uuid} not found.` };
605
+ }
606
+ (0, credentialIO_1.writeActiveCredentials)(stored.credentials);
607
+ mergeOauthAccountIntoLiveConfig(stored.oauthAccount);
608
+ return { success: true };
609
+ });
610
+ }
611
+ catch (err) {
612
+ return { success: false, error: `Could not install the refreshed credentials: ${err}` };
389
613
  }
390
- return applied;
391
614
  }
392
615
  function resolveActiveClaudeHome() {
393
616
  const active = (0, accountRegistry_1.getActiveSavedAccount)('claude-code');
394
617
  if (!active)
395
- return getClaudeDir();
618
+ return (0, claudeProfiles_1.getLiveClaudeHome)();
396
619
  return (0, claudeProfiles_1.getClaudeProfileHome)(active.providerAccountId ?? active.id);
397
620
  }
398
621
  function applyActiveClaudeToLiveHome() {
@@ -417,7 +640,7 @@ function applyActiveClaudeToLiveHomeUnlocked() {
417
640
  const originalCreds = (0, credentialIO_1.readActiveCredentials)();
418
641
  let originalConfig = null;
419
642
  try {
420
- originalConfig = fs.readFileSync(getClaudeConfigPath(), 'utf8');
643
+ originalConfig = fs.readFileSync((0, claudeProfiles_1.getClaudeConfigPath)(), 'utf8');
421
644
  }
422
645
  catch {
423
646
  /* no existing config to back up */
@@ -450,7 +673,7 @@ function applyActiveClaudeToLiveHomeUnlocked() {
450
673
  }
451
674
  if (originalConfig) {
452
675
  try {
453
- fs.writeFileSync(getClaudeConfigPath(), originalConfig);
676
+ fs.writeFileSync((0, claudeProfiles_1.getClaudeConfigPath)(), originalConfig);
454
677
  }
455
678
  catch {
456
679
  /* rollback failed */
@@ -478,7 +701,7 @@ function reconcileClaudeAuthState() {
478
701
  const oauthAccount = readFlatClaudeOauthAccount(uuid);
479
702
  if (!credentials || !oauthAccount)
480
703
  continue;
481
- writeClaudeProfileMirror(uuid, credentials, oauthAccount);
704
+ writeClaudeProfileMirror(uuid, credentials, oauthAccount, 'migration');
482
705
  }
483
706
  catch {
484
707
  // Migration is best-effort per account.
@@ -497,35 +720,32 @@ function reconcileClaudeAuthState() {
497
720
  }
498
721
  // ── Remove account ───────────────────────────────────────────────────────
499
722
  function removeAccount(uuid) {
500
- // One read-modify-write under the registry lock (see addCurrentAccount).
723
+ // One read-modify-write of the registry under the lock (see addCurrentAccount).
501
724
  return (0, accountRegistry_1.withSavedAccountRegistryLock)(() => {
502
- const registry = readAccountRegistry();
503
- if (!registry) {
504
- return { success: false, error: 'No account registry found.' };
505
- }
506
- const idx = registry.accounts.findIndex((a) => a.uuid === uuid);
507
- if (idx === -1) {
725
+ const profile = findClaudeProfile(uuid);
726
+ if (!profile) {
508
727
  return { success: false, error: `Account ${uuid} not found.` };
509
728
  }
510
- // Remove backed-up files
511
- try {
512
- fs.unlinkSync(path.join(getCredentialsDir(), `${uuid}.credentials.json`));
513
- }
514
- catch {
515
- /* ok */
729
+ // Remove backed-up files and the profile home (credential store included).
730
+ for (const filePath of [
731
+ path.join(getCredentialsDir(), `${uuid}.credentials.json`),
732
+ path.join(getConfigsDir(), `${uuid}.config.json`),
733
+ ]) {
734
+ try {
735
+ fs.unlinkSync(filePath);
736
+ }
737
+ catch {
738
+ /* ok */
739
+ }
516
740
  }
517
741
  try {
518
- fs.unlinkSync(path.join(getConfigsDir(), `${uuid}.config.json`));
742
+ (0, credentialIO_1.deleteStoredCredentials)((0, claudeProfiles_1.getClaudeProfileHome)(uuid));
743
+ fs.rmSync((0, claudeProfiles_1.getClaudeProfileDir)(profile.id), { recursive: true, force: true });
519
744
  }
520
745
  catch {
521
746
  /* ok */
522
747
  }
523
- // Remove from registry
524
- registry.accounts.splice(idx, 1);
525
- if (registry.activeAccountUuid === uuid) {
526
- registry.activeAccountUuid = registry.accounts[0]?.uuid ?? null;
527
- }
528
- writeAccountRegistryUnlocked(registry);
748
+ (0, accountRegistry_1.removeSavedAccountProfileUnlocked)('claude-code', profile.id);
529
749
  return { success: true };
530
750
  });
531
751
  }
@@ -551,22 +771,21 @@ function getActiveAccount() {
551
771
  }
552
772
  /**
553
773
  * Resolves the *currently logged-in* Claude account for display, preferring the
554
- * live `~/.claude/.claude.json` identity over the saved registry pointer (which
555
- * only sidekick's own switch flow updates and therefore goes stale after a native
774
+ * live `.claude.json` identity over the saved registry pointer (which only
775
+ * sidekick's own switch flow updates and therefore goes stale after a native
556
776
  * `claude /login`).
557
777
  *
558
778
  * Safe self-heal: when the live account matches a saved profile (by account UUID,
559
779
  * email as fallback) that isn't the current active pointer, the pointer is
560
780
  * re-pointed so registry-keyed data tracks reality too. Never creates or deletes
561
- * profiles; an unknown live account is shown as-is with no label and no write.
781
+ * profiles; an unknown live account is shown as-is with no label and no write
782
+ * (the sync engine registers it on the next startup or watch event).
562
783
  */
563
784
  function resolveActiveClaudeAccount(options = {}) {
564
785
  const selfHeal = options.selfHeal ?? true;
565
786
  const live = readActiveClaudeAccount();
566
787
  if (live) {
567
- const profiles = (0, accountRegistry_1.listSavedAccountProfiles)('claude-code');
568
- const match = profiles.find((p) => (p.providerAccountId ?? p.id) === live.uuid) ??
569
- profiles.find((p) => (p.email ?? p.metadata?.email) === live.email);
788
+ const match = findClaudeProfileForIdentity(live);
570
789
  if (match) {
571
790
  const active = (0, accountRegistry_1.getActiveSavedAccount)('claude-code');
572
791
  // Hot paths (the status line runs on every prompt) pass selfHeal: false