sidekick-shared 0.25.0 → 0.26.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 (116) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +49 -3
  3. package/dist/accountChanges.js +22 -3
  4. package/dist/accountRegistry.d.ts +22 -1
  5. package/dist/accountRegistry.js +37 -14
  6. package/dist/accountStatus.d.ts +9 -1
  7. package/dist/accountStatus.js +3 -3
  8. package/dist/accounts.d.ts +3 -1
  9. package/dist/accounts.js +84 -64
  10. package/dist/aggregation/EventAggregator.d.ts +15 -2
  11. package/dist/aggregation/EventAggregator.js +54 -9
  12. package/dist/aggregation/costProvenance.d.ts +14 -0
  13. package/dist/aggregation/costProvenance.js +35 -0
  14. package/dist/aggregation/snapshot.d.ts +13 -2
  15. package/dist/aggregation/snapshot.js +57 -6
  16. package/dist/aggregation/types.d.ts +30 -1
  17. package/dist/analytics/compactionLedger.js +15 -5
  18. package/dist/analytics/qualityScore.js +2 -1
  19. package/dist/browser.d.ts +11 -1
  20. package/dist/browser.js +29 -1
  21. package/dist/codexProfiles.d.ts +3 -1
  22. package/dist/codexProfiles.js +4 -2
  23. package/dist/codexQuota.d.ts +2 -0
  24. package/dist/codexQuota.js +11 -66
  25. package/dist/failingTools.d.ts +37 -0
  26. package/dist/failingTools.js +70 -0
  27. package/dist/formatters/sessionDump.js +13 -7
  28. package/dist/formatting.d.ts +13 -0
  29. package/dist/formatting.js +35 -0
  30. package/dist/historicalStore.d.ts +47 -0
  31. package/dist/historicalStore.js +312 -0
  32. package/dist/index.d.ts +46 -12
  33. package/dist/index.js +112 -24
  34. package/dist/node.d.ts +19 -0
  35. package/dist/node.js +47 -1
  36. package/dist/observedContextWindows.d.ts +7 -0
  37. package/dist/observedContextWindows.js +17 -2
  38. package/dist/observedSessionCollector.d.ts +3 -8
  39. package/dist/observedSessionCollector.js +10 -27
  40. package/dist/parsers/sessionActivityDetector.d.ts +24 -2
  41. package/dist/parsers/sessionActivityDetector.js +95 -17
  42. package/dist/parsers/sessionPathResolver.d.ts +11 -0
  43. package/dist/parsers/sessionPathResolver.js +13 -10
  44. package/dist/peakHours.d.ts +34 -3
  45. package/dist/peakHours.js +92 -7
  46. package/dist/pricingCatalog.d.ts +7 -0
  47. package/dist/pricingCatalog.js +8 -5
  48. package/dist/providers/claudeCode.js +11 -96
  49. package/dist/providers/codex.d.ts +2 -0
  50. package/dist/providers/codex.js +94 -259
  51. package/dist/providers/detect.d.ts +2 -0
  52. package/dist/providers/detect.js +29 -24
  53. package/dist/providers/factory.d.ts +6 -0
  54. package/dist/providers/openCode.d.ts +2 -0
  55. package/dist/providers/openCode.js +105 -121
  56. package/dist/providers/rolloutWalker.d.ts +53 -0
  57. package/dist/providers/rolloutWalker.js +209 -0
  58. package/dist/providers/types.d.ts +30 -0
  59. package/dist/quota.d.ts +30 -3
  60. package/dist/quota.js +35 -3
  61. package/dist/quotaHistory.d.ts +1 -1
  62. package/dist/quotaHistory.js +36 -35
  63. package/dist/quotaResolver.d.ts +81 -0
  64. package/dist/quotaResolver.js +213 -0
  65. package/dist/quotaSnapshots.d.ts +15 -2
  66. package/dist/quotaSnapshots.js +25 -3
  67. package/dist/quotaThresholds.d.ts +60 -0
  68. package/dist/quotaThresholds.js +90 -0
  69. package/dist/report/htmlReportGenerator.js +10 -4
  70. package/dist/report/sessionReportInputs.d.ts +23 -0
  71. package/dist/report/sessionReportInputs.js +34 -0
  72. package/dist/schemas/index.d.ts +2 -0
  73. package/dist/schemas/index.js +10 -1
  74. package/dist/schemas/quota.d.ts +139 -0
  75. package/dist/schemas/quota.js +17 -2
  76. package/dist/schemas/quotaHistory.d.ts +1 -0
  77. package/dist/schemas/quotaHistory.js +1 -1
  78. package/dist/schemas/sessionEvent.d.ts +6 -0
  79. package/dist/schemas/sessionEvent.js +1 -0
  80. package/dist/schemas/stateFile.d.ts +198 -0
  81. package/dist/schemas/stateFile.js +77 -0
  82. package/dist/schemas/transcript.js +2 -0
  83. package/dist/sessionFingerprint.d.ts +25 -0
  84. package/dist/sessionFingerprint.js +46 -0
  85. package/dist/sessionPreviews.d.ts +7 -1
  86. package/dist/sessionPreviews.js +15 -3
  87. package/dist/sessionStats.d.ts +51 -0
  88. package/dist/sessionStats.js +166 -0
  89. package/dist/stateFile.d.ts +113 -0
  90. package/dist/stateFile.js +160 -0
  91. package/dist/statusline/claudeStatuslinePayload.d.ts +89 -0
  92. package/dist/statusline/claudeStatuslinePayload.js +152 -0
  93. package/dist/statusline/formatter.d.ts +13 -0
  94. package/dist/statusline/formatter.js +48 -6
  95. package/dist/statusline/index.d.ts +10 -2
  96. package/dist/statusline/index.js +19 -1
  97. package/dist/tokenSummary.d.ts +62 -0
  98. package/dist/tokenSummary.js +72 -0
  99. package/dist/transcript.d.ts +5 -0
  100. package/dist/transcript.js +2 -0
  101. package/dist/types/observedSessionV1.d.ts +2 -0
  102. package/dist/types/observedSessionV1.js +75 -18
  103. package/dist/types/sessionEvent.d.ts +2 -0
  104. package/dist/usage/billingBlocks.d.ts +85 -0
  105. package/dist/usage/billingBlocks.js +153 -0
  106. package/dist/usage/importSessionHistory.d.ts +53 -0
  107. package/dist/usage/importSessionHistory.js +88 -0
  108. package/dist/usage/usageEvents.d.ts +92 -0
  109. package/dist/usage/usageEvents.js +322 -0
  110. package/dist/usage/usageReports.d.ts +76 -0
  111. package/dist/usage/usageReports.js +208 -0
  112. package/dist/watchers/factory.d.ts +6 -0
  113. package/dist/watchers/factory.js +30 -25
  114. package/dist/zaiQuotaApi.d.ts +2 -0
  115. package/dist/zaiQuotaApi.js +2 -0
  116. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,63 @@ All notable changes to sidekick-shared will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.26.0] - 2026-09-05
