sidekick-shared 0.27.6 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +58 -18
  3. package/dist/browser.d.ts +13 -2
  4. package/dist/browser.js +30 -2
  5. package/dist/claudePaths.d.ts +11 -0
  6. package/dist/claudePaths.js +61 -0
  7. package/dist/claudeProfiles.d.ts +2 -9
  8. package/dist/claudeProfiles.js +10 -25
  9. package/dist/codexPaths.d.ts +22 -0
  10. package/dist/codexPaths.js +67 -0
  11. package/dist/codexProfiles.d.ts +1 -4
  12. package/dist/codexProfiles.js +5 -43
  13. package/dist/doctor.js +2 -2
  14. package/dist/esm/accountChangeSignal.js +15 -0
  15. package/dist/esm/accountChanges.js +152 -0
  16. package/dist/esm/accountHealth.js +288 -0
  17. package/dist/esm/accountLaunch.js +145 -0
  18. package/dist/esm/accountLease.js +60 -0
  19. package/dist/esm/accountManager.js +606 -0
  20. package/dist/esm/accountRegistry.js +214 -0
  21. package/dist/esm/accountStatus.js +51 -0
  22. package/dist/esm/accountSwitch.js +74 -0
  23. package/dist/esm/accountSync.js +156 -0
  24. package/dist/esm/accountSyncTypes.js +4 -0
  25. package/dist/esm/accounts.js +1291 -0
  26. package/dist/esm/aggregation/EventAggregator.js +2034 -0
  27. package/dist/esm/aggregation/FrequencyTracker.js +69 -0
  28. package/dist/esm/aggregation/HeatmapTracker.js +89 -0
  29. package/dist/esm/aggregation/PatternExtractor.js +168 -0
  30. package/dist/esm/aggregation/costProvenance.js +31 -0
  31. package/dist/esm/aggregation/snapshot.js +161 -0
  32. package/dist/esm/aggregation/types.js +5 -0
  33. package/dist/esm/analytics/codeImpact.js +18 -0
  34. package/dist/esm/analytics/compactionLedger.js +37 -0
  35. package/dist/esm/analytics/qualityScore.js +112 -0
  36. package/dist/esm/autoSwitch.js +185 -0
  37. package/dist/esm/browser.js +34 -0
  38. package/dist/esm/claudeCredentialDoctor.js +239 -0
  39. package/dist/esm/claudeCredentialLedger.js +91 -0
  40. package/dist/esm/claudeCredentials.js +111 -0
  41. package/dist/esm/claudeLiveGuard.js +43 -0
  42. package/dist/esm/claudePaths.js +22 -0
  43. package/dist/esm/claudeProfiles.js +142 -0
  44. package/dist/esm/claudeRefreshLock.js +106 -0
  45. package/dist/esm/codexAuth.js +101 -0
  46. package/dist/esm/codexCredentialDoctor.js +265 -0
  47. package/dist/esm/codexCredentialStore.js +164 -0
  48. package/dist/esm/codexKeepAlive.js +112 -0
  49. package/dist/esm/codexPaths.js +172 -0
  50. package/dist/esm/codexProfiles.js +1251 -0
  51. package/dist/esm/codexQuota.js +564 -0
  52. package/dist/esm/codexQuotaWatcher.js +363 -0
  53. package/dist/esm/context/composer.js +71 -0
  54. package/dist/esm/context/sessionContext.js +546 -0
  55. package/dist/esm/credentialIO.js +283 -0
  56. package/dist/esm/credentials.js +49 -0
  57. package/dist/esm/doctor.js +305 -0
  58. package/dist/esm/ensureDefaultAccounts.js +62 -0
  59. package/dist/esm/errorHistory.js +56 -0
  60. package/dist/esm/extractors/errorTaxonomy.js +43 -0
  61. package/dist/esm/extractors/gatherAssets.js +32 -0
  62. package/dist/esm/extractors/sessionAssets.js +176 -0
  63. package/dist/esm/extractors/sources/claudeAssets.js +158 -0
  64. package/dist/esm/extractors/sources/codexAssets.js +234 -0
  65. package/dist/esm/extractors/tokenUsage.js +41 -0
  66. package/dist/esm/extractors/toolCall.js +142 -0
  67. package/dist/esm/failingTools.js +65 -0
  68. package/dist/esm/fileHead.js +27 -0
  69. package/dist/esm/formatters/eventHighlighter.js +332 -0
  70. package/dist/esm/formatters/noiseClassifier.js +209 -0
  71. package/dist/esm/formatters/sessionDump.js +328 -0
  72. package/dist/esm/formatters/toolSummary.js +226 -0
  73. package/dist/esm/formatting.js +82 -0
  74. package/dist/esm/handoffUrl.js +23 -0
  75. package/dist/esm/historicalStore.js +305 -0
  76. package/dist/esm/humanPrompt.js +88 -0
  77. package/dist/esm/index.js +213 -0
  78. package/dist/esm/keepAlive.js +189 -0
  79. package/dist/esm/liveCredentialGuard.js +33 -0
  80. package/dist/esm/modelAliases.js +51 -0
  81. package/dist/esm/modelCatalog.js +139 -0
  82. package/dist/esm/modelContext.js +280 -0
  83. package/dist/esm/modelInfo.js +832 -0
  84. package/dist/esm/multiProviderQuotaService.js +450 -0
  85. package/dist/esm/node.js +46 -0
  86. package/dist/esm/observedContextWindows.js +165 -0
  87. package/dist/esm/observedSessionCollector.js +939 -0
  88. package/dist/esm/parsers/changelogParser.js +71 -0
  89. package/dist/esm/parsers/codexHistory.js +89 -0
  90. package/dist/esm/parsers/codexParser.js +906 -0
  91. package/dist/esm/parsers/debugLogParser.js +126 -0
  92. package/dist/esm/parsers/jsonl.js +66 -0
  93. package/dist/esm/parsers/mcpToolName.js +28 -0
  94. package/dist/esm/parsers/openCodeParser.js +625 -0
  95. package/dist/esm/parsers/planExtractor.js +335 -0
  96. package/dist/esm/parsers/sessionActivityDetector.js +246 -0
  97. package/dist/esm/parsers/sessionPathResolver.js +713 -0
  98. package/dist/esm/parsers/subagentScanner.js +360 -0
  99. package/dist/esm/parsers/subagentStatsCache.js +45 -0
  100. package/dist/esm/parsers/subagentTraceParser.js +325 -0
  101. package/dist/esm/paths.js +134 -0
  102. package/dist/esm/peakHours.js +194 -0
  103. package/dist/esm/phrases.js +1330 -0
  104. package/dist/esm/pricingCatalog.js +247 -0
  105. package/dist/esm/processDetection.js +200 -0
  106. package/dist/esm/projectMigration.js +59 -0
  107. package/dist/esm/promptHistory.js +1221 -0
  108. package/dist/esm/providerFailure.js +362 -0
  109. package/dist/esm/providerIds.js +6 -0
  110. package/dist/esm/providerQuota.js +1 -0
  111. package/dist/esm/providerServiceStatus.js +145 -0
  112. package/dist/esm/providerServiceStatusTypes.js +1 -0
  113. package/dist/esm/providerStatus.js +117 -0
  114. package/dist/esm/providers/claudeCode.js +575 -0
  115. package/dist/esm/providers/claudeListing.js +121 -0
  116. package/dist/esm/providers/codex.js +1034 -0
  117. package/dist/esm/providers/codexDatabase.js +297 -0
  118. package/dist/esm/providers/codexRollouts.js +215 -0
  119. package/dist/esm/providers/detect.js +185 -0
  120. package/dist/esm/providers/diagnostics.js +83 -0
  121. package/dist/esm/providers/directoryListingCache.js +206 -0
  122. package/dist/esm/providers/factory.js +54 -0
  123. package/dist/esm/providers/openCode.js +2008 -0
  124. package/dist/esm/providers/openCodeDatabase.js +419 -0
  125. package/dist/esm/providers/rolloutWalker.js +252 -0
  126. package/dist/esm/providers/types.js +12 -0
  127. package/dist/esm/quota.js +182 -0
  128. package/dist/esm/quotaHistory.js +364 -0
  129. package/dist/esm/quotaPoller.js +230 -0
  130. package/dist/esm/quotaPresentation.js +97 -0
  131. package/dist/esm/quotaResolver.js +231 -0
  132. package/dist/esm/quotaSnapshots.js +130 -0
  133. package/dist/esm/quotaThresholds.js +85 -0
  134. package/dist/esm/readers/decisions.js +24 -0
  135. package/dist/esm/readers/handoff.js +20 -0
  136. package/dist/esm/readers/helpers.js +27 -0
  137. package/dist/esm/readers/history.js +9 -0
  138. package/dist/esm/readers/notes.js +48 -0
  139. package/dist/esm/readers/plans.js +225 -0
  140. package/dist/esm/readers/tasks.js +19 -0
  141. package/dist/esm/report/htmlHelpers.js +280 -0
  142. package/dist/esm/report/htmlReportGenerator.js +704 -0
  143. package/dist/esm/report/index.js +7 -0
  144. package/dist/esm/report/logo.js +2 -0
  145. package/dist/esm/report/openBrowser.js +19 -0
  146. package/dist/esm/report/sessionReportInputs.js +33 -0
  147. package/dist/esm/report/transcriptParser.js +83 -0
  148. package/dist/esm/report/types.js +4 -0
  149. package/dist/esm/schemas/accountManager.js +204 -0
  150. package/dist/esm/schemas/accountStatus.js +25 -0
  151. package/dist/esm/schemas/assistantTurn.js +74 -0
  152. package/dist/esm/schemas/index.js +25 -0
  153. package/dist/esm/schemas/observedSessionV1.js +77 -0
  154. package/dist/esm/schemas/providerFailure.js +37 -0
  155. package/dist/esm/schemas/quota.js +132 -0
  156. package/dist/esm/schemas/quotaHistory.js +40 -0
  157. package/dist/esm/schemas/sessionEvent.js +216 -0
  158. package/dist/esm/schemas/stateFile.js +75 -0
  159. package/dist/esm/schemas/tokenEstimation.js +7 -0
  160. package/dist/esm/schemas/transcript.js +86 -0
  161. package/dist/esm/schemas/usageNormalization.js +38 -0
  162. package/dist/esm/search/advancedFilter.js +197 -0
  163. package/dist/esm/search/sessionSearch.js +66 -0
  164. package/dist/esm/sessionFingerprint.js +40 -0
  165. package/dist/esm/sessionMonitor.js +198 -0
  166. package/dist/esm/sessionPreviews.js +522 -0
  167. package/dist/esm/sessionPromptHistory.js +254 -0
  168. package/dist/esm/sessionPromptSignals.js +364 -0
  169. package/dist/esm/sessionStats.js +160 -0
  170. package/dist/esm/sessionTokenTotals.js +98 -0
  171. package/dist/esm/sessionTranscripts.js +49 -0
  172. package/dist/esm/stateFile.js +119 -0
  173. package/dist/esm/statusline/BurnRateCalculator.js +40 -0
  174. package/dist/esm/statusline/claudeStatuslinePayload.js +148 -0
  175. package/dist/esm/statusline/formatter.js +122 -0
  176. package/dist/esm/statusline/index.js +8 -0
  177. package/dist/esm/terminalSync.js +95 -0
  178. package/dist/esm/testing/credentialGuard.js +197 -0
  179. package/dist/esm/testing/index.js +12 -0
  180. package/dist/esm/testing/sessionLogFixtures.js +128 -0
  181. package/dist/esm/tokenEstimation.js +67 -0
  182. package/dist/esm/tokenSummary.js +113 -0
  183. package/dist/esm/toolCategory.js +82 -0
  184. package/dist/esm/transcript.js +257 -0
  185. package/dist/esm/turns/assistantTurn.js +373 -0
  186. package/dist/esm/types/codex.js +23 -0
  187. package/dist/esm/types/decisionLog.js +5 -0
  188. package/dist/esm/types/historicalData.js +47 -0
  189. package/dist/esm/types/knowledgeNote.js +15 -0
  190. package/dist/esm/types/observedSessionV1.js +257 -0
  191. package/dist/esm/types/opencode.js +12 -0
  192. package/dist/esm/types/plan.js +7 -0
  193. package/dist/esm/types/sessionEvent.js +10 -0
  194. package/dist/esm/types/taskPersistence.js +12 -0
  195. package/dist/esm/usage/billingBlocks.js +148 -0
  196. package/dist/esm/usage/claudeUsageDedupe.js +189 -0
  197. package/dist/esm/usage/importSessionHistory.js +84 -0
  198. package/dist/esm/usage/subagentUsage.js +18 -0
  199. package/dist/esm/usage/usageEvents.js +402 -0
  200. package/dist/esm/usage/usageReports.js +202 -0
  201. package/dist/esm/usageNormalization.js +95 -0
  202. package/dist/esm/watchers/eventBridge.js +219 -0
  203. package/dist/esm/watchers/factory.js +63 -0
  204. package/dist/esm/watchers/jsonlTail.js +289 -0
  205. package/dist/esm/watchers/jsonlWatcher.js +435 -0
  206. package/dist/esm/watchers/providerReaderWatcher.js +118 -0
  207. package/dist/esm/watchers/sqliteWatcher.js +285 -0
  208. package/dist/esm/watchers/types.js +4 -0
  209. package/dist/esm/workspaceRoots.js +56 -0
  210. package/dist/esm/writers/atomic.js +375 -0
  211. package/dist/esm/writers/decisions.js +51 -0
  212. package/dist/esm/writers/notes.js +46 -0
  213. package/dist/esm/writers/tasks.js +63 -0
  214. package/dist/esm/zaiQuota.js +323 -0
  215. package/dist/esm/zaiQuotaApi.js +271 -0
  216. package/dist/esm/zaiQuotaWatcher.js +265 -0
  217. package/dist/fileHead.d.ts +22 -0
  218. package/dist/fileHead.js +33 -0
  219. package/dist/index.d.ts +16 -7
  220. package/dist/index.js +49 -15
  221. package/dist/node.d.ts +13 -1
  222. package/dist/node.js +43 -2
  223. package/dist/parsers/codexHistory.js +2 -2
  224. package/dist/parsers/codexParser.d.ts +12 -0
  225. package/dist/parsers/codexParser.js +34 -12
  226. package/dist/parsers/debugLogParser.js +2 -2
  227. package/dist/parsers/sessionPathResolver.js +5 -4
  228. package/dist/promptHistory.d.ts +32 -0
  229. package/dist/promptHistory.js +115 -59
  230. package/dist/providerFailure.d.ts +12 -6
  231. package/dist/providerFailure.js +45 -0
  232. package/dist/providers/claudeCode.js +7 -99
  233. package/dist/providers/claudeListing.d.ts +22 -0
  234. package/dist/providers/claudeListing.js +158 -0
  235. package/dist/providers/codex.d.ts +3 -1
  236. package/dist/providers/codex.js +26 -227
  237. package/dist/providers/codexRollouts.d.ts +44 -0
  238. package/dist/providers/codexRollouts.js +261 -0
  239. package/dist/providers/detect.js +4 -4
  240. package/dist/readers/plans.js +3 -3
  241. package/dist/schemas/index.d.ts +2 -0
  242. package/dist/schemas/index.js +7 -2
  243. package/dist/schemas/providerFailure.d.ts +154 -0
  244. package/dist/schemas/providerFailure.js +40 -0
  245. package/dist/sessionPromptHistory.d.ts +12 -7
  246. package/dist/sessionPromptHistory.js +15 -3
  247. package/dist/sessionPromptSignals.d.ts +9 -0
  248. package/dist/sessionPromptSignals.js +13 -0
  249. package/dist/testing/credentialGuard.d.ts +23 -0
  250. package/dist/testing/credentialGuard.js +233 -0
  251. package/dist/testing/index.d.ts +14 -0
  252. package/dist/testing/index.js +22 -0
  253. package/dist/testing/sessionLogFixtures.d.ts +28 -0
  254. package/dist/testing/sessionLogFixtures.js +169 -0
  255. package/dist/tokenEstimation.d.ts +2 -0
  256. package/dist/tokenEstimation.js +21 -11
  257. package/dist/toolCategory.d.ts +18 -0
  258. package/dist/toolCategory.js +86 -0
  259. package/dist/types/codex.d.ts +4 -0
  260. package/dist/usage/usageEvents.d.ts +16 -0
  261. package/dist/usage/usageEvents.js +53 -9
  262. package/dist/watchers/jsonlTail.d.ts +26 -1
  263. package/dist/watchers/jsonlTail.js +136 -28
  264. package/dist/watchers/jsonlWatcher.js +7 -0
  265. package/dist/watchers/providerReaderWatcher.js +1 -0
  266. package/dist/watchers/types.d.ts +6 -0
  267. package/dist/workspaceRoots.d.ts +23 -0
  268. package/dist/workspaceRoots.js +93 -0
  269. package/package.json +22 -2
@@ -0,0 +1,112 @@
1
+ import { summarizeTokens } from '../tokenSummary';
2
+ function awarded(maximum, ratio) {
3
+ return Math.round(maximum * Math.max(0, Math.min(1, ratio)) * 10) / 10;
4
+ }
5
+ export function scoreSessionQuality(metrics) {
6
+ const latencyMs = metrics.latencyStats?.avgFirstTokenLatencyMs ?? 0;
7
+ const latencyRatio = latencyMs > 0 ? 1 - Math.min(1, latencyMs / 15_000) : 1;
8
+ const completedCalls = metrics.toolStats.reduce((sum, tool) => sum + tool.completedCount, 0);
9
+ const errorRate = completedCalls > 0 ? metrics.errorRollup.totalFailures / completedCalls : 0;
10
+ const contextTokens = Math.max(1, summarizeTokens(metrics.tokens).context);
11
+ const evicted = metrics.compactionEvents.reduce((sum, event) => sum + event.tokensReclaimed, 0);
12
+ const contextRatio = 1 - Math.min(1, evicted / contextTokens);
13
+ const start = metrics.sessionStartTime ? Date.parse(metrics.sessionStartTime) : NaN;
14
+ const end = metrics.lastEventTime ? Date.parse(metrics.lastEventTime) : NaN;
15
+ let bypassMs = 0;
16
+ for (let index = 0; index < metrics.permissionModeHistory.length; index++) {
17
+ const change = metrics.permissionModeHistory[index];
18
+ if (change.mode !== 'bypassPermissions')
19
+ continue;
20
+ const from = Date.parse(change.timestamp);
21
+ const to = Date.parse(metrics.permissionModeHistory[index + 1]?.timestamp ?? metrics.lastEventTime ?? '');
22
+ if (Number.isFinite(from) && Number.isFinite(to))
23
+ bypassMs += Math.max(0, to - from);
24
+ }
25
+ const sessionMs = Number.isFinite(start) && Number.isFinite(end) ? Math.max(1, end - start) : 1;
26
+ const bypassRatio = Math.min(1, bypassMs / sessionMs);
27
+ const tasks = Array.from(metrics.taskState.tasks.values()).filter((task) => task.status !== 'deleted');
28
+ const completedTasks = tasks.filter((task) => task.status === 'completed').length;
29
+ const goalGates = tasks.filter((task) => task.isGoalGate);
30
+ const completedGates = goalGates.filter((task) => task.status === 'completed').length;
31
+ const taskRatio = tasks.length > 0 ? completedTasks / tasks.length : 1;
32
+ const goalRatio = goalGates.length > 0 ? completedGates / goalGates.length : taskRatio;
33
+ const outcomeRatio = taskRatio * 0.6 + goalRatio * 0.4;
34
+ const factors = [
35
+ {
36
+ id: 'latency',
37
+ label: 'Response latency',
38
+ contribution: awarded(15, latencyRatio),
39
+ maximum: 15,
40
+ detail: latencyMs > 0 ? `${Math.round(latencyMs)}ms average first token` : 'No latency penalty',
41
+ },
42
+ {
43
+ id: 'reliability',
44
+ label: 'Tool reliability',
45
+ contribution: awarded(30, 1 - Math.min(1, errorRate * 2)),
46
+ maximum: 30,
47
+ detail: `${metrics.errorRollup.totalFailures}/${completedCalls} completed tool calls failed`,
48
+ },
49
+ {
50
+ id: 'context',
51
+ label: 'Context stability',
52
+ contribution: awarded(15, contextRatio),
53
+ maximum: 15,
54
+ detail: `${metrics.compactionCount} compactions, ${evicted} tokens evicted`,
55
+ },
56
+ {
57
+ id: 'permission_safety',
58
+ label: 'Permission safety',
59
+ contribution: awarded(10, 1 - bypassRatio),
60
+ maximum: 10,
61
+ detail: `${Math.round(bypassRatio * 100)}% of observed time bypassing permissions`,
62
+ },
63
+ {
64
+ id: 'outcomes',
65
+ label: 'Task outcomes',
66
+ contribution: awarded(30, outcomeRatio),
67
+ maximum: 30,
68
+ detail: `${completedTasks}/${tasks.length} tasks and ${completedGates}/${goalGates.length} goal gates completed`,
69
+ },
70
+ ];
71
+ return {
72
+ score: Math.round(factors.reduce((sum, factor) => sum + factor.contribution, 0)),
73
+ beta: true,
74
+ factors,
75
+ };
76
+ }
77
+ export function calculateQualityTrend(records, now = new Date()) {
78
+ const end = now.getTime();
79
+ const week = 7 * 24 * 60 * 60 * 1000;
80
+ const eligible = records.filter((record) => record.qualityScore > 0 && Number.isFinite(Date.parse(record.endTime)));
81
+ const current = eligible.filter((record) => {
82
+ const at = Date.parse(record.endTime);
83
+ return at >= end - week && at <= end;
84
+ });
85
+ const previous = eligible.filter((record) => {
86
+ const at = Date.parse(record.endTime);
87
+ return at >= end - 2 * week && at < end - week;
88
+ });
89
+ const average = (items) => items.length > 0
90
+ ? Math.round((items.reduce((sum, item) => sum + item.qualityScore, 0) / items.length) * 10) /
91
+ 10
92
+ : null;
93
+ const currentWeekAverage = average(current);
94
+ const previousWeekAverage = average(previous);
95
+ const delta = currentWeekAverage != null && previousWeekAverage != null
96
+ ? Math.round((currentWeekAverage - previousWeekAverage) * 10) / 10
97
+ : null;
98
+ return {
99
+ currentWeekAverage,
100
+ previousWeekAverage,
101
+ delta,
102
+ direction: delta == null
103
+ ? 'insufficient'
104
+ : delta > 0.5
105
+ ? 'improving'
106
+ : delta < -0.5
107
+ ? 'declining'
108
+ : 'flat',
109
+ currentWeekSessions: current.length,
110
+ previousWeekSessions: previous.length,
111
+ };
112
+ }
@@ -0,0 +1,185 @@
1
+ import { getActiveAccount, listAccounts, } from './accounts';
2
+ import { switchAccountAsync as defaultSwitchAccount } from './accountManager';
3
+ import { getActiveCodexAccount, listCodexAccounts } from './codexProfiles';
4
+ import { readQuotaSnapshot } from './quotaSnapshots';
5
+ export const DEFAULT_AUTO_SWITCH_CONFIG = {
6
+ enabled: false,
7
+ thresholdPct: 90,
8
+ };
9
+ const MATERIAL_REMAINING_IMPROVEMENT_PCT = 5;
10
+ const MAX_STALE_SNAPSHOT_AGE_MS = 5 * 60 * 60 * 1000;
11
+ function mergedConfig(config) {
12
+ return {
13
+ ...DEFAULT_AUTO_SWITCH_CONFIG,
14
+ ...config,
15
+ };
16
+ }
17
+ function quotaUtilization(quota) {
18
+ if (!quota?.available)
19
+ return 0;
20
+ return Math.max(quota.fiveHour.utilization, quota.sevenDay.utilization);
21
+ }
22
+ function quotaRemaining(quota) {
23
+ if (!quota?.available)
24
+ return 0;
25
+ return Math.max(0, 100 - quotaUtilization(quota));
26
+ }
27
+ function isUsableCandidateQuota(quota, nowMs) {
28
+ if (!quota?.available)
29
+ return false;
30
+ if (quota.stale !== true)
31
+ return true;
32
+ const capturedAt = Date.parse(quota.capturedAt ?? '');
33
+ if (!Number.isFinite(capturedAt) || capturedAt > nowMs)
34
+ return false;
35
+ return nowMs - capturedAt <= MAX_STALE_SNAPSHOT_AGE_MS;
36
+ }
37
+ export function decideAutoSwitch(_provider, active, candidates, config, nowMs = Date.now()) {
38
+ const cfg = mergedConfig(config);
39
+ if (!cfg.enabled)
40
+ return null;
41
+ if (!active.quota?.available)
42
+ return null;
43
+ const activeUtilization = quotaUtilization(active.quota);
44
+ if (activeUtilization < cfg.thresholdPct)
45
+ return null;
46
+ const activeRemaining = quotaRemaining(active.quota);
47
+ const best = candidates
48
+ .filter((candidate) => candidate.accountId !== active.accountId &&
49
+ candidate.switchable !== false &&
50
+ isUsableCandidateQuota(candidate.quota, nowMs))
51
+ .sort((a, b) => quotaRemaining(b.quota) - quotaRemaining(a.quota))[0];
52
+ if (!best)
53
+ return null;
54
+ if (quotaRemaining(best.quota) < activeRemaining + MATERIAL_REMAINING_IMPROVEMENT_PCT)
55
+ return null;
56
+ return { switchTo: best.accountId };
57
+ }
58
+ function runtimeStateToProvider(provider) {
59
+ return provider === 'claude' ? 'claude-code' : 'codex';
60
+ }
61
+ function accountIdFor(provider, account) {
62
+ return provider === 'claude-code'
63
+ ? account.uuid
64
+ : account.id;
65
+ }
66
+ export class AutoSwitchController {
67
+ quotaService;
68
+ getClaudeAccounts;
69
+ getActiveClaudeAccount;
70
+ getCodexAccounts;
71
+ getActiveCodexAccount;
72
+ readSnapshot;
73
+ switchAccount;
74
+ onTransition;
75
+ log;
76
+ cooldownMs;
77
+ now;
78
+ config;
79
+ subscription = null;
80
+ switchedDuringCrossing = new Set();
81
+ lastSwitchAt = {};
82
+ switchInFlight = new Set();
83
+ constructor(options) {
84
+ this.quotaService = options.quotaService;
85
+ this.config = mergedConfig(options.config);
86
+ this.getClaudeAccounts = options.getClaudeAccounts ?? listAccounts;
87
+ this.getActiveClaudeAccount = options.getActiveClaudeAccount ?? getActiveAccount;
88
+ this.getCodexAccounts = options.getCodexAccounts ?? listCodexAccounts;
89
+ this.getActiveCodexAccount = options.getActiveCodexAccount ?? getActiveCodexAccount;
90
+ this.readSnapshot = options.readSnapshot ?? readQuotaSnapshot;
91
+ this.switchAccount = options.switchAccount ?? defaultSwitchAccount;
92
+ this.onTransition = options.onTransition;
93
+ this.log = options.log;
94
+ this.cooldownMs = options.cooldownMs ?? 0;
95
+ this.now = options.now ?? Date.now;
96
+ }
97
+ start() {
98
+ if (this.subscription)
99
+ return;
100
+ this.subscription = this.quotaService.onUpdate((state) => this.handleUpdate(state));
101
+ }
102
+ stop() {
103
+ this.subscription?.dispose();
104
+ this.subscription = null;
105
+ }
106
+ dispose() {
107
+ this.stop();
108
+ }
109
+ setConfig(config) {
110
+ this.config = mergedConfig(config);
111
+ if (!this.config.enabled) {
112
+ this.switchedDuringCrossing.clear();
113
+ }
114
+ }
115
+ handleUpdate(state) {
116
+ // Fire-and-forget, but the registry/snapshot reads and the consumer's
117
+ // onTransition can all throw — a rejection here would escape the quota
118
+ // service's per-listener guard as an unhandled promise rejection.
119
+ if (state.claude) {
120
+ this.handleProviderUpdate('claude', state.claude).catch((error) => this.log?.('[AutoSwitch] Update failed for claude.', error));
121
+ }
122
+ if (state.codex) {
123
+ this.handleProviderUpdate('codex', state.codex).catch((error) => this.log?.('[AutoSwitch] Update failed for codex.', error));
124
+ }
125
+ }
126
+ async handleProviderUpdate(runtimeProvider, activeQuota) {
127
+ const providerId = runtimeStateToProvider(runtimeProvider);
128
+ // While a switch is mid-flight the registry's active account is
129
+ // indeterminate, so skip the whole update until it settles.
130
+ if (this.switchInFlight.has(providerId))
131
+ return;
132
+ const activeAccount = providerId === 'claude-code' ? this.getActiveClaudeAccount() : this.getActiveCodexAccount();
133
+ if (!activeAccount)
134
+ return;
135
+ const activeAccountId = accountIdFor(providerId, activeAccount);
136
+ if (quotaUtilization(activeQuota) < this.config.thresholdPct) {
137
+ this.switchedDuringCrossing.delete(providerId);
138
+ }
139
+ const accounts = providerId === 'claude-code' ? this.getClaudeAccounts() : this.getCodexAccounts();
140
+ if (accounts.length <= 1)
141
+ return;
142
+ const candidates = accounts.map((account) => {
143
+ const accountId = accountIdFor(providerId, account);
144
+ return {
145
+ accountId,
146
+ quota: accountId === activeAccountId ? activeQuota : this.readSnapshot(providerId, accountId),
147
+ };
148
+ });
149
+ const decision = decideAutoSwitch(providerId, { accountId: activeAccountId, quota: activeQuota }, candidates, this.config, this.now());
150
+ if (!decision)
151
+ return;
152
+ if (this.switchedDuringCrossing.has(providerId))
153
+ return;
154
+ const previousSwitchAt = this.lastSwitchAt[providerId] ?? 0;
155
+ if (this.cooldownMs > 0 && this.now() - previousSwitchAt < this.cooldownMs)
156
+ return;
157
+ this.switchInFlight.add(providerId);
158
+ let result;
159
+ try {
160
+ result = await this.switchAccount(providerId, decision.switchTo);
161
+ }
162
+ catch (error) {
163
+ this.log?.(`[AutoSwitch] Switch threw for ${providerId} to ${decision.switchTo}.`, error);
164
+ return;
165
+ }
166
+ finally {
167
+ this.switchInFlight.delete(providerId);
168
+ }
169
+ if (!result.success) {
170
+ this.log?.(`[AutoSwitch] Could not switch ${providerId} to ${decision.switchTo}.`, result.error);
171
+ return;
172
+ }
173
+ this.lastSwitchAt[providerId] = this.now();
174
+ this.switchedDuringCrossing.add(providerId);
175
+ // A switch that completes after stop()/dispose() still records its
176
+ // state, but must not fire a transition into a stopped consumer.
177
+ if (!this.subscription)
178
+ return;
179
+ this.onTransition?.({
180
+ provider: providerId,
181
+ from: activeAccountId,
182
+ to: decision.switchTo,
183
+ });
184
+ }
185
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Browser / webview entry.
3
+ *
4
+ * Pure, synchronous, filesystem-free helpers. Safe to bundle for browser
5
+ * runtimes (VS Code webviews, web apps, etc.). Does NOT pull in node:fs,
6
+ * node:path, or any Node-only module.
7
+ *
8
+ * For pricing hydration (LiteLLM catalog refresh), use `sidekick-shared/node`.
9
+ */
10
+ export { getModelContextWindowSize, resolveModelContextWindow, DEFAULT_CONTEXT_WINDOW, } from './modelContext';
11
+ export { normalizeProviderUsage, extractNormalizedUsage, calculateNormalizedUsageCost, } from './usageNormalization';
12
+ export { formatTokenBreakdown, summarizeTokens, sumTokenTotals, TOKEN_CONTEXT_LABEL, TOKEN_MAIN_THREAD_LABEL, TOKEN_SESSION_TOTAL_LABEL, TOKEN_SUBAGENTS_LABEL, TOKEN_TOTAL_LABEL, } from './tokenSummary';
13
+ export { ClaudeUsageDeduper, SYNTHETIC_MODEL } from './usage/claudeUsageDedupe';
14
+ export { ASCII_CHARS_PER_TOKEN, estimateTextTokens, estimateSerializedTokens, } from './tokenEstimation';
15
+ export { projectSessionTranscript } from './transcript';
16
+ export { addLocalDays, formatDurationMs, formatLocalDateKey, formatTokenCount, parseLocalDateKey, } from './formatting';
17
+ export { parseMcpToolName } from './parsers/mcpToolName';
18
+ export { buildSessionContextSnapshot, calculateSessionContextPressure, createSessionContextProjector, } from './context/sessionContext';
19
+ export { ACCOUNT_PROVIDER_IDS, MODEL_PROVIDER_IDS, QUOTA_PROVIDER_IDS, RUNTIME_QUOTA_PROVIDER_IDS, SESSION_PROVIDER_IDS, } from './providerIds';
20
+ export { assistantTurnEventsFromSessionEvents, extractTurnSubagents, isAssistantTurnSubagentTool, reasoningSummary, segmentAssistantTurn, } from './turns/assistantTurn';
21
+ export { parseModelId, getModelPricing, resolveModelPricing, getModelInfo, calculateCost, calculateCostWithPricing, calculateCostWithProvenance, mergeCostSources, shortModelName, getModelDisplayInfo, compareModelIds, sortModelIds, formatCost, } from './modelInfo';
22
+ export { exportResolvedModelCatalog, importResolvedModelCatalog, registerModelAlias, resolveModelAlias, RESOLVED_MODEL_CATALOG_SCHEMA_VERSION, } from './modelCatalog';
23
+ export { BILLING_BLOCK_DURATION_MS, computeBillingBlocks, findActiveBillingBlock, } from './usage/billingBlocks';
24
+ export { bucketUsage, summarizeUsageRows, usageBucketKey, weekKey } from './usage/usageReports';
25
+ export { applySessionSummary, isFileImported, markFileImported, removeSessionSummary, sessionSummaryFromStats, } from './historicalStore';
26
+ export { FAILING_TOOL_TREND_ARROWS, failingToolTrend, mergeFailingToolWindows, } from './failingTools';
27
+ export { diagnoseProviderFailure, PROVIDER_AUTHENTICATION_SOURCES, PROVIDER_AUTHENTICATION_STATES, PROVIDER_CREDENTIAL_KINDS, PROVIDER_FAILURE_CODES, PROVIDER_FAILURE_EVIDENCE_SOURCES, PROVIDER_RECOVERY_ACTIONS, } from './providerFailure';
28
+ export { isAggregateCodexLimit } from './types/codex';
29
+ // Codex rollout parsing (pure: no filesystem access)
30
+ export { CodexRolloutParser, DEFAULT_MAX_TRACKED_TOOL_CALLS, extractPatchFilePaths, normalizeCodexToolName, normalizeCodexToolInput, resolveCodexCallUsage, } from './parsers/codexParser';
31
+ export { HUMAN_CLAUDE_ENTRYPOINTS, HUMAN_CODEX_SOURCES, humanPromptText, isHumanPrompt, } from './humanPrompt';
32
+ export { renderHandoffUrlTemplate } from './handoffUrl';
33
+ // Tool categories across providers
34
+ export { classifyTool, TOOL_CATEGORIES } from './toolCategory';
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Inspect and repair the Claude credential stores Sidekick owns.
3
+ *
4
+ * Ownership is proven, never guessed: a store counts only when its directory
5
+ * is in the ledger (claudeCredentialLedger.ts), is a profile home under
6
+ * Sidekick's accounts directory, or its Keychain item carries Sidekick's
7
+ * comment tag naming the directory. Other tools legitimately create
8
+ * `Claude Code-credentials-<hash>` items for their own `CLAUDE_CONFIG_DIR`, so
9
+ * nothing is ever matched by name pattern.
10
+ *
11
+ * Findings:
12
+ * - `orphan`: the store's directory is gone, or it belongs to no saved account
13
+ * and no login younger than a day. It holds a copy of some login nothing
14
+ * will use; repair deletes it.
15
+ * - `live-duplicate`: the live account's profile home holds a CLI-usable copy
16
+ * of its login (a second copy of the live chain); repair runs the sync that
17
+ * removes it (repairing live first when the copy is newer).
18
+ * - `corrupt`: the store does not parse (for example a credential truncated by
19
+ * an older build's Keychain write); repair deletes it, and the account needs
20
+ * a new sign-in unless its backup is still good.
21
+ * - `stale-entry`: a ledger entry whose store is already gone; repair forgets it.
22
+ */
23
+ import { execFileSync } from 'child_process';
24
+ import * as fs from 'fs';
25
+ import * as path from 'path';
26
+ import { listSavedAccountProfiles } from './accountRegistry';
27
+ import { claudeKeychainService, getClaudeProfileDir, getClaudeProfileHome, getClaudeProfilesDir, isLiveClaudeHomeSidekickProfile, readLiveClaudeIdentity, } from './claudeProfiles';
28
+ import { forgetClaudeCredentialHome, readClaudeCredentialLedger } from './claudeCredentialLedger';
29
+ import { deleteStoredCredentials, readClaudeCredentialStore, SIDEKICK_KEYCHAIN_COMMENT_PREFIX, } from './credentialIO';
30
+ import { syncClaudeLiveState } from './accounts';
31
+ /** Logins younger than this may still be finalized; the doctor leaves them alone. */
32
+ const PENDING_LOGIN_GRACE_MS = 24 * 60 * 60 * 1000;
33
+ function decodeKeychainValue(raw) {
34
+ const quoted = raw.match(/^"(.*)"$/);
35
+ if (quoted)
36
+ return quoted[1];
37
+ const hex = raw.match(/^0x([0-9A-Fa-f]+)/);
38
+ if (hex)
39
+ return Buffer.from(hex[1], 'hex').toString('utf8').replace(/\0+$/, '');
40
+ return raw;
41
+ }
42
+ /** Parse `security dump-keychain` output (attributes only) into item summaries. */
43
+ export function parseKeychainDump(output) {
44
+ const items = [];
45
+ for (const block of output.split(/^keychain: /m)) {
46
+ const service = block.match(/^\s*"svce"<blob>=(.+)$/m)?.[1];
47
+ if (!service)
48
+ continue;
49
+ const comment = block.match(/^\s*"icmt"<blob>=(.+)$/m)?.[1];
50
+ const decodedService = decodeKeychainValue(service.trim());
51
+ const decodedComment = comment && comment.trim() !== '<NULL>' ? decodeKeychainValue(comment.trim()) : undefined;
52
+ items.push({ service: decodedService, comment: decodedComment });
53
+ }
54
+ return items;
55
+ }
56
+ function defaultListKeychainItems() {
57
+ if (process.platform !== 'darwin')
58
+ return [];
59
+ const output = execFileSync('security', ['dump-keychain'], {
60
+ encoding: 'utf8',
61
+ stdio: ['ignore', 'pipe', 'ignore'],
62
+ timeout: 30_000,
63
+ killSignal: 'SIGKILL',
64
+ maxBuffer: 64 * 1024 * 1024,
65
+ });
66
+ return parseKeychainDump(String(output)).filter((item) => item.service.startsWith('Claude Code-credentials-'));
67
+ }
68
+ function collectOwnedHomes(keychainItems) {
69
+ const homes = new Map();
70
+ const add = (configDir, ownership, inLedger = false) => {
71
+ const resolved = path.resolve(configDir);
72
+ const existing = homes.get(resolved);
73
+ if (existing) {
74
+ existing.inLedger ||= inLedger;
75
+ return;
76
+ }
77
+ homes.set(resolved, {
78
+ configDir: resolved,
79
+ service: claudeKeychainService(resolved),
80
+ ownership,
81
+ inLedger,
82
+ });
83
+ };
84
+ for (const entry of readClaudeCredentialLedger())
85
+ add(entry.configDir, 'ledger', true);
86
+ try {
87
+ for (const id of fs.readdirSync(getClaudeProfilesDir())) {
88
+ add(getClaudeProfileHome(id), 'profile-home');
89
+ }
90
+ }
91
+ catch {
92
+ /* no profiles yet */
93
+ }
94
+ for (const item of keychainItems) {
95
+ if (!item.comment?.startsWith(SIDEKICK_KEYCHAIN_COMMENT_PREFIX))
96
+ continue;
97
+ const configDir = item.comment.slice(SIDEKICK_KEYCHAIN_COMMENT_PREFIX.length);
98
+ // The tag must name the directory the service was derived from.
99
+ if (configDir && claudeKeychainService(configDir) === item.service) {
100
+ add(configDir, 'keychain-tag');
101
+ }
102
+ }
103
+ return [...homes.values()];
104
+ }
105
+ function isRecentPendingLogin(configDir, now) {
106
+ const profilesDir = path.resolve(getClaudeProfilesDir());
107
+ const loginDir = path.dirname(configDir);
108
+ if (path.dirname(loginDir) !== profilesDir)
109
+ return false;
110
+ try {
111
+ const pending = JSON.parse(fs.readFileSync(path.join(getClaudeProfileDir(path.basename(loginDir)), 'profile.json'), 'utf8'));
112
+ const addedAt = pending.addedAt ? Date.parse(pending.addedAt) : NaN;
113
+ return !Number.isNaN(addedAt) && now - addedAt < PENDING_LOGIN_GRACE_MS;
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
119
+ export function inspectClaudeCredentialStores(options = {}) {
120
+ const now = options.now ?? Date.now();
121
+ let keychainItems = [];
122
+ let keychainScanned = false;
123
+ try {
124
+ keychainItems = (options.listKeychainItems ?? defaultListKeychainItems)();
125
+ keychainScanned = Boolean(options.listKeychainItems) || process.platform === 'darwin';
126
+ }
127
+ catch {
128
+ keychainScanned = false;
129
+ }
130
+ const profilesByHome = new Map();
131
+ for (const profile of listSavedAccountProfiles('claude-code')) {
132
+ profilesByHome.set(path.resolve(getClaudeProfileHome(profile.providerAccountId ?? profile.id)), profile);
133
+ }
134
+ // Inside a profile-home shell the "live" identity is that profile's own; its
135
+ // store is not a duplicate of anything.
136
+ const liveUuid = isLiveClaudeHomeSidekickProfile()
137
+ ? null
138
+ : (readLiveClaudeIdentity()?.uuid ?? null);
139
+ const findings = [];
140
+ const homes = collectOwnedHomes(keychainItems);
141
+ for (const home of homes) {
142
+ const store = readClaudeCredentialStore(home.configDir);
143
+ if (store.status === 'error')
144
+ continue;
145
+ const profile = profilesByHome.get(home.configDir);
146
+ const base = {
147
+ configDir: home.configDir,
148
+ service: home.service,
149
+ ownership: home.ownership,
150
+ accountId: profile?.id,
151
+ email: profile?.email ?? profile?.metadata?.email,
152
+ };
153
+ if (store.status === 'absent') {
154
+ if (home.inLedger && !profile && !isRecentPendingLogin(home.configDir, now)) {
155
+ findings.push({
156
+ ...base,
157
+ kind: 'stale-entry',
158
+ detail: 'Ledger entry for a store that no longer exists.',
159
+ action: 'Forget the entry.',
160
+ });
161
+ }
162
+ continue;
163
+ }
164
+ if (!profile) {
165
+ if (isRecentPendingLogin(home.configDir, now))
166
+ continue;
167
+ findings.push({
168
+ ...base,
169
+ kind: 'orphan',
170
+ detail: fs.existsSync(home.configDir)
171
+ ? 'Holds a login but belongs to no saved account.'
172
+ : 'Holds a login but its directory no longer exists.',
173
+ action: 'Delete the stored credential.',
174
+ });
175
+ continue;
176
+ }
177
+ if (liveUuid && (profile.providerAccountId ?? profile.id) === liveUuid) {
178
+ findings.push({
179
+ ...base,
180
+ kind: 'live-duplicate',
181
+ detail: "The live account's profile home holds a second usable copy of its login; whichever refreshes first invalidates the other.",
182
+ action: 'Remove the copy (installing it into live first if it is newer).',
183
+ });
184
+ continue;
185
+ }
186
+ if (store.status === 'corrupt') {
187
+ findings.push({
188
+ ...base,
189
+ kind: 'corrupt',
190
+ detail: 'The stored credential is not valid JSON (possibly truncated); it cannot be used.',
191
+ action: 'Delete the unusable store; sign in to this account again if its backup is expired.',
192
+ });
193
+ }
194
+ }
195
+ return { findings, homesChecked: homes.length, keychainScanned };
196
+ }
197
+ /** Inspect, then (unless `dryRun`) repair every finding. Dry run by default. */
198
+ export function repairClaudeCredentialStores(options = {}) {
199
+ const dryRun = options.dryRun ?? true;
200
+ const inspection = inspectClaudeCredentialStores(options);
201
+ const result = { ...inspection, dryRun, repaired: [] };
202
+ if (dryRun)
203
+ return result;
204
+ if (inspection.findings.some((finding) => finding.kind === 'live-duplicate')) {
205
+ result.sync = syncClaudeLiveState();
206
+ }
207
+ for (const finding of inspection.findings) {
208
+ try {
209
+ if (finding.kind === 'live-duplicate') {
210
+ const still = readClaudeCredentialStore(finding.configDir).status;
211
+ result.repaired.push({
212
+ finding,
213
+ ok: still === 'absent',
214
+ error: still === 'absent' ? undefined : result.sync?.warnings.join(' ') || 'still present',
215
+ });
216
+ continue;
217
+ }
218
+ if (finding.kind === 'stale-entry') {
219
+ forgetClaudeCredentialHome(finding.configDir);
220
+ result.repaired.push({ finding, ok: true });
221
+ continue;
222
+ }
223
+ const ok = deleteStoredCredentials(finding.configDir);
224
+ if (ok && finding.kind === 'orphan') {
225
+ // An abandoned login directory under our profiles dir goes with it.
226
+ const loginDir = path.dirname(finding.configDir);
227
+ if (path.dirname(loginDir) === path.resolve(getClaudeProfilesDir()) &&
228
+ fs.existsSync(loginDir)) {
229
+ fs.rmSync(loginDir, { recursive: true, force: true });
230
+ }
231
+ }
232
+ result.repaired.push({ finding, ok, error: ok ? undefined : 'could not delete' });
233
+ }
234
+ catch (err) {
235
+ result.repaired.push({ finding, ok: false, error: String(err) });
236
+ }
237
+ }
238
+ return result;
239
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Ledger of the Claude credential homes Sidekick created.
3
+ *
4
+ * Claude Code names a config directory's Keychain item after a hash of the
5
+ * directory, so once the directory is gone nothing ties the item back to it.
6
+ * Every home Sidekick writes a credential into (a saved profile) or points a
7
+ * `claude` login at is recorded here first, which is how `accounts doctor`
8
+ * proves an item is Sidekick's before it deletes anything. Items are never
9
+ * matched by name pattern: other tools legitimately use `CLAUDE_CONFIG_DIR`.
10
+ *
11
+ * Best effort by design: a ledger write failure must never fail the credential
12
+ * operation it describes. The lock is a leaf lock (callers may hold the swap
13
+ * or registry lock; nothing is acquired inside it).
14
+ */
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
17
+ import { getAccountsDir } from './accountRegistry';
18
+ import { claudeKeychainService } from './claudeProfiles';
19
+ import { updateJsonStoreAtomicSync } from './writers/atomic';
20
+ function emptyLedger() {
21
+ return { version: 1, entries: [] };
22
+ }
23
+ function normalizeLedger(raw) {
24
+ const entries = raw?.entries;
25
+ if (!Array.isArray(entries))
26
+ return emptyLedger();
27
+ return {
28
+ version: 1,
29
+ entries: entries.filter((entry) => !!entry &&
30
+ typeof entry === 'object' &&
31
+ typeof entry.configDir === 'string' &&
32
+ typeof entry.service === 'string'),
33
+ };
34
+ }
35
+ export function getClaudeCredentialLedgerPath() {
36
+ return path.join(getAccountsDir(), 'claude', 'credential-homes.json');
37
+ }
38
+ export function readClaudeCredentialLedger() {
39
+ try {
40
+ return normalizeLedger(JSON.parse(fs.readFileSync(getClaudeCredentialLedgerPath(), 'utf8')))
41
+ .entries;
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ }
47
+ /** Record (or refresh) a home Sidekick is about to hold a Claude credential in. */
48
+ export function recordClaudeCredentialHome(configDir, purpose, now = new Date()) {
49
+ const resolved = path.resolve(configDir);
50
+ const stamp = now.toISOString();
51
+ try {
52
+ updateJsonStoreAtomicSync(getClaudeCredentialLedgerPath(), emptyLedger, (latest) => {
53
+ const ledger = normalizeLedger(latest);
54
+ const existing = ledger.entries.find((entry) => entry.configDir === resolved);
55
+ if (existing) {
56
+ existing.lastWrittenAt = stamp;
57
+ // A login home that became a profile keeps the stronger purpose.
58
+ if (purpose === 'profile')
59
+ existing.purpose = 'profile';
60
+ return ledger;
61
+ }
62
+ ledger.entries.push({
63
+ configDir: resolved,
64
+ service: claudeKeychainService(resolved),
65
+ purpose,
66
+ createdAt: stamp,
67
+ lastWrittenAt: stamp,
68
+ });
69
+ return ledger;
70
+ });
71
+ }
72
+ catch {
73
+ /* best effort: the doctor still scans the profile homes on disk */
74
+ }
75
+ }
76
+ /** Drop a home whose credential store has been deleted. */
77
+ export function forgetClaudeCredentialHome(configDir) {
78
+ const resolved = path.resolve(configDir);
79
+ try {
80
+ if (!fs.existsSync(getClaudeCredentialLedgerPath()))
81
+ return;
82
+ updateJsonStoreAtomicSync(getClaudeCredentialLedgerPath(), emptyLedger, (latest) => {
83
+ const ledger = normalizeLedger(latest);
84
+ ledger.entries = ledger.entries.filter((entry) => entry.configDir !== resolved);
85
+ return ledger;
86
+ });
87
+ }
88
+ catch {
89
+ /* best effort */
90
+ }
91
+ }