9
+
10
+ ### Added
11
+
12
+ - `summarizeTokens()`, `sumTokenTotals()`, `TOKEN_TOTAL_LABEL`, and `TOKEN_CONTEXT_LABEL` define the one token vocabulary every Sidekick surface uses: `total` is input + output + cache writes + cache reads, `context` is input + cache; browser-safe
13
+ - `AggregatedTokens` gains `costUsd`, `costProvenance` (`reported` | `estimated` | `mixed` | `unpriced` | `none`), `reportedCostUsd`, `estimatedCostUsd`, and `unpricedCalls`; `describeCostProvenance()` and `classifyCostProvenance()` are exported; `CanonicalSessionTranscript.usage` adds `pricedCostUsd` and `unpricedEvents` (schema updated)
14
+ - `parseClaudeStatuslinePayload()` and `quotaFromStatuslinePayload()` (also on the `sidekick-shared/statusline` subpath, which now exports `writeQuotaSnapshot`, `appendQuotaHistorySample`, and `getWorkspaceIdFromPath`) read the JSON Claude Code pipes to a status-line command; `QuotaState.source` and quota-history samples accept `statusline`
15
+ - `formatStatusline({ live })` renders context %, session cost, prompt-cache hit rate, the seven-day window, and the snapshot age when it is older than five minutes
16
+ - `readQuotaSnapshot()` reports `ageMs` and `freshness`; `classifyQuotaFreshness()`, `formatQuotaAge()`, `QUOTA_FRESH_MAX_AGE_MS`, and `QUOTA_AGING_MAX_AGE_MS` are exported and the quota schema accepts the new fields
17
+ - `getScheduledPeakHoursState()`, `PEAK_HOURS_DESCRIPTION`, `PEAK_HOURS_CACHE_MS`, and `PeakHoursState.source` (`promoclock` | `schedule`); `fetchPeakHoursStatus({ force })` bypasses the ten-minute cache
18
+ - `evaluateQuotaThresholds()`, `describeQuotaThresholdAlert()`, and `DEFAULT_QUOTA_THRESHOLDS` for once-per-reset-window threshold alerts
19
+ - `formatLocalDateKey()`, `parseLocalDateKey()`, and `addLocalDays()` (root and browser entry points); `hydratePricingCatalog({ offline })`; `pruneSnapshots()` and `MAX_SNAPSHOT_FILES`; `getActiveAccountStatus(error, { selfHeal })`, mirrored on `resolveActiveClaudeAccount()` and `resolveActiveCodexAccount()`; `_resetProviderDetectionCache()` and `_resetPeakHoursCache()` for tests
20
+ - `resolveQuota({ providerId, workspacePath, preferFresh, allowApi, … })` resolves Claude, Codex, or z.ai quota with one precedence — a persisted sample younger than `QUOTA_FRESH_MAX_AGE_MS` (any origin), then session logs, then the provider API (persisted on success), then an aging or stale sample with the API failure attached — returning `ResolvedQuota` with `resolution`, `source`, `capturedSource`, `freshness`, and `ageMs`; every side effect is injectable
21
+ - `QuotaState.capturedSource` (`api` | `session` | `statusline`) records where a cached sample came from; `readQuotaSnapshot()` fills it and `writeQuotaSnapshot()` preserves it instead of storing `cache`; the quota schema accepts it; `QuotaSnapshotRecord`, `enrichCodexQuota()`, and `enrichZaiQuota()` are exported; `fetchQuota(token, { fetchImpl })` accepts an injected fetch
22
+ - `computeSessionFileStats(events, options)` and `readSessionFileStats(provider, sessionPath, { resolveLabel })` compute `SessionFileStats` for every provider over the shared `EventAggregator`; `firstUserPrompt(events)` derives a session label from events already in memory. `SessionFileStats` gains `availability` (`full` | `partial` | `unavailable`), `unavailableReason`, `costUsd`, `costProvenance`, `unpricedCalls`, and `toolFailures`; `reportedCost` is deprecated (same value as `costUsd`)
23
+ - `collectUsageEvents({ providers, since, until, workspacePath })` reads every session touched in a window once through its provider reader and returns priced usage events, caching raw token counts per session under `<configDir>/usage-cache/` keyed by size and mtime (priced at load, so a catalog refresh never leaves stale costs); `pruneUsageCache()`, `getUsageCacheDir()`, `USAGE_CACHE_VERSION`, and `MAX_USAGE_CACHE_FILES` are exported
24
+ - `computeBillingBlocks(events, { now })` and `findActiveBillingBlock()` (root and browser entry points) group usage events into ccusage-style five-hour blocks aligned to the UTC hour, with cache-inclusive totals, cost provenance, per-model usage, burn rate, remaining time, and end-of-block projections; `BILLING_BLOCK_DURATION_MS` is exported
25
+ - `bucketUsage(events, { granularity, utc, weekStartsOn, groupBy })`, `summarizeUsageRows()`, `usageBucketKey()`, and `weekKey()` (root and browser entry points) bucket usage events by event time into day, week, month, or session rows with provider, project, and model dimensions, cache-inclusive totals, and cost provenance
26
+ - `applySessionSummary(store, summary)`, `removeSessionSummary()`, `markFileImported()`, `isFileImported()`, and `sessionSummaryFromStats()` (root and browser entry points) are the pure `historical-data.json` mutations ported from the VS Code extension, so the CLI and the extension credit sessions identically; `importSessionHistory({ providers, since, isImported, applySummary, markImported })` reads every finished session once through `readSessionFileStats()` and hands summaries to the caller's store, skipping files modified within `ACTIVE_SESSION_MTIME_THRESHOLD_MS`
27
+ - `SessionFileStats.modelUsage` entries gain `costUsd` and `priced`
28
+ - `classifySessionActivity({ events, mtimeMs })` classifies a session from events already in memory with the same states, reasons, grace period, and staleness rule as `detectSessionActivity()`; `refreshSessionActivityState()` accepts the previous reason so a session that was active only by grace period ends once the grace period lapses; `getObservedActivityReason()` exposes the reason behind an observed session's activity; `SESSION_ACTIVITY_GRACE_PERIOD_MS` and `SESSION_ACTIVITY_STALENESS_MS` are exported
29
+ - `readSessionReportInputs(provider, sessionPath)` reads a session once and returns its events, aggregator metrics, and report transcript; `resolveSessionPath(provider, workspacePath, sessionId?)` is the session lookup `createWatcher()` uses (exact id, unique prefix, or most recent); `sessionFingerprintParts()` and `fingerprintString()` are exported alongside `fileFingerprintParts()`; `SessionMessage.stop_reason` is preserved by the event schema
30
+ - `walkRolloutFiles(roots, { maxDepth, maxFiles, limit, sessionId })` and `walkRolloutFilesAsync()` are the one capped walker for Codex rollout files: newest-dated directories first, each file stat'ed once, results newest first with size and session id, an early exit for `limit`, and defaults of six levels and 20 000 files; `findSessionFilesWithStats()` returns a Claude Code session directory's files with the stat the listing already paid for
31
+ - `writeStateFile()`, `readStateFile()`, `getStateFilePath()`, `quotaToStateFile()`, `billingBlockToStateFile()`, `STATE_FILE_SCHEMA_VERSION`, and the `SidekickStateFile` types (root, node, and `statusline` entry points) maintain the public, versioned `<configDir>/state.json` for external tools — account, quota windows with freshness, context usage, session cost, active billing block — writing atomically only when the content changed; `sidekickStateFileSchema` and its sub-schemas are exported from `sidekick-shared/schemas`
32
+ - `SessionPreviewListResult.hasMore` reports whether `listSessionPreviewsAsync()` left candidates beyond `limit`, so callers can offer a larger limit only when one would show more
33
+ - `mergeFailingToolWindows()`, `failingToolTrend()`, and `FAILING_TOOL_TREND_ARROWS` (root and `browser` entry points) merge the 7-day and 30-day `getTopFailingTools()` windows into per-tool rows with a trend, shared by `sidekick stats` and the VS Code Health tab
34
+
35
+ ### Changed
36
+
37
+ - The HTML report and session dump label cost by provenance and render unpriced sessions as `—` with the unpriced call count; their "Total" row is cache-inclusive and labelled "Total (incl. cache)"
38
+ - `calculateCompactionLedger()` prices re-establishment through `calculateNormalizedUsageCost()` instead of the deprecated additive-reasoning path
39
+ - Codex `readSessionStats()` computes context as uncached + cached input, matching `CodexProvider.computeContextSize()`
40
+ - OpenCode `workspaceMatches` only matches sessions started in the workspace or a subdirectory of it
41
+ - `readQuotaHistoryDailyBuckets()` keys buckets by local calendar day
42
+ - `detectProvider()` and `getAllDetectedProviders()` share one implementation memoised for five seconds
43
+ - `loadObservedContextWindows()` ignores entries older than `OBSERVED_CONTEXT_WINDOW_TTL_MS` (30 days)
44
+ - `saveSnapshot()` writes through the fsynced atomic writer and prunes the directory to the newest 200 files every 25 saves; quota-history append and prune run under a per-file cross-process lock with fsync; the pricing catalog cache is written atomically
45
+ - `DEFAULT_PEAK_HOURS_TIMEOUT_MS` is 4 000 ms; `fetchPeakHoursStatus()` falls back to the schedule rather than returning `unavailable: true`
46
+ - `listSessionPreviews({ since })` throws `RangeError` on an unparseable cutoff
47
+ - `AggregatedTokens.reportedCost` is deprecated (same value as `costUsd`); `SNAPSHOT_SCHEMA_VERSION` is 5
48
+ - Claude Code, Codex, and OpenCode `readSessionStats()` share one implementation: per-model `tokens` are cache-inclusive (`summarizeTokens().total`) for every provider, Claude sessions report catalog-estimated cost and a tool success/failure split instead of `reportedCost: 0`, OpenCode compaction and truncation counts come from the aggregator instead of hardcoded zeros, a missing or unreadable source reports `availability: 'unavailable'` with a reason instead of silent zeros, and the label is taken from the events already read (Codex and OpenCode still prefer their database title) so the file is never opened twice
49
+ - OpenCode's file-backed reader implements `seekTo()` by message count (its `getPosition()` unit), so a reader restored from a snapshot emits only newer messages instead of replaying the whole session; the database-backed reader's `exists()` checks the session row (memoised for 30 s, treating a transient query failure as present) instead of always returning true
50
+ - `createProviderSessionAdapterV1().read()` reads a session once: stats, transcript, activity, and the pending request all come from one flushed reader pass instead of three separate file reads, and activity is classified from the events, which also fixes Codex sessions (whose raw lines never matched the Claude-only tail patterns) and database-backed OpenCode sessions (which always read as ended). `watch()` checks the size/mtime fingerprint before re-reading and refreshes only the activity in place while the content is unchanged, so a 2 s poll on an idle session costs a `stat`
51
+ - `ObservedSessionCollector` cache refreshes demote a session that was active only by grace period to ended after five seconds, matching a fresh classification
52
+ - The Codex provider's six recursive walkers (session discovery, listing, id lookup, project folders, subagent scan) and the quota resolver's separate copy all use the capped walker; `getAllProjectFolders()` reads `session_meta` only for rollouts newer than the SQLite index's mtime when the index answered, and every discovery path uses the per-path cwd cache (now bounded to 5 000 entries) instead of re-reading the first line of every rollout; `scanSubagents()` stops after the 50 newest-dated rollouts instead of enumerating the whole history; the local Codex quota scan walks at most `maxSessionFiles` rollouts and no longer stats every path a second time to sort it; `ClaudeCodeProvider.listAllSessionFiles()` no longer stats every session twice
53
+
54
+ ### Fixed
55
+
56
+ - `onAccountsChanged()` applies a later subscriber's faster `pollIntervalMs` and resets the interval after the last unsubscribe
57
+ - `OpenCodeProvider.dispose()` resets `dbStatus`; `CodexProvider.listSessionFilesAsync()` records its filesystem fallback for `getLastOperationStatus()`
58
+ - Observed-session cache hits could report a session as active for up to five minutes when a fresh parse would have said ended (the 0.25.0 grace-period drift note)
59
+ - `addCurrentAccount()` and `removeAccount()` hold the registry lock across their whole read-modify-write (new `withSavedAccountRegistryLock()` and `replaceSavedAccountProfilesUnlocked()`), so a concurrent add from another process is no longer dropped when the Claude profile set is replaced
60
+ - `resolveActiveClaudeAccount()`'s self-heal of the active pointer no longer fires an accounts-changed notification from a read path (`setActiveSavedAccount(..., { silent: true })`)
61
+ - `readSessionPreview()` / `readSessionPreviewAsync()` honour `maxPrefixBytes: 0` as "skip the prefix scan" (`firstTimestamp` and `workspacePath` come back null, the file is not opened) instead of flooring the budget to one byte
62
+ - `createSessionProviders()` documents that `diagnostics` is live (updated in place as providers run) and should be copied for a snapshot
63
+ - The packaging contract tests create their scratch projects under the OS temp directory instead of inside the package, so an interrupted run cannot leave a recursive `node_modules/sidekick-shared` link behind
64
+
8
65
  ## [0.25.0] - 2026-08-18
9
66
 
10
67
  ### Added
package/README.md CHANGED
@@ -9,6 +9,7 @@ Types, parsers, providers, readers, formatters, aggregation, search, reporting,
9
9
 
10
10
  ## Recent additions
11
11
 
12
+ - **One token vocabulary and cost provenance (0.26.0)** — `summarizeTokens()` gives every surface the same `total` (input + output + cache writes + cache reads) and `context` (input + cache) figures; `AggregatedTokens.costUsd` carries `costProvenance` so reports can say whether a cost was provider-reported or estimated; `parseClaudeStatuslinePayload()` / `quotaFromStatuslinePayload()` turn Claude Code's status-line JSON into an official `statusline` quota source; `readQuotaSnapshot()` reports `ageMs` / `freshness` and keeps the sample's origin as `capturedSource`; `resolveQuota()` is the one quota resolution path (fresh persisted sample → session logs → provider API → older sample) shared by the CLI, the MCP server, and both dashboards; `readSessionFileStats()` / `computeSessionFileStats()` give every provider one `readSessionStats()` over the shared aggregator, with `availability`, `costProvenance`, and `toolFailures` on `SessionFileStats`; `collectUsageEvents()` reads priced usage events from session logs with a per-session cache, `computeBillingBlocks()` groups them into five-hour billing blocks, and `bucketUsage()` turns them into day, week, month, or session rows; `applySessionSummary()` and `importSessionHistory()` let any host fold finished sessions into `historical-data.json` the way the extension does; observed sessions are read once per discovery, re-read only when their fingerprint changes, and classified from events by `classifySessionActivity()`; `readSessionReportInputs()` derives report metrics and transcript from one read; `writeStateFile()` / `readStateFile()` (also on the `statusline` subpath) maintain the public, versioned `state.json` for external tools, validated by `sidekickStateFileSchema`; `getScheduledPeakHoursState()` and `evaluateQuotaThresholds()` are shared by the CLI and extension dashboards.
12
13
  - **Host-safe APIs (0.25.0)** — `listSessionPreviewsAsync()` / `readSessionPreviewAsync()` read previews with bounded concurrency and cooperative yielding; `ObservedSessionCollector.subscribe()` and `SessionMonitor.subscribe()` replace host polling loops with debounced change batches; `createSessionProviders({ onDiagnostic })` constructs every usable provider with structured diagnostics instead of throwing; `findSessionById()` resolves one session without scanning; `exportResolvedModelCatalog()` / `importResolvedModelCatalog()` and `registerModelAlias()` transfer context/pricing resolutions across realms; `onAccountsChanged()` reports login/logout/switch events; provider constructors perform no environment I/O, and quota pollers stay dormant without an account.
13
14
  - **Native session-file helpers** — `encodeClaudeWorkspacePath()` / `getClaudeSessionDirectory()` locate Claude Code's `~/.claude/projects/` directories with the real on-disk encoding, `findCodexRolloutFile()` resolves a Codex session id to its rollout path, `readCodexHistory()` tail-reads `~/.codex/history.jsonl`, `listSessionPreviews()` / `readSessionPreview()` build a cheap stat-first recent-sessions index across providers, and `parseMcpToolName()` splits `mcp__<server>__<tool>` identifiers (browser-safe).
14
15
  - **Non-blocking account APIs & locked sync writers** — every login/switch entry point gains an async variant (`switchAccountAsync()`, `getAccountLoginStatusAsync()`, `finalizeAccountLoginAsync()`, `prepareCodexAccountAsync()`, `finalizeCodexAccountAsync()`, `switchToCodexAccountAsync()`) that keeps `codex` CLI probes off the event loop, and synchronous callers get the same cross-process store lock via `atomicWriteFileSync()` / `updateJsonStoreAtomicSync()` / `withFileLockSync()`, with progress-based timeouts and abandoned-lock reclaim.
@@ -104,7 +105,7 @@ returns the platform default that both overrides bypass.
104
105
  | **Quota** | Claude Max subscription quota fetching (5-hour and 7-day windows), Codex rate-limit extraction from event streams (plus Codex reset credits via `fetchCodexResetCreditsFromApi()`, attached to quota state by `fetchCodexQuotaFromApi()`), and authoritative z.ai Coding Plan quota fetching via `resolveZaiQuota()` / `fetchZaiQuotaFromApi()` (reads z.ai's `api/monitor/usage/quota/limit` endpoint with cached-snapshot fallback). The observed-traffic z.ai estimator was removed from the package root exports in 0.23.0 and every remaining export of it is `@deprecated` — `resolveZaiQuota()` is the single supported z.ai quota API |
105
106
  | **Provider Status** | API health checking via status.claude.com and status.openai.com (indicator, components, incidents) |
106
107
  | **Doctor** | Typed cross-provider health diagnostics — `runDoctor()` checks project identity, sessions, accounts, providers, and dependencies into a `HealthReport`; `formatHealthReport()` renders it and `getSessionDiagnostics()` exposes the per-provider session probe |
107
- | **Schemas** | Zod schemas for runtime validation of data crossing process/IPC boundaries — JSONL session events (`sessionEventSchema`, `messageUsageSchema`, `sessionMessageSchema`), assistant turns, observed-session V1 records, quota, account status, account management, and quota history — plus `extractSessionEvents()` to unwrap `progress`-wrapped events. Account and quota APIs guarantee results valid against the same-release schemas. Also published fs-free via the [`sidekick-shared/schemas`](#supported-import-paths) subpath |
108
+ | **Schemas** | Zod schemas for runtime validation of data crossing process/IPC boundaries — JSONL session events (`sessionEventSchema`, `messageUsageSchema`, `sessionMessageSchema`), assistant turns, observed-session V1 records, quota, account status, account management, quota history, and the public `state.json` contract (`sidekickStateFileSchema`) — plus `extractSessionEvents()` to unwrap `progress`-wrapped events. Account and quota APIs guarantee results valid against the same-release schemas. Also published fs-free via the [`sidekick-shared/schemas`](#supported-import-paths) subpath |
108
109
  | **Extractors** | Pure functions for single-event processing: `extractTokenUsage()`, `extractToolCall()` (top-level `tool_use`), `extractToolCalls()` (assistant content blocks), plus Node-only session asset extraction (`gatherAssetsForCwd()`, `readClaudeAssets()`, `readCodexAssets()`) for URLs, file paths, commands, and plans |
109
110
  | **Model Info & Pricing** | Model family parsing (Anthropic / OpenAI / Google, including legacy `claude-3-opus-…` and `claude-3-5-sonnet-…` IDs), context-window lookup (including Fable 5 / Opus 4.8 / Opus 4.7 / Sonnet 4.7 1M and GPT-5.x variants), pricing tables with optional LiteLLM hydration, normalized non-double-counting cost (`calculateNormalizedUsageCost()`, fed by `normalizeProviderUsage()` / `extractNormalizedUsage()`), legacy cost helpers retained but deprecated (`calculateCost()`, `calculateCostWithProvenance()`), `mergeCostSources()`, display helpers (`shortModelName()`, `getModelDisplayInfo()`, `compareModelIds()`, `sortModelIds()`, `formatCost()`), and cross-realm catalog transfer — `exportResolvedModelCatalog()` / `importResolvedModelCatalog()` move resolved context windows and pricing (with provenance, prefix inheritance explicit) between Node and browser realms, and `registerModelAlias()` resolves short or host-specific names to canonical ids |
110
111
  | **Quota Polling** | `QuotaPoller` class with exponential backoff, active/idle intervals, and cached fallback. Stays dormant while no account exists and wakes through the account-change signal |
@@ -130,7 +131,7 @@ returns the platform default that both overrides bypass.
130
131
  | `sidekick-shared/modelContext` | Any runtime | Direct access to the context-window module. |
131
132
  | `sidekick-shared/modelInfo` | Any runtime | Direct access to model parsing and cost math. |
132
133
  | `sidekick-shared/formatting` | Any runtime | Direct access to pure token and duration display helpers. |
133
- | `sidekick-shared/schemas` | Any runtime | Pure Zod boundary schemas (session events, assistant turns, observed-session V1, quota, account status, account management, quota history) — fs-free, no Node builtins. |
134
+ | `sidekick-shared/schemas` | Any runtime | Pure Zod boundary schemas (session events, assistant turns, observed-session V1, quota, account status, account management, quota history, the `state.json` contract) — fs-free, no Node builtins. |
134
135
  | `sidekick-shared/statusline` | Node only | Cache-only statusline surface: account status + quota snapshot readers, `formatStatusline()`, and burn-rate estimates. |
135
136
 
136
137
  ### Browser / webview runtimes
@@ -238,6 +239,32 @@ const estimate = estimateTextTokens(sourceCode, {
238
239
 
239
240
  `sidekick-fallback-v1` counts Latin/source-code text at 3.5 characters per token, CJK code points at one token, emoji at two, and other non-ASCII code points at one. Empty input is zero. An injected finite, nonnegative exact result takes precedence.
240
241
 
242
+ ### Read session stats for any provider
243
+
244
+ ```typescript
245
+ import { createSessionProviders, readSessionFileStats } from 'sidekick-shared';
246
+
247
+ const { providers } = createSessionProviders();
248
+ for (const provider of providers) {
249
+ for (const sessionPath of provider.findAllSessions(process.cwd())) {
250
+ const stats = provider.readSessionStats(sessionPath); // same as readSessionFileStats(provider, sessionPath)
251
+ if (stats.availability === 'unavailable') {
252
+ console.warn(`${provider.id}: ${stats.unavailableReason}`);
253
+ continue;
254
+ }
255
+ console.log(
256
+ stats.label,
257
+ stats.modelUsage,
258
+ stats.costUsd,
259
+ stats.costProvenance,
260
+ stats.toolFailures,
261
+ );
262
+ }
263
+ }
264
+ ```
265
+
266
+ Every provider computes `SessionFileStats` the same way — one reader pass through the shared `EventAggregator` — so per-model `tokens` are cache-inclusive, `costUsd` carries `costProvenance`, `toolFailures` splits out failed calls, and compaction and truncation counts are real for Claude Code, Codex, and OpenCode alike. `computeSessionFileStats(events, options)` does the same for events you already hold, and `firstUserPrompt(events)` derives the label.
267
+
241
268
  ### Read canonical session history
242
269
 
243
270
  ```typescript
@@ -308,7 +335,7 @@ Hosts with a UI loop should prefer the async variants from `sidekick-shared/node
308
335
  ```typescript
309
336
  import { listSessionPreviewsAsync, readSessionPreviewAsync } from 'sidekick-shared/node';
310
337
 
311
- const { previews, diagnostics } = await listSessionPreviewsAsync(providers, { limit: 10 });
338
+ const { previews, diagnostics, hasMore } = await listSessionPreviewsAsync(providers, { limit: 10 });
312
339
  const { preview } = await readSessionPreviewAsync(provider, filePath);
313
340
  ```
314
341
 
@@ -448,6 +475,25 @@ if (!peak.unavailable && peak.isPeak) {
448
475
  }
449
476
  ```
450
477
 
478
+ ### Resolve quota with one precedence
479
+
480
+ ```typescript
481
+ import { resolveQuota } from 'sidekick-shared';
482
+
483
+ // Fresh persisted sample (status line, session, or API; under five minutes) →
484
+ // session logs (Codex rollouts) → provider API → older sample labelled by age.
485
+ const quota = await resolveQuota({ providerId: 'claude-code' });
486
+ console.log(quota.resolution); // 'snapshot-fresh' | 'session' | 'api' | 'snapshot-aging' | 'snapshot-stale' | 'unavailable'
487
+ console.log(quota.source, quota.capturedSource, quota.freshness, quota.ageMs);
488
+
489
+ // `--refresh` semantics: ask the API first.
490
+ await resolveQuota({ providerId: 'codex', workspacePath: process.cwd(), preferFresh: false });
491
+ // Paint from local samples only (a poller will fetch live data next).
492
+ await resolveQuota({ providerId: 'claude-code', allowApi: false });
493
+ ```
494
+
495
+ `resolveQuota()` is what `sidekick quota`, `quota --all`, the MCP `get_quota_status` tool, and both dashboards call, so every surface reports the same numbers and the same `resolution`. Every side effect is injectable (`readSnapshot`, `writeSnapshot`, `fetchImpl`, `getClaudeAccessToken`, the account resolvers) for tests.
496
+
451
497
  ### Fetch subscription quota
452
498
 
453
499
  ```typescript
@@ -48,7 +48,8 @@ let pollTimer;
48
48
  let stopRawListener;
49
49
  let lastFingerprint;
50
50
  let debounceTimer;
51
- let activePollIntervalMs = 30_000;
51
+ const DEFAULT_POLL_INTERVAL_MS = 30_000;
52
+ let activePollIntervalMs = DEFAULT_POLL_INTERVAL_MS;
52
53
  /**
53
54
  * Observe login, logout, and active-account switches. Local Sidekick mutations
54
55
  * signal immediately; filesystem watchers cover external CLIs; a low-frequency
@@ -56,8 +57,17 @@ let activePollIntervalMs = 30_000;
56
57
  */
57
58
  function onAccountsChanged(listener, options = {}) {
58
59
  listeners.add(listener);
59
- activePollIntervalMs = Math.max(1_000, options.pollIntervalMs ?? activePollIntervalMs);
60
- ensureMonitoring();
60
+ // The fastest interval any live subscriber asked for wins; a later, faster
61
+ // subscriber restarts the timer rather than being silently ignored.
62
+ const requestedPollMs = Math.max(1_000, options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
63
+ if (!stopRawListener) {
64
+ activePollIntervalMs = requestedPollMs;
65
+ ensureMonitoring();
66
+ }
67
+ else if (requestedPollMs < activePollIntervalMs) {
68
+ activePollIntervalMs = requestedPollMs;
69
+ restartPollTimer();
70
+ }
61
71
  if (options.emitCurrent)
62
72
  emitIfChanged('poll', true, listener);
63
73
  return {
@@ -93,9 +103,17 @@ function ensureMonitoring() {
93
103
  // Missing/unwatchable directories are covered by the catch-up poll.
94
104
  }
95
105
  }
106
+ startPollTimer();
107
+ }
108
+ function startPollTimer() {
96
109
  pollTimer = setInterval(() => emitIfChanged('poll'), activePollIntervalMs);
97
110
  pollTimer.unref?.();
98
111
  }
112
+ function restartPollTimer() {
113
+ if (pollTimer)
114
+ clearInterval(pollTimer);
115
+ startPollTimer();
116
+ }
99
117
  function stopMonitoring() {
100
118
  stopRawListener?.();
101
119
  stopRawListener = undefined;
@@ -109,6 +127,7 @@ function stopMonitoring() {
109
127
  clearTimeout(debounceTimer);
110
128
  debounceTimer = undefined;
111
129
  lastFingerprint = undefined;
130
+ activePollIntervalMs = DEFAULT_POLL_INTERVAL_MS;
112
131
  }
113
132
  function watchTargets() {
114
133
  return Array.from(new Set([(0, accountRegistry_1.getAccountsDir)(), path.join(os.homedir(), '.claude'), (0, codexProfiles_1.getSystemCodexHome)()]));
@@ -41,11 +41,32 @@ export interface ResolvedActiveAccount {
41
41
  source: 'live' | 'registry' | 'none';
42
42
  }
43
43
  export declare function getAccountsDir(): string;
44
+ interface RegistryWriteOptions {
45
+ /**
46
+ * Skip the local change notification. Only for writes that change no
47
+ * observable account state, such as re-pointing the active pointer at the
48
+ * profile that already matches the live login (self-heal): a read path
49
+ * must not wake every subscriber.
50
+ */
51
+ silent?: boolean;
52
+ }
53
+ /**
54
+ * Run `operation` while holding the registry lock. For callers that need a
55
+ * read-modify-write across several steps (the legacy Claude facade's
56
+ * add/remove); inside it use the `*Unlocked` writers, never the locking ones:
57
+ * the lock is not re-entrant.
58
+ */
59
+ export declare function withSavedAccountRegistryLock<T>(operation: () => T): T;
44
60
  export declare function readSavedAccountRegistry(): SavedAccountRegistry | null;
45
61
  export declare function writeSavedAccountRegistry(registry: SavedAccountRegistry): void;
46
62
  export declare function listSavedAccountProfiles(providerId?: AccountProviderId): SavedAccountProfile[];
47
63
  export declare function getActiveSavedAccount(providerId: AccountProviderId): SavedAccountProfile | null;
48
64
  export declare function upsertSavedAccountProfile(profile: SavedAccountProfile): SavedAccountRegistry;
49
- export declare function setActiveSavedAccount(providerId: AccountProviderId, accountId: string | null): SavedAccountRegistry;
65
+ export declare function setActiveSavedAccount(providerId: AccountProviderId, accountId: string | null, options?: RegistryWriteOptions): SavedAccountRegistry;
50
66
  export declare function replaceSavedAccountProfiles(providerId: AccountProviderId, accounts: SavedAccountProfile[], activeId: string | null): SavedAccountRegistry;
67
+ /**
68
+ * Same as {@link replaceSavedAccountProfiles} without taking the registry
69
+ * lock: for callers already inside {@link withSavedAccountRegistryLock}.
70
+ */
71
+ export declare function replaceSavedAccountProfilesUnlocked(providerId: AccountProviderId, accounts: SavedAccountProfile[], activeId: string | null): SavedAccountRegistry;
51
72
  export declare function removeSavedAccountProfile(providerId: AccountProviderId, accountId: string): SavedAccountProfile | null;
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.ACCOUNT_PROVIDER_IDS = void 0;
37
37
  exports.getAccountsDir = getAccountsDir;
38
+ exports.withSavedAccountRegistryLock = withSavedAccountRegistryLock;
38
39
  exports.readSavedAccountRegistry = readSavedAccountRegistry;
39
40
  exports.writeSavedAccountRegistry = writeSavedAccountRegistry;
40
41
  exports.listSavedAccountProfiles = listSavedAccountProfiles;
@@ -42,6 +43,7 @@ exports.getActiveSavedAccount = getActiveSavedAccount;
42
43
  exports.upsertSavedAccountProfile = upsertSavedAccountProfile;
43
44
  exports.setActiveSavedAccount = setActiveSavedAccount;
44
45
  exports.replaceSavedAccountProfiles = replaceSavedAccountProfiles;
46
+ exports.replaceSavedAccountProfilesUnlocked = replaceSavedAccountProfilesUnlocked;
45
47
  exports.removeSavedAccountProfile = removeSavedAccountProfile;
46
48
  const fs = __importStar(require("fs"));
47
49
  const path = __importStar(require("path"));
@@ -62,9 +64,20 @@ function getRegistryLockPath() {
62
64
  function ensureAccountsDir() {
63
65
  fs.mkdirSync(getAccountsDir(), { recursive: true, mode: 0o700 });
64
66
  }
65
- function writeRegistryUnlocked(registry) {
67
+ function writeRegistryUnlocked(registry, options = {}) {
66
68
  (0, atomic_1.atomicWriteJsonSync)(getRegistryPath(), normalizeRegistry(registry));
67
- (0, accountChangeSignal_1._emitAccountsChanged)('local');
69
+ if (!options.silent)
70
+ (0, accountChangeSignal_1._emitAccountsChanged)('local');
71
+ }
72
+ /**
73
+ * Run `operation` while holding the registry lock. For callers that need a
74
+ * read-modify-write across several steps (the legacy Claude facade's
75
+ * add/remove); inside it use the `*Unlocked` writers, never the locking ones:
76
+ * the lock is not re-entrant.
77
+ */
78
+ function withSavedAccountRegistryLock(operation) {
79
+ ensureAccountsDir();
80
+ return (0, atomic_1.withFileLockSync)(getRegistryLockPath(), operation);
68
81
  }
69
82
  /**
70
83
  * Serializes registry read-modify-write cycles across processes. Without the
@@ -73,13 +86,13 @@ function writeRegistryUnlocked(registry) {
73
86
  * mutation. Lock ordering: callers that also hold a provider auth-swap lock
74
87
  * take that lock first and this one inside it, never the reverse.
75
88
  */
76
- function mutateSavedAccountRegistry(mutator) {
77
- ensureAccountsDir();
78
- return (0, atomic_1.withFileLockSync)(getRegistryLockPath(), () => {
79
- const next = mutator(readSavedAccountRegistry() ?? createEmptyRegistry());
80
- writeRegistryUnlocked(next);
81
- return next;
82
- });
89
+ function mutateSavedAccountRegistry(mutator, options = {}) {
90
+ return withSavedAccountRegistryLock(() => mutateSavedAccountRegistryUnlocked(mutator, options));
91
+ }
92
+ function mutateSavedAccountRegistryUnlocked(mutator, options = {}) {
93
+ const next = mutator(readSavedAccountRegistry() ?? createEmptyRegistry());
94
+ writeRegistryUnlocked(next, options);
95
+ return next;
83
96
  }
84
97
  function createEmptyRegistry() {
85
98
  return {
@@ -187,20 +200,30 @@ function upsertSavedAccountProfile(profile) {
187
200
  return registry;
188
201
  });
189
202
  }
190
- function setActiveSavedAccount(providerId, accountId) {
203
+ function setActiveSavedAccount(providerId, accountId, options = {}) {
191
204
  return mutateSavedAccountRegistry((registry) => {
192
205
  registry.activeByProvider[providerId] = accountId;
193
206
  return registry;
194
- });
207
+ }, options);
195
208
  }
196
- function replaceSavedAccountProfiles(providerId, accounts, activeId) {
197
- return mutateSavedAccountRegistry((registry) => {
209
+ function replaceProfiles(providerId, accounts, activeId) {
210
+ return (registry) => {
198
211
  registry.accounts = registry.accounts
199
212
  .filter((account) => account.providerId !== providerId)
200
213
  .concat(accounts);
201
214
  registry.activeByProvider[providerId] = activeId;
202
215
  return registry;
203
- });
216
+ };
217
+ }
218
+ function replaceSavedAccountProfiles(providerId, accounts, activeId) {
219
+ return mutateSavedAccountRegistry(replaceProfiles(providerId, accounts, activeId));
220
+ }
221
+ /**
222
+ * Same as {@link replaceSavedAccountProfiles} without taking the registry
223
+ * lock: for callers already inside {@link withSavedAccountRegistryLock}.
224
+ */
225
+ function replaceSavedAccountProfilesUnlocked(providerId, accounts, activeId) {
226
+ return mutateSavedAccountRegistryUnlocked(replaceProfiles(providerId, accounts, activeId));
204
227
  }
205
228
  function removeSavedAccountProfile(providerId, accountId) {
206
229
  ensureAccountsDir();
@@ -10,10 +10,18 @@ export interface ActiveAccountStatus {
10
10
  codex: ActiveProviderAccountStatus;
11
11
  error?: string;
12
12
  }
13
+ export interface ActiveAccountStatusOptions {
14
+ /**
15
+ * Re-point a stale saved-account pointer at the live identity when they
16
+ * disagree (default true). Hot paths such as the status line pass `false`
17
+ * so they never write; an ordinary command repairs the pointer later.
18
+ */
19
+ selfHeal?: boolean;
20
+ }
13
21
  /**
14
22
  * Reads active Claude Code and Codex account status in one filesystem pass.
15
23
  *
16
24
  * If reading either provider throws, the result still has provider-shaped
17
25
  * fields so startup flows can render a consistent "not configured" state.
18
26
  */
19
- export declare function getActiveAccountStatus(error?: string): ActiveAccountStatus;
27
+ export declare function getActiveAccountStatus(error?: string, options?: ActiveAccountStatusOptions): ActiveAccountStatus;
@@ -12,10 +12,10 @@ function errorMessage(error) {
12
12
  * If reading either provider throws, the result still has provider-shaped
13
13
  * fields so startup flows can render a consistent "not configured" state.
14
14
  */
15
- function getActiveAccountStatus(error) {
15
+ function getActiveAccountStatus(error, options = {}) {
16
16
  try {
17
- const claudeAccount = (0, accounts_1.resolveActiveClaudeAccount)();
18
- const codexAccount = (0, codexProfiles_1.resolveActiveCodexAccount)();
17
+ const claudeAccount = (0, accounts_1.resolveActiveClaudeAccount)(options);
18
+ const codexAccount = (0, codexProfiles_1.resolveActiveCodexAccount)(options);
19
19
  const claude = claudeAccount.source !== 'none'
20
20
  ? {
21
21
  present: true,
@@ -50,5 +50,7 @@ export declare function getActiveAccount(): AccountEntry | null;
50
50
  * re-pointed so registry-keyed data tracks reality too. Never creates or deletes
51
51
  * profiles; an unknown live account is shown as-is with no label and no write.
52
52
  */
53
- export declare function resolveActiveClaudeAccount(): ResolvedActiveAccount;
53
+ export declare function resolveActiveClaudeAccount(options?: {
54
+ selfHeal?: boolean;
55
+ }): ResolvedActiveAccount;
54
56
  export declare function isMultiAccountEnabled(): boolean;
package/dist/accounts.js CHANGED
@@ -201,7 +201,14 @@ function readAccountRegistry() {
201
201
  };
202
202
  }
203
203
  function writeAccountRegistry(registry) {
204
- const mappedClaudeProfiles = registry.accounts.map((account) => ({
204
+ (0, accountRegistry_1.replaceSavedAccountProfiles)('claude-code', mapClaudeProfiles(registry), registry.activeAccountUuid);
205
+ }
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
+ function mapClaudeProfiles(registry) {
211
+ return registry.accounts.map((account) => ({
205
212
  id: account.uuid,
206
213
  providerId: 'claude-code',
207
214
  providerAccountId: account.uuid,
@@ -212,7 +219,6 @@ function writeAccountRegistry(registry) {
212
219
  email: account.email,
213
220
  },
214
221
  }));
215
- (0, accountRegistry_1.replaceSavedAccountProfiles)('claude-code', mappedClaudeProfiles, registry.activeAccountUuid);
216
222
  }
217
223
  // ── Read active Claude account ───────────────────────────────────────────
218
224
  function readActiveClaudeAccount() {
@@ -254,17 +260,34 @@ function addCurrentAccount(label) {
254
260
  return { success: false, error: 'Could not read Claude config file.' };
255
261
  }
256
262
  ensureDirs();
257
- // 4. Load or create registry
258
- const registry = readAccountRegistry() ?? {
259
- version: 1,
260
- activeAccountUuid: null,
261
- accounts: [],
262
- };
263
- // 5. Check if account already exists — update credentials + label
264
- const existing = registry.accounts.find((a) => a.uuid === active.uuid);
265
- if (existing) {
266
- if (label !== undefined)
267
- existing.label = label;
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.
266
+ 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
268
291
  (0, atomic_1.atomicWriteJsonSync)(path.join(getCredentialsDir(), `${active.uuid}.credentials.json`), credBlob);
269
292
  (0, atomic_1.atomicWriteJsonSync)(path.join(getConfigsDir(), `${active.uuid}.config.json`), configBlob);
270
293
  try {
@@ -273,29 +296,17 @@ function addCurrentAccount(label) {
273
296
  catch {
274
297
  /* flat backups remain available */
275
298
  }
299
+ // 7. Add entry
300
+ registry.accounts.push({
301
+ uuid: active.uuid,
302
+ email: active.email,
303
+ label,
304
+ addedAt: new Date().toISOString(),
305
+ });
276
306
  registry.activeAccountUuid = active.uuid;
277
- writeAccountRegistry(registry);
307
+ writeAccountRegistryUnlocked(registry);
278
308
  return { success: true };
279
- }
280
- // 6. New account — back up credentials
281
- (0, atomic_1.atomicWriteJsonSync)(path.join(getCredentialsDir(), `${active.uuid}.credentials.json`), credBlob);
282
- (0, atomic_1.atomicWriteJsonSync)(path.join(getConfigsDir(), `${active.uuid}.config.json`), configBlob);
283
- try {
284
- writeClaudeProfileMirror(active.uuid, credBlob, configBlob);
285
- }
286
- catch {
287
- /* flat backups remain available */
288
- }
289
- // 7. Add entry
290
- registry.accounts.push({
291
- uuid: active.uuid,
292
- email: active.email,
293
- label,
294
- addedAt: new Date().toISOString(),
295
309
  });
296
- registry.activeAccountUuid = active.uuid;
297
- writeAccountRegistry(registry);
298
- return { success: true };
299
310
  }
300
311
  // ── Switch to account ────────────────────────────────────────────────────
301
312
  /**
@@ -486,34 +497,37 @@ function reconcileClaudeAuthState() {
486
497
  }
487
498
  // ── Remove account ───────────────────────────────────────────────────────
488
499
  function removeAccount(uuid) {
489
- const registry = readAccountRegistry();
490
- if (!registry) {
491
- return { success: false, error: 'No account registry found.' };
492
- }
493
- const idx = registry.accounts.findIndex((a) => a.uuid === uuid);
494
- if (idx === -1) {
495
- return { success: false, error: `Account ${uuid} not found.` };
496
- }
497
- // Remove backed-up files
498
- try {
499
- fs.unlinkSync(path.join(getCredentialsDir(), `${uuid}.credentials.json`));
500
- }
501
- catch {
502
- /* ok */
503
- }
504
- try {
505
- fs.unlinkSync(path.join(getConfigsDir(), `${uuid}.config.json`));
506
- }
507
- catch {
508
- /* ok */
509
- }
510
- // Remove from registry
511
- registry.accounts.splice(idx, 1);
512
- if (registry.activeAccountUuid === uuid) {
513
- registry.activeAccountUuid = registry.accounts[0]?.uuid ?? null;
514
- }
515
- writeAccountRegistry(registry);
516
- return { success: true };
500
+ // One read-modify-write under the registry lock (see addCurrentAccount).
501
+ 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) {
508
+ return { success: false, error: `Account ${uuid} not found.` };
509
+ }
510
+ // Remove backed-up files
511
+ try {
512
+ fs.unlinkSync(path.join(getCredentialsDir(), `${uuid}.credentials.json`));
513
+ }
514
+ catch {
515
+ /* ok */
516
+ }
517
+ try {
518
+ fs.unlinkSync(path.join(getConfigsDir(), `${uuid}.config.json`));
519
+ }
520
+ catch {
521
+ /* ok */
522
+ }
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);
529
+ return { success: true };
530
+ });
517
531
  }
518
532
  // ── Query helpers ────────────────────────────────────────────────────────
519
533
  function listAccounts() {
@@ -546,7 +560,8 @@ function getActiveAccount() {
546
560
  * re-pointed so registry-keyed data tracks reality too. Never creates or deletes
547
561
  * profiles; an unknown live account is shown as-is with no label and no write.
548
562
  */
549
- function resolveActiveClaudeAccount() {
563
+ function resolveActiveClaudeAccount(options = {}) {
564
+ const selfHeal = options.selfHeal ?? true;
550
565
  const live = readActiveClaudeAccount();
551
566
  if (live) {
552
567
  const profiles = (0, accountRegistry_1.listSavedAccountProfiles)('claude-code');
@@ -554,12 +569,17 @@ function resolveActiveClaudeAccount() {
554
569
  profiles.find((p) => (p.email ?? p.metadata?.email) === live.email);
555
570
  if (match) {
556
571
  const active = (0, accountRegistry_1.getActiveSavedAccount)('claude-code');
557
- if (!active || active.id !== match.id) {
572
+ // Hot paths (the status line runs on every prompt) pass selfHeal: false
573
+ // so a persistently failing registry write never becomes a write per
574
+ // prompt; the next ordinary command repairs the pointer instead.
575
+ if (selfHeal && (!active || active.id !== match.id)) {
558
576
  // Self-heal is best-effort: a registry write failure (read-only/full
559
577
  // disk) must never break display or extension activation. We still
560
578
  // return the correct live identity below.
579
+ // Silent: this is a read path repairing a pointer to the profile that
580
+ // already matches the live login, so subscribers have nothing new to see.
561
581
  try {
562
- (0, accountRegistry_1.setActiveSavedAccount)('claude-code', match.id);
582
+ (0, accountRegistry_1.setActiveSavedAccount)('claude-code', match.id, { silent: true });
563
583
  }
564
584
  catch {
565
585
  /* keep going with the live identity */