@cortexkit/opencode-antigravity-auth 1.1.0 → 2.0.1
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.
- package/README.md +225 -569
- package/dist/cli.js +4017 -0
- package/dist/cli.js.map +7 -0
- package/dist/index.d.ts +1 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20742 -14586
- package/dist/index.js.map +4 -4
- package/dist/src/antigravity/oauth.d.ts +1 -1
- package/dist/src/antigravity/oauth.js +1 -1
- package/dist/src/cli.d.ts +21 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +237 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/constants.d.ts +1 -1
- package/dist/src/constants.js +1 -1
- package/dist/src/hooks/auto-update-checker/cache.d.ts.map +1 -1
- package/dist/src/hooks/auto-update-checker/cache.js +13 -13
- package/dist/src/hooks/auto-update-checker/cache.js.map +1 -1
- package/dist/src/hooks/auto-update-checker/checker.d.ts +1 -1
- package/dist/src/hooks/auto-update-checker/checker.d.ts.map +1 -1
- package/dist/src/hooks/auto-update-checker/checker.js +75 -51
- package/dist/src/hooks/auto-update-checker/checker.js.map +1 -1
- package/dist/src/hooks/auto-update-checker/constants.d.ts.map +1 -1
- package/dist/src/hooks/auto-update-checker/constants.js +12 -12
- package/dist/src/hooks/auto-update-checker/constants.js.map +1 -1
- package/dist/src/hooks/auto-update-checker/index.d.ts +6 -6
- package/dist/src/hooks/auto-update-checker/index.d.ts.map +1 -1
- package/dist/src/hooks/auto-update-checker/index.js +17 -17
- package/dist/src/hooks/auto-update-checker/index.js.map +1 -1
- package/dist/src/hooks/auto-update-checker/logging.js +2 -2
- package/dist/src/hooks/auto-update-checker/logging.js.map +1 -1
- package/dist/src/hooks/auto-update-checker/types.d.ts.map +1 -1
- package/dist/src/plugin/account-access.d.ts +99 -0
- package/dist/src/plugin/account-access.d.ts.map +1 -0
- package/dist/src/plugin/account-access.js +480 -0
- package/dist/src/plugin/account-access.js.map +1 -0
- package/dist/src/plugin/account-command-oauth.d.ts +34 -0
- package/dist/src/plugin/account-command-oauth.d.ts.map +1 -0
- package/dist/src/plugin/account-command-oauth.js +156 -0
- package/dist/src/plugin/account-command-oauth.js.map +1 -0
- package/dist/src/plugin/accounts.d.ts +7 -256
- package/dist/src/plugin/accounts.d.ts.map +1 -1
- package/dist/src/plugin/accounts.js +31 -1367
- package/dist/src/plugin/accounts.js.map +1 -1
- package/dist/src/plugin/agy-request-metadata.d.ts +1 -1
- package/dist/src/plugin/agy-request-metadata.js +1 -1
- package/dist/src/plugin/agy-transport.d.ts +1 -1
- package/dist/src/plugin/agy-transport.js +1 -1
- package/dist/src/plugin/auth-doctor.d.ts +6 -6
- package/dist/src/plugin/auth-doctor.d.ts.map +1 -1
- package/dist/src/plugin/auth-doctor.js +74 -66
- package/dist/src/plugin/auth-doctor.js.map +1 -1
- package/dist/src/plugin/auth-drift.d.ts +4 -4
- package/dist/src/plugin/auth-drift.d.ts.map +1 -1
- package/dist/src/plugin/auth-drift.js +15 -15
- package/dist/src/plugin/auth-drift.js.map +1 -1
- package/dist/src/plugin/auth-loader.d.ts +50 -0
- package/dist/src/plugin/auth-loader.d.ts.map +1 -0
- package/dist/src/plugin/auth-loader.js +225 -0
- package/dist/src/plugin/auth-loader.js.map +1 -0
- package/dist/src/plugin/auth.d.ts +1 -1
- package/dist/src/plugin/auth.js +1 -1
- package/dist/src/plugin/background-quota-refresh.d.ts +146 -0
- package/dist/src/plugin/background-quota-refresh.d.ts.map +1 -0
- package/dist/src/plugin/background-quota-refresh.js +347 -0
- package/dist/src/plugin/background-quota-refresh.js.map +1 -0
- package/dist/src/plugin/cache/index.d.ts +1 -1
- package/dist/src/plugin/cache/index.d.ts.map +1 -1
- package/dist/src/plugin/cache/index.js +1 -1
- package/dist/src/plugin/cache/index.js.map +1 -1
- package/dist/src/plugin/cache/signature-cache.d.ts +1 -1
- package/dist/src/plugin/cache/signature-cache.d.ts.map +1 -1
- package/dist/src/plugin/cache/signature-cache.js +23 -24
- package/dist/src/plugin/cache/signature-cache.js.map +1 -1
- package/dist/src/plugin/cache.d.ts +6 -5
- package/dist/src/plugin/cache.d.ts.map +1 -1
- package/dist/src/plugin/cache.js +21 -7
- package/dist/src/plugin/cache.js.map +1 -1
- package/dist/src/plugin/catalog.d.ts +23 -0
- package/dist/src/plugin/catalog.d.ts.map +1 -0
- package/dist/src/plugin/catalog.js +60 -0
- package/dist/src/plugin/catalog.js.map +1 -0
- package/dist/src/plugin/cli.d.ts +7 -7
- package/dist/src/plugin/cli.d.ts.map +1 -1
- package/dist/src/plugin/cli.js +62 -55
- package/dist/src/plugin/cli.js.map +1 -1
- package/dist/src/plugin/command-data.d.ts +329 -0
- package/dist/src/plugin/command-data.d.ts.map +1 -0
- package/dist/src/plugin/command-data.js +517 -0
- package/dist/src/plugin/command-data.js.map +1 -0
- package/dist/src/plugin/commands.d.ts +154 -0
- package/dist/src/plugin/commands.d.ts.map +1 -0
- package/dist/src/plugin/commands.js +746 -0
- package/dist/src/plugin/commands.js.map +1 -0
- package/dist/src/plugin/config/index.d.ts +2 -2
- package/dist/src/plugin/config/index.d.ts.map +1 -1
- package/dist/src/plugin/config/index.js +2 -2
- package/dist/src/plugin/config/index.js.map +1 -1
- package/dist/src/plugin/config/loader.d.ts +1 -1
- package/dist/src/plugin/config/loader.d.ts.map +1 -1
- package/dist/src/plugin/config/loader.js +18 -16
- package/dist/src/plugin/config/loader.js.map +1 -1
- package/dist/src/plugin/config/models.d.ts +2 -2
- package/dist/src/plugin/config/models.d.ts.map +1 -1
- package/dist/src/plugin/config/models.js +1 -1
- package/dist/src/plugin/config/models.js.map +1 -1
- package/dist/src/plugin/config/operator-settings-schema.d.ts +29 -0
- package/dist/src/plugin/config/operator-settings-schema.d.ts.map +1 -0
- package/dist/src/plugin/config/operator-settings-schema.js +28 -0
- package/dist/src/plugin/config/operator-settings-schema.js.map +1 -0
- package/dist/src/plugin/config/schema.d.ts +23 -2
- package/dist/src/plugin/config/schema.d.ts.map +1 -1
- package/dist/src/plugin/config/schema.js +109 -25
- package/dist/src/plugin/config/schema.js.map +1 -1
- package/dist/src/plugin/config/updater.d.ts.map +1 -1
- package/dist/src/plugin/config/updater.js +15 -15
- package/dist/src/plugin/config/updater.js.map +1 -1
- package/dist/src/plugin/config/writer.d.ts +34 -0
- package/dist/src/plugin/config/writer.d.ts.map +1 -0
- package/dist/src/plugin/config/writer.js +81 -0
- package/dist/src/plugin/config/writer.js.map +1 -0
- package/dist/src/plugin/core/streaming/index.d.ts +1 -1
- package/dist/src/plugin/core/streaming/index.d.ts.map +1 -1
- package/dist/src/plugin/core/streaming/index.js +1 -1
- package/dist/src/plugin/core/streaming/index.js.map +1 -1
- package/dist/src/plugin/core/streaming/transformer.d.ts.map +1 -1
- package/dist/src/plugin/core/streaming/transformer.js +85 -44
- package/dist/src/plugin/core/streaming/transformer.js.map +1 -1
- package/dist/src/plugin/core/streaming/types.d.ts.map +1 -1
- package/dist/src/plugin/debug.d.ts +10 -3
- package/dist/src/plugin/debug.d.ts.map +1 -1
- package/dist/src/plugin/debug.js +110 -47
- package/dist/src/plugin/debug.js.map +1 -1
- package/dist/src/plugin/dependencies.d.ts +129 -0
- package/dist/src/plugin/dependencies.d.ts.map +1 -0
- package/dist/src/plugin/dependencies.js +107 -0
- package/dist/src/plugin/dependencies.js.map +1 -0
- package/dist/src/plugin/errors.d.ts +27 -0
- package/dist/src/plugin/errors.d.ts.map +1 -1
- package/dist/src/plugin/errors.js +29 -3
- package/dist/src/plugin/errors.js.map +1 -1
- package/dist/src/plugin/event-handler.d.ts +53 -0
- package/dist/src/plugin/event-handler.d.ts.map +1 -0
- package/dist/src/plugin/event-handler.js +101 -0
- package/dist/src/plugin/event-handler.js.map +1 -0
- package/dist/src/plugin/fetch/retry-state.d.ts +61 -0
- package/dist/src/plugin/fetch/retry-state.d.ts.map +1 -0
- package/dist/src/plugin/fetch/retry-state.js +248 -0
- package/dist/src/plugin/fetch/retry-state.js.map +1 -0
- package/dist/src/plugin/fetch/warmup.d.ts +30 -0
- package/dist/src/plugin/fetch/warmup.d.ts.map +1 -0
- package/dist/src/plugin/fetch/warmup.js +76 -0
- package/dist/src/plugin/fetch/warmup.js.map +1 -0
- package/dist/src/plugin/fetch-interceptor.d.ts +59 -0
- package/dist/src/plugin/fetch-interceptor.d.ts.map +1 -0
- package/dist/src/plugin/fetch-interceptor.js +1399 -0
- package/dist/src/plugin/fetch-interceptor.js.map +1 -0
- package/dist/src/plugin/fetch-routing.d.ts +26 -0
- package/dist/src/plugin/fetch-routing.d.ts.map +1 -0
- package/dist/src/plugin/fetch-routing.js +99 -0
- package/dist/src/plugin/fetch-routing.js.map +1 -0
- package/dist/src/plugin/fingerprint.d.ts +1 -1
- package/dist/src/plugin/fingerprint.js +1 -1
- package/dist/src/plugin/gemini-dump.d.ts +5 -5
- package/dist/src/plugin/gemini-dump.d.ts.map +1 -1
- package/dist/src/plugin/gemini-dump.js +109 -55
- package/dist/src/plugin/gemini-dump.js.map +1 -1
- package/dist/src/plugin/google-search-tool.d.ts +9 -0
- package/dist/src/plugin/google-search-tool.d.ts.map +1 -0
- package/dist/src/plugin/google-search-tool.js +56 -0
- package/dist/src/plugin/google-search-tool.js.map +1 -0
- package/dist/src/plugin/image-saver.d.ts.map +1 -1
- package/dist/src/plugin/image-saver.js +16 -16
- package/dist/src/plugin/image-saver.js.map +1 -1
- package/dist/src/plugin/index.d.ts +30 -0
- package/dist/src/plugin/index.d.ts.map +1 -0
- package/dist/src/plugin/index.js +411 -0
- package/dist/src/plugin/index.js.map +1 -0
- package/dist/src/plugin/killswitch.d.ts +94 -0
- package/dist/src/plugin/killswitch.d.ts.map +1 -0
- package/dist/src/plugin/killswitch.js +197 -0
- package/dist/src/plugin/killswitch.js.map +1 -0
- package/dist/src/plugin/lifecycle.d.ts +40 -0
- package/dist/src/plugin/lifecycle.d.ts.map +1 -0
- package/dist/src/plugin/lifecycle.js +73 -0
- package/dist/src/plugin/lifecycle.js.map +1 -0
- package/dist/src/plugin/logger.d.ts +10 -1
- package/dist/src/plugin/logger.d.ts.map +1 -1
- package/dist/src/plugin/logger.js +38 -12
- package/dist/src/plugin/logger.js.map +1 -1
- package/dist/src/plugin/logging-utils.d.ts +35 -1
- package/dist/src/plugin/logging-utils.d.ts.map +1 -1
- package/dist/src/plugin/logging-utils.js +98 -15
- package/dist/src/plugin/logging-utils.js.map +1 -1
- package/dist/src/plugin/model-registry.d.ts +1 -1
- package/dist/src/plugin/model-registry.js +1 -1
- package/dist/src/plugin/oauth-login.d.ts +22 -0
- package/dist/src/plugin/oauth-login.d.ts.map +1 -0
- package/dist/src/plugin/oauth-login.js +37 -0
- package/dist/src/plugin/oauth-login.js.map +1 -0
- package/dist/src/plugin/oauth-methods.d.ts +42 -0
- package/dist/src/plugin/oauth-methods.d.ts.map +1 -0
- package/dist/src/plugin/oauth-methods.js +1276 -0
- package/dist/src/plugin/oauth-methods.js.map +1 -0
- package/dist/src/plugin/operator-settings.d.ts +40 -0
- package/dist/src/plugin/operator-settings.d.ts.map +1 -0
- package/dist/src/plugin/operator-settings.js +148 -0
- package/dist/src/plugin/operator-settings.js.map +1 -0
- package/dist/src/plugin/persist-account-pool.d.ts +37 -0
- package/dist/src/plugin/persist-account-pool.d.ts.map +1 -0
- package/dist/src/plugin/persist-account-pool.js +142 -0
- package/dist/src/plugin/persist-account-pool.js.map +1 -0
- package/dist/src/plugin/project.d.ts +1 -1
- package/dist/src/plugin/project.js +1 -1
- package/dist/src/plugin/prompt-context.d.ts.map +1 -1
- package/dist/src/plugin/prompt-context.js +11 -11
- package/dist/src/plugin/prompt-context.js.map +1 -1
- package/dist/src/plugin/quota.d.ts +138 -41
- package/dist/src/plugin/quota.d.ts.map +1 -1
- package/dist/src/plugin/quota.js +417 -285
- package/dist/src/plugin/quota.js.map +1 -1
- package/dist/src/plugin/recovery/constants.d.ts.map +1 -1
- package/dist/src/plugin/recovery/constants.js +24 -15
- package/dist/src/plugin/recovery/constants.js.map +1 -1
- package/dist/src/plugin/recovery/index.d.ts +3 -3
- package/dist/src/plugin/recovery/index.d.ts.map +1 -1
- package/dist/src/plugin/recovery/index.js +3 -3
- package/dist/src/plugin/recovery/index.js.map +1 -1
- package/dist/src/plugin/recovery/storage.d.ts +1 -1
- package/dist/src/plugin/recovery/storage.d.ts.map +1 -1
- package/dist/src/plugin/recovery/storage.js +31 -39
- package/dist/src/plugin/recovery/storage.js.map +1 -1
- package/dist/src/plugin/recovery/types.d.ts +12 -12
- package/dist/src/plugin/recovery/types.d.ts.map +1 -1
- package/dist/src/plugin/recovery.d.ts +3 -3
- package/dist/src/plugin/recovery.d.ts.map +1 -1
- package/dist/src/plugin/recovery.js +62 -58
- package/dist/src/plugin/recovery.js.map +1 -1
- package/dist/src/plugin/refresh-queue.d.ts +5 -2
- package/dist/src/plugin/refresh-queue.d.ts.map +1 -1
- package/dist/src/plugin/refresh-queue.js +37 -15
- package/dist/src/plugin/refresh-queue.js.map +1 -1
- package/dist/src/plugin/request-helpers.d.ts +1 -1
- package/dist/src/plugin/request-helpers.d.ts.map +1 -1
- package/dist/src/plugin/request-helpers.js +451 -352
- package/dist/src/plugin/request-helpers.js.map +1 -1
- package/dist/src/plugin/request.d.ts +7 -7
- package/dist/src/plugin/request.d.ts.map +1 -1
- package/dist/src/plugin/request.js +622 -384
- package/dist/src/plugin/request.js.map +1 -1
- package/dist/src/plugin/rotation.d.ts +1 -168
- package/dist/src/plugin/rotation.d.ts.map +1 -1
- package/dist/src/plugin/rotation.js +1 -327
- package/dist/src/plugin/rotation.js.map +1 -1
- package/dist/src/plugin/search.d.ts.map +1 -1
- package/dist/src/plugin/search.js +39 -35
- package/dist/src/plugin/search.js.map +1 -1
- package/dist/src/plugin/server.d.ts +1 -1
- package/dist/src/plugin/server.d.ts.map +1 -1
- package/dist/src/plugin/server.js +42 -37
- package/dist/src/plugin/server.js.map +1 -1
- package/dist/src/plugin/session-context.d.ts +2 -1
- package/dist/src/plugin/session-context.d.ts.map +1 -1
- package/dist/src/plugin/session-context.js +11 -7
- package/dist/src/plugin/session-context.js.map +1 -1
- package/dist/src/plugin/storage.d.ts +59 -120
- package/dist/src/plugin/storage.d.ts.map +1 -1
- package/dist/src/plugin/storage.js +140 -485
- package/dist/src/plugin/storage.js.map +1 -1
- package/dist/src/plugin/stores/signature-store.d.ts.map +1 -1
- package/dist/src/plugin/stores/signature-store.js.map +1 -1
- package/dist/src/plugin/thinking-recovery.d.ts.map +1 -1
- package/dist/src/plugin/thinking-recovery.js +35 -33
- package/dist/src/plugin/thinking-recovery.js.map +1 -1
- package/dist/src/plugin/token.d.ts +2 -2
- package/dist/src/plugin/token.d.ts.map +1 -1
- package/dist/src/plugin/token.js +30 -21
- package/dist/src/plugin/token.js.map +1 -1
- package/dist/src/plugin/transform/cross-model-sanitizer.d.ts +1 -1
- package/dist/src/plugin/transform/cross-model-sanitizer.js +1 -1
- package/dist/src/plugin/transform/index.d.ts +1 -1
- package/dist/src/plugin/transform/index.js +1 -1
- package/dist/src/plugin/transform/model-resolver.d.ts +1 -1
- package/dist/src/plugin/transform/model-resolver.js +1 -1
- package/dist/src/plugin/transform/types.d.ts +1 -1
- package/dist/src/plugin/transform/types.js +1 -1
- package/dist/src/plugin/types.d.ts +25 -69
- package/dist/src/plugin/types.d.ts.map +1 -1
- package/dist/src/plugin/ui/ansi.d.ts.map +1 -1
- package/dist/src/plugin/ui/ansi.js.map +1 -1
- package/dist/src/plugin/ui/auth-menu.d.ts +1 -1
- package/dist/src/plugin/ui/auth-menu.d.ts.map +1 -1
- package/dist/src/plugin/ui/auth-menu.js +84 -35
- package/dist/src/plugin/ui/auth-menu.js.map +1 -1
- package/dist/src/plugin/ui/confirm.d.ts.map +1 -1
- package/dist/src/plugin/ui/confirm.js.map +1 -1
- package/dist/src/plugin/ui/model-status.d.ts +3 -3
- package/dist/src/plugin/ui/model-status.d.ts.map +1 -1
- package/dist/src/plugin/ui/model-status.js +3 -3
- package/dist/src/plugin/ui/model-status.js.map +1 -1
- package/dist/src/plugin/ui/quota-status.d.ts +4 -4
- package/dist/src/plugin/ui/quota-status.d.ts.map +1 -1
- package/dist/src/plugin/ui/quota-status.js +49 -49
- package/dist/src/plugin/ui/quota-status.js.map +1 -1
- package/dist/src/plugin/ui/select.d.ts.map +1 -1
- package/dist/src/plugin/ui/select.js +22 -12
- package/dist/src/plugin/ui/select.js.map +1 -1
- package/dist/src/plugin/version.d.ts +1 -1
- package/dist/src/plugin/version.js +1 -1
- package/dist/src/rpc/notifications.d.ts +6 -0
- package/dist/src/rpc/notifications.d.ts.map +1 -0
- package/dist/src/rpc/notifications.js +46 -0
- package/dist/src/rpc/notifications.js.map +1 -0
- package/dist/src/rpc/port-file.d.ts +13 -0
- package/dist/src/rpc/port-file.d.ts.map +1 -0
- package/dist/src/rpc/port-file.js +109 -0
- package/dist/src/rpc/port-file.js.map +1 -0
- package/dist/src/rpc/protocol.d.ts +22 -0
- package/dist/src/rpc/protocol.d.ts.map +1 -0
- package/dist/src/rpc/protocol.js +1 -0
- package/dist/src/rpc/protocol.js.map +1 -0
- package/dist/src/rpc/rpc-client.d.ts +10 -0
- package/dist/src/rpc/rpc-client.d.ts.map +1 -0
- package/dist/src/rpc/rpc-client.js +58 -0
- package/dist/src/rpc/rpc-client.js.map +1 -0
- package/dist/src/rpc/rpc-dir.d.ts +4 -0
- package/dist/src/rpc/rpc-dir.d.ts.map +1 -0
- package/dist/src/rpc/rpc-dir.js +19 -0
- package/dist/src/rpc/rpc-dir.js.map +1 -0
- package/dist/src/rpc/rpc-server.d.ts +13 -0
- package/dist/src/rpc/rpc-server.d.ts.map +1 -0
- package/dist/src/rpc/rpc-server.js +231 -0
- package/dist/src/rpc/rpc-server.js.map +1 -0
- package/dist/src/sidebar-state.d.ts +357 -0
- package/dist/src/sidebar-state.d.ts.map +1 -0
- package/dist/src/sidebar-state.js +680 -0
- package/dist/src/sidebar-state.js.map +1 -0
- package/dist/src/tui/command-dialogs.d.ts +61 -0
- package/dist/src/tui/command-dialogs.d.ts.map +1 -0
- package/dist/src/tui/command-dialogs.js +595 -0
- package/dist/src/tui/command-dialogs.js.map +1 -0
- package/dist/src/tui/file-logger.d.ts +41 -0
- package/dist/src/tui/file-logger.d.ts.map +1 -0
- package/dist/src/tui/file-logger.js +114 -0
- package/dist/src/tui/file-logger.js.map +1 -0
- package/dist/src/tui-preferences.d.ts +40 -0
- package/dist/src/tui-preferences.d.ts.map +1 -0
- package/dist/src/tui-preferences.js +328 -0
- package/dist/src/tui-preferences.js.map +1 -0
- package/dist/src/tui.d.ts +68 -0
- package/dist/src/tui.d.ts.map +1 -0
- package/dist/src/tui.js +697 -0
- package/dist/src/tui.js.map +1 -0
- package/package.json +46 -22
- package/src/plugin/command-data.ts +931 -0
- package/src/rpc/port-file.ts +143 -0
- package/src/rpc/protocol.ts +31 -0
- package/src/rpc/rpc-client.ts +102 -0
- package/src/rpc/rpc-dir.ts +24 -0
- package/src/sidebar-state.ts +991 -0
- package/src/tui/command-dialogs.tsx +832 -0
- package/src/tui/entry.mjs +74 -0
- package/src/tui/file-logger.ts +145 -0
- package/src/tui-compiled/plugin/command-data.ts +931 -0
- package/src/tui-compiled/rpc/port-file.ts +143 -0
- package/src/tui-compiled/rpc/protocol.ts +31 -0
- package/src/tui-compiled/rpc/rpc-client.ts +102 -0
- package/src/tui-compiled/rpc/rpc-dir.ts +24 -0
- package/src/tui-compiled/sidebar-state.ts +991 -0
- package/src/tui-compiled/tui/command-dialogs.tsx +779 -0
- package/src/tui-compiled/tui/file-logger.ts +145 -0
- package/src/tui-compiled/tui-preferences.ts +416 -0
- package/src/tui-compiled/tui.tsx +1193 -0
- package/src/tui-preferences.ts +416 -0
- package/src/tui.tsx +1171 -0
- package/dist/src/plugin.d.ts +0 -48
- package/dist/src/plugin.d.ts.map +0 -1
- package/dist/src/plugin.js +0 -3311
- package/dist/src/plugin.js.map +0 -1
|
@@ -0,0 +1,991 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar state contract for the OpenTUI sidebar.
|
|
3
|
+
*
|
|
4
|
+
* This module is the read-only seam between the long-running plugin and the
|
|
5
|
+
* Solid/OpenTUI sidebar tree. It deliberately does NOT import account storage,
|
|
6
|
+
* the account manager, OAuth code, or any other privileged host-side module:
|
|
7
|
+
* the TUI is rendered inside the host's terminal and a single stray import
|
|
8
|
+
* could leak credentials or pull a heavy manager into the render path.
|
|
9
|
+
*
|
|
10
|
+
* The plugin writes a redacted snapshot to the file resolved by
|
|
11
|
+
* `getSidebarStateFile()` and the TUI polls it. The contract version is `1`:
|
|
12
|
+
* any future field that the TUI cannot understand must be ignored, and any
|
|
13
|
+
* broken/missing file must collapse to `DEFAULT_SIDEBAR_STATE`.
|
|
14
|
+
*
|
|
15
|
+
* ## Writer surface
|
|
16
|
+
*
|
|
17
|
+
* The plugin-side writers live here too so the read and write halves of the
|
|
18
|
+
* contract evolve together. They are imported by the plugin (auth-loader,
|
|
19
|
+
* quota, fetch-interceptor, event-handler, commands) but never invoked from
|
|
20
|
+
* the TUI's compiled tree — that tree only calls the readers, so the
|
|
21
|
+
* heavyweight core imports below never run inside the host's render path.
|
|
22
|
+
*
|
|
23
|
+
* Every disk mutation follows the same recipe:
|
|
24
|
+
*
|
|
25
|
+
* 1. Serialize through `sidebarWriteChain` so concurrent in-process calls
|
|
26
|
+
* never interleave merges against the same file.
|
|
27
|
+
* 2. Acquire Task 7's `acquireFencedFileLock` with bounded retry+jitter
|
|
28
|
+
* (≤2s). A live cross-process holder that does not release in time
|
|
29
|
+
* surfaces as `SidebarStateLockContentionError`.
|
|
30
|
+
* 3. Re-read and normalize the on-disk state while holding the lock.
|
|
31
|
+
* 4. Merge the new machine or routing payload against the re-read state.
|
|
32
|
+
* 5. `assertOwned()` + `writeJsonAtomic` with mode 0o600, then release.
|
|
33
|
+
*
|
|
34
|
+
* The merge step is deterministic: machine fields adopt only when the new
|
|
35
|
+
* `checkedAt` is ≥ the on-disk one, `routingAuthoritative` is sticky-true,
|
|
36
|
+
* and `activeRouting` is merged independently and pruned to the freshest
|
|
37
|
+
* 100 entries within 24h.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { mkdirSync, readFileSync } from 'node:fs'
|
|
41
|
+
import { homedir } from 'node:os'
|
|
42
|
+
import { dirname, join } from 'node:path'
|
|
43
|
+
// Subpath imports (not the barrel): this file ships into the TUI's
|
|
44
|
+
// compiled tree, and the barrel re-exports account/OAuth/quota modules
|
|
45
|
+
// that must never execute inside the host's render path.
|
|
46
|
+
import { writeJsonAtomic } from '@cortexkit/antigravity-auth-core/atomic-write'
|
|
47
|
+
import {
|
|
48
|
+
acquireFencedFileLock,
|
|
49
|
+
type FencedFileLock,
|
|
50
|
+
} from '@cortexkit/antigravity-auth-core/file-lock'
|
|
51
|
+
import { xdgState } from 'xdg-basedir'
|
|
52
|
+
|
|
53
|
+
export const SIDEBAR_STATE_VERSION = 1 as const
|
|
54
|
+
|
|
55
|
+
export type SidebarQuotaKey = 'gemini' | 'non-gemini'
|
|
56
|
+
|
|
57
|
+
export interface SidebarQuotaWindowEntry {
|
|
58
|
+
window: 'weekly' | '5h'
|
|
59
|
+
remainingPercent: number
|
|
60
|
+
resetAt?: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SidebarQuotaEntry {
|
|
64
|
+
/** Most-constrained window's remaining % (derived, for back-compat). */
|
|
65
|
+
remainingPercent: number
|
|
66
|
+
resetAt?: number
|
|
67
|
+
/** Per-window breakdown. Omitted in pre-windows snapshots. */
|
|
68
|
+
windows?: SidebarQuotaWindowEntry[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface SidebarAccountState {
|
|
72
|
+
id: string
|
|
73
|
+
label: string
|
|
74
|
+
enabled: boolean
|
|
75
|
+
health: number
|
|
76
|
+
current: boolean
|
|
77
|
+
cooldownUntil?: number
|
|
78
|
+
quota: Partial<Record<SidebarQuotaKey, SidebarQuotaEntry>>
|
|
79
|
+
/**
|
|
80
|
+
* Captured plan tier. Absent when unknown — do NOT default to `free-tier`.
|
|
81
|
+
* `id` is the raw upstream string (e.g. `"free-tier"`); never normalised.
|
|
82
|
+
*/
|
|
83
|
+
tier?: { id: string; paidId?: string; capturedAt: number }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SidebarRoutingEntry {
|
|
87
|
+
accountId: string
|
|
88
|
+
modelFamily: 'claude' | 'gemini'
|
|
89
|
+
headerStyle: 'antigravity' | 'gemini-cli'
|
|
90
|
+
strategy?: 'sticky' | 'round-robin' | 'hybrid'
|
|
91
|
+
updatedAt: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface SidebarStateV1 {
|
|
95
|
+
version: typeof SIDEBAR_STATE_VERSION
|
|
96
|
+
checkedAt: number
|
|
97
|
+
accounts: SidebarAccountState[]
|
|
98
|
+
activeRouting: Record<string, SidebarRoutingEntry>
|
|
99
|
+
routingAuthoritative: boolean
|
|
100
|
+
quotaBackoffUntil?: number
|
|
101
|
+
lastError?: string
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The subset of `SidebarStateV1` that a non-routing writer may set. The
|
|
106
|
+
* fetch interceptor writes `activeRouting` directly via its own entry point
|
|
107
|
+
* because routing is session-scoped, not machine-scoped.
|
|
108
|
+
*/
|
|
109
|
+
export interface SidebarMachineState {
|
|
110
|
+
checkedAt: number
|
|
111
|
+
accounts: SidebarAccountState[]
|
|
112
|
+
quotaBackoffUntil?: number
|
|
113
|
+
lastError?: string
|
|
114
|
+
/**
|
|
115
|
+
* Optional: opt in to mark the snapshot as authoritative. When `true`,
|
|
116
|
+
* the merge keeps the existing `routingAuthoritative: true` even if a
|
|
117
|
+
* later non-authoritative machine write lands. Sticky-true semantics.
|
|
118
|
+
*/
|
|
119
|
+
routingAuthoritative?: boolean
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const DEFAULT_SIDEBAR_STATE: SidebarStateV1 = {
|
|
123
|
+
version: SIDEBAR_STATE_VERSION,
|
|
124
|
+
checkedAt: 0,
|
|
125
|
+
accounts: [],
|
|
126
|
+
activeRouting: {},
|
|
127
|
+
routingAuthoritative: false,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const SIDEBAR_STATE_ENV = 'ANTIGRAVITY_AUTH_SIDEBAR_STATE_FILE'
|
|
131
|
+
|
|
132
|
+
const SIDEBAR_STATE_DIR = 'cortexkit/antigravity-auth'
|
|
133
|
+
const SIDEBAR_STATE_FILENAME = 'sidebar-state.json'
|
|
134
|
+
|
|
135
|
+
/** Active routing entries older than this are dropped on every merge. */
|
|
136
|
+
const ACTIVE_ROUTING_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
|
137
|
+
/** Active routing map is capped at this many newest entries. */
|
|
138
|
+
const ACTIVE_ROUTING_MAX_ENTRIES = 100
|
|
139
|
+
|
|
140
|
+
const SIDEBAR_LOCK_NAME = 'sidebar'
|
|
141
|
+
const SIDEBAR_LOCK_TTL_MS = 10_000
|
|
142
|
+
const SIDEBAR_LOCK_TIMEOUT_MS = 2_000
|
|
143
|
+
const SIDEBAR_LOCK_RETRY_BASE_MS = 25
|
|
144
|
+
const SIDEBAR_LOCK_RETRY_CAP_MS = 75
|
|
145
|
+
const SIDEBAR_LOCK_JITTER_MS = 25
|
|
146
|
+
const SIDEBAR_STATE_DIR_MODE = 0o700
|
|
147
|
+
const SIDEBAR_STATE_FILE_MODE = 0o600
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Thrown by every writer when the cross-process lock cannot be acquired
|
|
151
|
+
* within `SIDEBAR_LOCK_TIMEOUT_MS`. The caller decides whether to surface
|
|
152
|
+
* a toast, drop the write, or retry the next tick.
|
|
153
|
+
*/
|
|
154
|
+
export class SidebarStateLockContentionError extends Error {
|
|
155
|
+
readonly details: { stateFile: string; timeoutMs: number }
|
|
156
|
+
|
|
157
|
+
constructor(stateFile: string, timeoutMs: number) {
|
|
158
|
+
super(
|
|
159
|
+
`Could not acquire sidebar-state lock at ${stateFile} within ${timeoutMs}ms`,
|
|
160
|
+
)
|
|
161
|
+
this.name = 'SidebarStateLockContentionError'
|
|
162
|
+
this.details = { stateFile, timeoutMs }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Steps exposed to the merge hooks. Race tests pause writers here; production
|
|
168
|
+
* callers leave the hooks unset (a no-op fast path).
|
|
169
|
+
*
|
|
170
|
+
* - `await-lock` — before invoking `acquireFencedFileLock`.
|
|
171
|
+
* - `acquired-lock` — after the lock is granted and before the read.
|
|
172
|
+
* - `read-state` — after the on-disk state is normalized.
|
|
173
|
+
* - `merged-state` — after the merge but before `writeJsonAtomic`.
|
|
174
|
+
* - `wrote-state` — after the rename but before `lock.release()`.
|
|
175
|
+
*/
|
|
176
|
+
export type SidebarMergeStep =
|
|
177
|
+
| 'await-lock'
|
|
178
|
+
| 'acquired-lock'
|
|
179
|
+
| 'read-state'
|
|
180
|
+
| 'merged-state'
|
|
181
|
+
| 'wrote-state'
|
|
182
|
+
|
|
183
|
+
export interface SidebarMergeHooks {
|
|
184
|
+
onStep?: (step: SidebarMergeStep) => Promise<void> | void
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let sidebarMergeHooks: SidebarMergeHooks | null = null
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Install (or clear with `null`) the deterministic race hooks. Tests use
|
|
191
|
+
* these to inject interleavings; production callers leave them unset. The
|
|
192
|
+
* module-level state means a test must reset hooks in its own `afterEach`
|
|
193
|
+
* to avoid bleeding into the next test.
|
|
194
|
+
*/
|
|
195
|
+
export function setSidebarMergeHooks(hooks: SidebarMergeHooks | null): void {
|
|
196
|
+
sidebarMergeHooks = hooks
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function emitMergeStep(step: SidebarMergeStep): Promise<void> {
|
|
200
|
+
await sidebarMergeHooks?.onStep?.(step)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Resolve the on-disk path the plugin writes to and the TUI reads from.
|
|
205
|
+
*
|
|
206
|
+
* - `ANTIGRAVITY_AUTH_SIDEBAR_STATE_FILE` wins when set (tests, packaged
|
|
207
|
+
* installers, and any user override).
|
|
208
|
+
* - Otherwise fall back to the XDG state directory, mirroring the path
|
|
209
|
+
* conventions used elsewhere in the project.
|
|
210
|
+
*/
|
|
211
|
+
export function getSidebarStateFile(): string {
|
|
212
|
+
const override = process.env[SIDEBAR_STATE_ENV]
|
|
213
|
+
if (override && override.trim().length > 0) return override
|
|
214
|
+
const base = xdgState ?? join(homedir(), '.local', 'state')
|
|
215
|
+
return join(base, SIDEBAR_STATE_DIR, SIDEBAR_STATE_FILENAME)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Read and normalize the sidebar state file. Returns the default state when
|
|
220
|
+
* the file is missing, unreadable, malformed, or schema-incompatible — the TUI
|
|
221
|
+
* must never throw out of `readSidebarState()`, the panel just shows
|
|
222
|
+
* "Awaiting Antigravity state" and the next poll retries.
|
|
223
|
+
*
|
|
224
|
+
* The read is sync on purpose: the TUI polls on a 2-second timer and the file
|
|
225
|
+
* is tiny (a handful of accounts); an async read here would just add race
|
|
226
|
+
* surface area against Solid's reactive render cycle.
|
|
227
|
+
*/
|
|
228
|
+
export function readSidebarState(
|
|
229
|
+
path: string = getSidebarStateFile(),
|
|
230
|
+
): SidebarStateV1 {
|
|
231
|
+
let raw: string
|
|
232
|
+
try {
|
|
233
|
+
raw = readFileSync(path, 'utf-8')
|
|
234
|
+
} catch {
|
|
235
|
+
return { ...DEFAULT_SIDEBAR_STATE }
|
|
236
|
+
}
|
|
237
|
+
let parsed: unknown
|
|
238
|
+
try {
|
|
239
|
+
parsed = JSON.parse(raw)
|
|
240
|
+
} catch {
|
|
241
|
+
return { ...DEFAULT_SIDEBAR_STATE, lastError: 'malformed-json' }
|
|
242
|
+
}
|
|
243
|
+
return normalizeSidebarState(parsed)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function normalizeSidebarState(input: unknown): SidebarStateV1 {
|
|
247
|
+
if (!isObject(input)) {
|
|
248
|
+
return { ...DEFAULT_SIDEBAR_STATE, lastError: 'shape' }
|
|
249
|
+
}
|
|
250
|
+
const record = input as Record<string, unknown>
|
|
251
|
+
const version = record.version
|
|
252
|
+
if (version !== SIDEBAR_STATE_VERSION) {
|
|
253
|
+
return {
|
|
254
|
+
...DEFAULT_SIDEBAR_STATE,
|
|
255
|
+
lastError: `unsupported-version:${stringifySafe(version)}`,
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const accountsRaw = record.accounts
|
|
260
|
+
const accounts = Array.isArray(accountsRaw)
|
|
261
|
+
? accountsRaw
|
|
262
|
+
.map((entry) => normalizeAccount(entry))
|
|
263
|
+
.filter((entry): entry is SidebarAccountState => entry !== null)
|
|
264
|
+
: []
|
|
265
|
+
|
|
266
|
+
const routingRaw = record.activeRouting
|
|
267
|
+
const activeRouting: Record<string, SidebarRoutingEntry> = {}
|
|
268
|
+
if (isObject(routingRaw)) {
|
|
269
|
+
for (const [sessionId, entry] of Object.entries(
|
|
270
|
+
routingRaw as Record<string, unknown>,
|
|
271
|
+
)) {
|
|
272
|
+
const normalized = normalizeRouting(entry)
|
|
273
|
+
if (normalized) activeRouting[sessionId] = normalized
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const checkedAt = toFiniteNumber(record.checkedAt)
|
|
278
|
+
const routingAuthoritative = record.routingAuthoritative === true
|
|
279
|
+
const quotaBackoffUntil = toFiniteNumber(record.quotaBackoffUntil)
|
|
280
|
+
const lastError =
|
|
281
|
+
typeof record.lastError === 'string' ? record.lastError : undefined
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
version: SIDEBAR_STATE_VERSION,
|
|
285
|
+
checkedAt: checkedAt ?? 0,
|
|
286
|
+
accounts,
|
|
287
|
+
activeRouting,
|
|
288
|
+
routingAuthoritative,
|
|
289
|
+
quotaBackoffUntil: quotaBackoffUntil ?? undefined,
|
|
290
|
+
lastError,
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function normalizeAccount(input: unknown): SidebarAccountState | null {
|
|
295
|
+
if (!isObject(input)) return null
|
|
296
|
+
const record = input as Record<string, unknown>
|
|
297
|
+
const id = typeof record.id === 'string' ? record.id : null
|
|
298
|
+
const label = typeof record.label === 'string' ? record.label : null
|
|
299
|
+
if (!id || !label) return null
|
|
300
|
+
const enabled = record.enabled !== false
|
|
301
|
+
const health = clampNumber(toFiniteNumber(record.health), 0, 100)
|
|
302
|
+
const current = record.current === true
|
|
303
|
+
const cooldownUntil = toFiniteNumber(record.cooldownUntil) ?? undefined
|
|
304
|
+
const quotaRaw = record.quota
|
|
305
|
+
const quota: SidebarAccountState['quota'] = {}
|
|
306
|
+
if (isObject(quotaRaw)) {
|
|
307
|
+
for (const key of ['gemini', 'non-gemini'] as const) {
|
|
308
|
+
const entry = (quotaRaw as Record<string, unknown>)[key]
|
|
309
|
+
const normalized = normalizeQuota(entry)
|
|
310
|
+
if (normalized) quota[key] = normalized
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const tier = normalizeTier(record.tier)
|
|
314
|
+
return {
|
|
315
|
+
id,
|
|
316
|
+
label,
|
|
317
|
+
enabled,
|
|
318
|
+
health,
|
|
319
|
+
current,
|
|
320
|
+
cooldownUntil,
|
|
321
|
+
quota,
|
|
322
|
+
...(tier !== undefined ? { tier } : {}),
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function normalizeTier(
|
|
327
|
+
input: unknown,
|
|
328
|
+
): { id: string; paidId?: string; capturedAt: number } | undefined {
|
|
329
|
+
if (!isObject(input)) return undefined
|
|
330
|
+
const record = input as Record<string, unknown>
|
|
331
|
+
const id =
|
|
332
|
+
typeof record.id === 'string' && record.id.length > 0 ? record.id : null
|
|
333
|
+
const capturedAt = toFiniteNumber(record.capturedAt)
|
|
334
|
+
if (!id || capturedAt === null) return undefined
|
|
335
|
+
const paidId =
|
|
336
|
+
typeof record.paidId === 'string' && record.paidId.length > 0
|
|
337
|
+
? record.paidId
|
|
338
|
+
: undefined
|
|
339
|
+
return { id, ...(paidId ? { paidId } : {}), capturedAt }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function normalizeQuota(input: unknown): SidebarQuotaEntry | null {
|
|
343
|
+
if (!isObject(input)) return null
|
|
344
|
+
const record = input as Record<string, unknown>
|
|
345
|
+
const remaining = toFiniteNumber(record.remainingPercent)
|
|
346
|
+
if (remaining === null) return null
|
|
347
|
+
const resetAt = toFiniteNumber(record.resetAt) ?? undefined
|
|
348
|
+
|
|
349
|
+
// Tolerant: deserialize windows array if present; drop malformed entries.
|
|
350
|
+
const windowsRaw = record.windows
|
|
351
|
+
let windows: SidebarQuotaEntry['windows']
|
|
352
|
+
if (Array.isArray(windowsRaw)) {
|
|
353
|
+
const parsed = windowsRaw
|
|
354
|
+
.filter(isObject)
|
|
355
|
+
.map((w) => {
|
|
356
|
+
const win = (w as Record<string, unknown>).window
|
|
357
|
+
const rp = toFiniteNumber(
|
|
358
|
+
(w as Record<string, unknown>).remainingPercent,
|
|
359
|
+
)
|
|
360
|
+
const ra = toFiniteNumber((w as Record<string, unknown>).resetAt)
|
|
361
|
+
if ((win !== 'weekly' && win !== '5h') || rp === null) return null
|
|
362
|
+
return {
|
|
363
|
+
window: win as 'weekly' | '5h',
|
|
364
|
+
remainingPercent: clampNumber(rp, 0, 100),
|
|
365
|
+
resetAt: ra ?? undefined,
|
|
366
|
+
}
|
|
367
|
+
})
|
|
368
|
+
.filter((v): v is NonNullable<typeof v> => v !== null)
|
|
369
|
+
if (parsed.length > 0) windows = parsed
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
remainingPercent: clampNumber(remaining, 0, 100),
|
|
374
|
+
resetAt,
|
|
375
|
+
windows,
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function normalizeRouting(input: unknown): SidebarRoutingEntry | null {
|
|
380
|
+
if (!isObject(input)) return null
|
|
381
|
+
const record = input as Record<string, unknown>
|
|
382
|
+
const accountId =
|
|
383
|
+
typeof record.accountId === 'string' ? record.accountId : null
|
|
384
|
+
const modelFamily = record.modelFamily
|
|
385
|
+
const headerStyle = record.headerStyle
|
|
386
|
+
const strategy = record.strategy
|
|
387
|
+
const updatedAt = toFiniteNumber(record.updatedAt) ?? 0
|
|
388
|
+
if (
|
|
389
|
+
!accountId ||
|
|
390
|
+
(modelFamily !== 'claude' && modelFamily !== 'gemini') ||
|
|
391
|
+
(headerStyle !== 'antigravity' && headerStyle !== 'gemini-cli')
|
|
392
|
+
) {
|
|
393
|
+
return null
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
accountId,
|
|
397
|
+
modelFamily,
|
|
398
|
+
headerStyle,
|
|
399
|
+
strategy:
|
|
400
|
+
strategy === 'sticky' ||
|
|
401
|
+
strategy === 'round-robin' ||
|
|
402
|
+
strategy === 'hybrid'
|
|
403
|
+
? strategy
|
|
404
|
+
: undefined,
|
|
405
|
+
updatedAt,
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
410
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function toFiniteNumber(value: unknown): number | null {
|
|
414
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
415
|
+
if (typeof value === 'string') {
|
|
416
|
+
const parsed = Number(value)
|
|
417
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
418
|
+
}
|
|
419
|
+
return null
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function clampNumber(value: number | null, min: number, max: number): number {
|
|
423
|
+
if (value === null) return min
|
|
424
|
+
if (value < min) return min
|
|
425
|
+
if (value > max) return max
|
|
426
|
+
return value
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function stringifySafe(value: unknown): string {
|
|
430
|
+
if (typeof value === 'string') return value
|
|
431
|
+
try {
|
|
432
|
+
return JSON.stringify(value) ?? 'unknown'
|
|
433
|
+
} catch {
|
|
434
|
+
return 'unknown'
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Ensure the parent directory for the sidebar state file exists. Convenience
|
|
440
|
+
* helper used by writers (tests, plugins) — the TUI itself does not write.
|
|
441
|
+
*/
|
|
442
|
+
export function ensureSidebarStateDir(
|
|
443
|
+
path: string = getSidebarStateFile(),
|
|
444
|
+
): void {
|
|
445
|
+
mkdirSync(dirname(path), { recursive: true, mode: SIDEBAR_STATE_DIR_MODE })
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Drop active-routing entries older than 24h and cap the map at the freshest
|
|
450
|
+
* 100. Pure helper exposed for unit testing; the writers call it on every
|
|
451
|
+
* merge so the on-disk map never grows without bound.
|
|
452
|
+
*/
|
|
453
|
+
export function pruneActiveRouting(
|
|
454
|
+
map: Record<string, SidebarRoutingEntry>,
|
|
455
|
+
now: number,
|
|
456
|
+
): Record<string, SidebarRoutingEntry> {
|
|
457
|
+
const cutoff = now - ACTIVE_ROUTING_MAX_AGE_MS
|
|
458
|
+
const filtered: Array<[string, SidebarRoutingEntry]> = []
|
|
459
|
+
for (const [sessionId, entry] of Object.entries(map)) {
|
|
460
|
+
if (entry.updatedAt >= cutoff) {
|
|
461
|
+
filtered.push([sessionId, entry])
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
filtered.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
|
465
|
+
if (filtered.length <= ACTIVE_ROUTING_MAX_ENTRIES) {
|
|
466
|
+
return Object.fromEntries(filtered)
|
|
467
|
+
}
|
|
468
|
+
return Object.fromEntries(filtered.slice(0, ACTIVE_ROUTING_MAX_ENTRIES))
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Structural input for `redactAccountForSidebar`. Decoupled from the core
|
|
473
|
+
* `ManagedAccount` type so this module never forces a type-level import
|
|
474
|
+
* shape on callers (and so the TUI's compiled tree does not see the core
|
|
475
|
+
* ManagedAccount shape beyond what's actually used).
|
|
476
|
+
*
|
|
477
|
+
* Deliberately excludes `email`: the sidebar/redaction boundary is a PII
|
|
478
|
+
* firewall. Adding `email` here would re-introduce the leak this boundary
|
|
479
|
+
* exists to prevent. Legacy profile labels are accepted but never serialized.
|
|
480
|
+
*/
|
|
481
|
+
export interface SidebarAccountRedactionInput {
|
|
482
|
+
/** Position in the harness-visible account array. */
|
|
483
|
+
index: number
|
|
484
|
+
/** Legacy profile label; deliberately ignored by the redactor. */
|
|
485
|
+
label?: string
|
|
486
|
+
enabled?: boolean
|
|
487
|
+
current?: boolean
|
|
488
|
+
coolingDownUntil?: number
|
|
489
|
+
/** Health score in `[0, 100]`. Defaults to 100 when missing. */
|
|
490
|
+
healthScore?: number
|
|
491
|
+
cachedQuota?: {
|
|
492
|
+
gemini?: {
|
|
493
|
+
remainingFraction?: number
|
|
494
|
+
resetTime?: string
|
|
495
|
+
windows?: Array<{
|
|
496
|
+
window: 'weekly' | '5h'
|
|
497
|
+
remainingFraction: number
|
|
498
|
+
resetTime: string
|
|
499
|
+
}>
|
|
500
|
+
}
|
|
501
|
+
'non-gemini'?: {
|
|
502
|
+
remainingFraction?: number
|
|
503
|
+
resetTime?: string
|
|
504
|
+
windows?: Array<{
|
|
505
|
+
window: 'weekly' | '5h'
|
|
506
|
+
remainingFraction: number
|
|
507
|
+
resetTime: string
|
|
508
|
+
}>
|
|
509
|
+
}
|
|
510
|
+
// Legacy per-pool keys written by older versions of the plugin.
|
|
511
|
+
// Accepted here so the normalizer can map them to the current pool
|
|
512
|
+
// keys at read time without requiring a disk migration.
|
|
513
|
+
[legacyKey: string]:
|
|
514
|
+
| {
|
|
515
|
+
remainingFraction?: number
|
|
516
|
+
resetTime?: string
|
|
517
|
+
modelCount?: number
|
|
518
|
+
windows?: Array<{
|
|
519
|
+
window: 'weekly' | '5h'
|
|
520
|
+
remainingFraction: number
|
|
521
|
+
resetTime: string
|
|
522
|
+
}>
|
|
523
|
+
}
|
|
524
|
+
| undefined
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Opaque identity stamp that was attached to the persisted quota snapshot.
|
|
528
|
+
* Used together with `currentQuotaAccountId` to detect a stale cache that
|
|
529
|
+
* landed on the wrong account after an index shift or token replacement.
|
|
530
|
+
* PII-safe — it is a 16-char hash, not the refresh token itself.
|
|
531
|
+
*/
|
|
532
|
+
cachedQuotaAccountId?: string
|
|
533
|
+
/**
|
|
534
|
+
* Opaque identity stamp for the account that is currently at this index.
|
|
535
|
+
* The redactor drops `cachedQuota` when `cachedQuotaAccountId` is set
|
|
536
|
+
* AND does not match this value, mirroring `toCommandAccountRow` in the
|
|
537
|
+
* command-data service. Omitted in the persisted sidebar state.
|
|
538
|
+
*/
|
|
539
|
+
currentQuotaAccountId?: string
|
|
540
|
+
/**
|
|
541
|
+
* Captured plan tier from `loadCodeAssist`. Absent when unknown. The `id`
|
|
542
|
+
* is the raw upstream string; `capturedAt` is epoch ms. Not PII — tier
|
|
543
|
+
* metadata survives the redaction boundary unchanged.
|
|
544
|
+
*/
|
|
545
|
+
tier?: { id: string; paidId?: string; capturedAt: number }
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Build a `{ id, capturedAt }` tier object from account fields, or `undefined`
|
|
550
|
+
* when either field is absent. Centralised so every producer (index.ts,
|
|
551
|
+
* background-quota-refresh.ts, command-data.ts) uses the same guard and the
|
|
552
|
+
* same shape -- the same pattern shipped as inline literals across three files
|
|
553
|
+
* and produced six missed-field bugs; one helper ends that.
|
|
554
|
+
*/
|
|
555
|
+
export function toCapturedTier(account: {
|
|
556
|
+
capturedTierId?: string
|
|
557
|
+
capturedPaidTierId?: string
|
|
558
|
+
capturedTierAt?: number
|
|
559
|
+
}): { id: string; paidId?: string; capturedAt: number } | undefined {
|
|
560
|
+
return account.capturedTierId !== undefined &&
|
|
561
|
+
account.capturedTierAt !== undefined
|
|
562
|
+
? {
|
|
563
|
+
id: account.capturedTierId,
|
|
564
|
+
...(account.capturedPaidTierId !== undefined
|
|
565
|
+
? { paidId: account.capturedPaidTierId }
|
|
566
|
+
: {}),
|
|
567
|
+
capturedAt: account.capturedTierAt,
|
|
568
|
+
}
|
|
569
|
+
: undefined
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Map pre-pool legacy quota keys to the current two-pool schema at read time.
|
|
574
|
+
*
|
|
575
|
+
* Legacy mappings (empirically settled from burn tests):
|
|
576
|
+
* `gemini-pro` + `gemini-flash` → `gemini`
|
|
577
|
+
* `claude` + `gpt-oss` → `non-gemini`
|
|
578
|
+
*
|
|
579
|
+
* Where multiple legacy keys collapse into one pool the MIN remainingFraction
|
|
580
|
+
* and earliest resetTime are used (most-constrained-first, matching the
|
|
581
|
+
* window-level rule). No `windows` arrays are invented for migrated entries;
|
|
582
|
+
* the single aggregate bar is the correct render for pre-window snapshots.
|
|
583
|
+
*
|
|
584
|
+
* Non-legacy keys that are already canonical (`gemini`, `non-gemini`) are
|
|
585
|
+
* carried through unchanged. The next real quota refresh overwrites any
|
|
586
|
+
* migrated snapshot with authoritative data.
|
|
587
|
+
*/
|
|
588
|
+
export function normalizeLegacyCachedQuota(
|
|
589
|
+
raw: SidebarAccountRedactionInput['cachedQuota'],
|
|
590
|
+
): SidebarAccountRedactionInput['cachedQuota'] {
|
|
591
|
+
if (!raw) return raw
|
|
592
|
+
|
|
593
|
+
// Pool already carries current keys — fast path when no legacy keys present.
|
|
594
|
+
const hasLegacy =
|
|
595
|
+
'gemini-pro' in raw ||
|
|
596
|
+
'gemini-flash' in raw ||
|
|
597
|
+
'claude' in raw ||
|
|
598
|
+
'gpt-oss' in raw
|
|
599
|
+
if (!hasLegacy) return raw
|
|
600
|
+
|
|
601
|
+
type PoolEntry = NonNullable<
|
|
602
|
+
SidebarAccountRedactionInput['cachedQuota']
|
|
603
|
+
>['gemini']
|
|
604
|
+
|
|
605
|
+
// Pick the earlier of two ISO reset timestamps, preferring a defined
|
|
606
|
+
// value over undefined. Extracted so both branches of minFraction use
|
|
607
|
+
// the same merge rather than the previous asymmetry where the a-wins
|
|
608
|
+
// branch dropped b's possibly-earlier resetTime.
|
|
609
|
+
const earlierResetTime = (
|
|
610
|
+
a: string | undefined,
|
|
611
|
+
b: string | undefined,
|
|
612
|
+
): string | undefined => {
|
|
613
|
+
if (!a) return b
|
|
614
|
+
if (!b) return a
|
|
615
|
+
return a < b ? a : b
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const minFraction = (a: PoolEntry, b: PoolEntry): PoolEntry => {
|
|
619
|
+
if (!a) return b
|
|
620
|
+
if (!b) return a
|
|
621
|
+
const fa = a.remainingFraction ?? 1
|
|
622
|
+
const fb = b.remainingFraction ?? 1
|
|
623
|
+
const winner = fa <= fb ? a : b
|
|
624
|
+
const loser = fa <= fb ? b : a
|
|
625
|
+
// Always merge to the earliest resetTime regardless of which pool wins
|
|
626
|
+
// the fraction comparison — an earlier window expiry from the loser
|
|
627
|
+
// pool must not be silently dropped.
|
|
628
|
+
return {
|
|
629
|
+
...winner,
|
|
630
|
+
resetTime: earlierResetTime(winner.resetTime, loser.resetTime),
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const gemini: PoolEntry =
|
|
635
|
+
minFraction(
|
|
636
|
+
raw.gemini,
|
|
637
|
+
minFraction(
|
|
638
|
+
raw['gemini-pro'] as PoolEntry,
|
|
639
|
+
raw['gemini-flash'] as PoolEntry,
|
|
640
|
+
),
|
|
641
|
+
) ?? raw.gemini
|
|
642
|
+
|
|
643
|
+
const nonGemini: PoolEntry =
|
|
644
|
+
minFraction(
|
|
645
|
+
raw['non-gemini'],
|
|
646
|
+
minFraction(raw.claude as PoolEntry, raw['gpt-oss'] as PoolEntry),
|
|
647
|
+
) ?? raw['non-gemini']
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
...(gemini !== undefined ? { gemini } : {}),
|
|
651
|
+
...(nonGemini !== undefined ? { 'non-gemini': nonGemini } : {}),
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Project a raw cachedQuota pool entry into the sidebar-safe shape.
|
|
657
|
+
*
|
|
658
|
+
* Centralized seam — every producer (quota.ts pushSidebarQuotaSnapshot,
|
|
659
|
+
* auth-loader.ts materializer, command-data.ts writeSidebar) routes
|
|
660
|
+
* through here so the `windows` array is never dropped by an inline
|
|
661
|
+
* `{ remainingFraction, resetTime }` literal.
|
|
662
|
+
*
|
|
663
|
+
* Tolerant: returns `undefined` when the source is missing or the
|
|
664
|
+
* `remainingFraction` is not a finite number. Legacy entries without
|
|
665
|
+
* `windows` produce `{ remainingPercent, resetAt }` only — the TUI's
|
|
666
|
+
* legacy path then renders a single bar.
|
|
667
|
+
*/
|
|
668
|
+
export function projectQuotaPoolForSidebar(source: {
|
|
669
|
+
remainingFraction?: number
|
|
670
|
+
resetTime?: string
|
|
671
|
+
windows?: ReadonlyArray<{
|
|
672
|
+
window: 'weekly' | '5h'
|
|
673
|
+
remainingFraction: number
|
|
674
|
+
resetTime: string
|
|
675
|
+
}>
|
|
676
|
+
}): SidebarQuotaEntry | undefined {
|
|
677
|
+
const fraction = source.remainingFraction
|
|
678
|
+
if (typeof fraction !== 'number' || !Number.isFinite(fraction))
|
|
679
|
+
return undefined
|
|
680
|
+
const remainingPercent = clampNumber(Math.round(fraction * 100), 0, 100)
|
|
681
|
+
let resetAt: number | undefined
|
|
682
|
+
if (typeof source.resetTime === 'string' && source.resetTime.length > 0) {
|
|
683
|
+
const parsed = Date.parse(source.resetTime)
|
|
684
|
+
if (Number.isFinite(parsed)) resetAt = parsed
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const windows: SidebarQuotaEntry['windows'] = source.windows?.length
|
|
688
|
+
? source.windows.map((w) => ({
|
|
689
|
+
window: w.window,
|
|
690
|
+
remainingPercent: clampNumber(
|
|
691
|
+
Math.round(w.remainingFraction * 100),
|
|
692
|
+
0,
|
|
693
|
+
100,
|
|
694
|
+
),
|
|
695
|
+
resetAt:
|
|
696
|
+
typeof w.resetTime === 'string' && w.resetTime.length > 0
|
|
697
|
+
? (() => {
|
|
698
|
+
const parsed = Date.parse(w.resetTime)
|
|
699
|
+
return Number.isFinite(parsed) ? parsed : undefined
|
|
700
|
+
})()
|
|
701
|
+
: undefined,
|
|
702
|
+
}))
|
|
703
|
+
: undefined
|
|
704
|
+
|
|
705
|
+
return { remainingPercent, resetAt, windows }
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Returns `true` when an account at `index` is the active account for
|
|
710
|
+
* at least one model family. Two accounts CAN both be current — one
|
|
711
|
+
* serving claude, one serving gemini — so this must not collapse to one.
|
|
712
|
+
*/
|
|
713
|
+
export function isAccountCurrent(
|
|
714
|
+
index: number,
|
|
715
|
+
activeIndexByFamily: { claude: number; gemini: number },
|
|
716
|
+
): boolean {
|
|
717
|
+
return (
|
|
718
|
+
index === activeIndexByFamily.claude || index === activeIndexByFamily.gemini
|
|
719
|
+
)
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Convert a live account snapshot into the redacted shape the TUI renders.
|
|
724
|
+
* The redacted `SidebarAccountState` carries no email, refresh token, access
|
|
725
|
+
* token, project ID, fingerprint, OAuth profile name, or other personal or
|
|
726
|
+
* credential-shaped fields. Display labels are generated ordinal account names.
|
|
727
|
+
*/
|
|
728
|
+
export function redactAccountForSidebar(
|
|
729
|
+
source: SidebarAccountRedactionInput,
|
|
730
|
+
): SidebarAccountState {
|
|
731
|
+
const id = `acct-${source.index}`
|
|
732
|
+
const label = `Account ${source.index + 1}`
|
|
733
|
+
const enabled = source.enabled !== false
|
|
734
|
+
const current = source.current === true
|
|
735
|
+
const cooldownUntil =
|
|
736
|
+
typeof source.coolingDownUntil === 'number' &&
|
|
737
|
+
Number.isFinite(source.coolingDownUntil)
|
|
738
|
+
? source.coolingDownUntil
|
|
739
|
+
: undefined
|
|
740
|
+
const health = clampNumber(
|
|
741
|
+
typeof source.healthScore === 'number' ? source.healthScore : 100,
|
|
742
|
+
0,
|
|
743
|
+
100,
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
const quota: SidebarAccountState['quota'] = {}
|
|
747
|
+
// Stamp mismatch: the persisted quota snapshot was captured for a
|
|
748
|
+
// different account than the one currently at this index. Drop the
|
|
749
|
+
// stale cache rather than rendering the wrong account's quota
|
|
750
|
+
// percentages. Mirrors `toCommandAccountRow` in command-data.
|
|
751
|
+
const staleCachedQuota =
|
|
752
|
+
typeof source.cachedQuotaAccountId === 'string' &&
|
|
753
|
+
typeof source.currentQuotaAccountId === 'string' &&
|
|
754
|
+
source.cachedQuotaAccountId !== source.currentQuotaAccountId
|
|
755
|
+
// Normalize legacy per-pool keys to the current two-pool schema before
|
|
756
|
+
// reading. This is non-destructive: the next real quota refresh will
|
|
757
|
+
// overwrite the migrated snapshot with authoritative data.
|
|
758
|
+
const cached = staleCachedQuota
|
|
759
|
+
? undefined
|
|
760
|
+
: normalizeLegacyCachedQuota(source.cachedQuota)
|
|
761
|
+
if (cached) {
|
|
762
|
+
for (const key of ['gemini', 'non-gemini'] as const) {
|
|
763
|
+
const entry = cached[key]
|
|
764
|
+
if (!entry) continue
|
|
765
|
+
const projected = projectQuotaPoolForSidebar(entry)
|
|
766
|
+
if (projected) quota[key] = projected
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return {
|
|
771
|
+
id,
|
|
772
|
+
label,
|
|
773
|
+
enabled,
|
|
774
|
+
health,
|
|
775
|
+
current,
|
|
776
|
+
cooldownUntil,
|
|
777
|
+
quota,
|
|
778
|
+
// Tier is plan metadata, not PII — it passes the redaction boundary.
|
|
779
|
+
...(source.tier !== undefined ? { tier: source.tier } : {}),
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Build a `SidebarMachineState` from a list of live account snapshots.
|
|
785
|
+
* Convenience for the auth-loader / quota writer call sites that already
|
|
786
|
+
* hold an array of accounts and want to push a single snapshot.
|
|
787
|
+
*/
|
|
788
|
+
export function buildSidebarMachineStateFromAccounts(
|
|
789
|
+
accounts: SidebarAccountRedactionInput[],
|
|
790
|
+
options: {
|
|
791
|
+
checkedAt?: number
|
|
792
|
+
quotaBackoffUntil?: number
|
|
793
|
+
lastError?: string
|
|
794
|
+
routingAuthoritative?: boolean
|
|
795
|
+
} = {},
|
|
796
|
+
): SidebarMachineState {
|
|
797
|
+
return {
|
|
798
|
+
checkedAt: options.checkedAt ?? Date.now(),
|
|
799
|
+
accounts: accounts.map((entry) => redactAccountForSidebar(entry)),
|
|
800
|
+
quotaBackoffUntil: options.quotaBackoffUntil,
|
|
801
|
+
lastError: options.lastError,
|
|
802
|
+
routingAuthoritative: options.routingAuthoritative,
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
interface SidebarStateWriteOptions {
|
|
807
|
+
stateFile?: string
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
let sidebarWriteChain: Promise<void> = Promise.resolve()
|
|
811
|
+
|
|
812
|
+
function enqueueSidebarWrite<T>(work: () => Promise<T>): Promise<T> {
|
|
813
|
+
// Always run `work` regardless of whether the prior link resolved or
|
|
814
|
+
// rejected — a failed write must not poison the chain for the next caller.
|
|
815
|
+
const next = sidebarWriteChain.then(work, work)
|
|
816
|
+
sidebarWriteChain = next.then(
|
|
817
|
+
() => undefined,
|
|
818
|
+
() => undefined,
|
|
819
|
+
)
|
|
820
|
+
return next
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Wait for any in-flight sidebar state write to drain. The plugin lifecycle
|
|
825
|
+
* calls this during `dispose()` so the file logger and RPC server are torn
|
|
826
|
+
* down only after every queued write has either landed or thrown.
|
|
827
|
+
*/
|
|
828
|
+
export function drainSidebarWrites(): Promise<void> {
|
|
829
|
+
return sidebarWriteChain
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async function sleep(ms: number): Promise<void> {
|
|
833
|
+
await new Promise((resolve) => setTimeout(resolve, ms))
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async function acquireSidebarLockWithRetry(
|
|
837
|
+
stateFile: string,
|
|
838
|
+
): Promise<FencedFileLock> {
|
|
839
|
+
const start = Date.now()
|
|
840
|
+
let attempt = 0
|
|
841
|
+
while (true) {
|
|
842
|
+
await emitMergeStep('await-lock')
|
|
843
|
+
const lock = await acquireFencedFileLock({
|
|
844
|
+
path: stateFile,
|
|
845
|
+
name: SIDEBAR_LOCK_NAME,
|
|
846
|
+
ttlMs: SIDEBAR_LOCK_TTL_MS,
|
|
847
|
+
renew: true,
|
|
848
|
+
})
|
|
849
|
+
if (lock) {
|
|
850
|
+
await emitMergeStep('acquired-lock')
|
|
851
|
+
return lock
|
|
852
|
+
}
|
|
853
|
+
const elapsed = Date.now() - start
|
|
854
|
+
if (elapsed >= SIDEBAR_LOCK_TIMEOUT_MS) {
|
|
855
|
+
throw new SidebarStateLockContentionError(
|
|
856
|
+
stateFile,
|
|
857
|
+
SIDEBAR_LOCK_TIMEOUT_MS,
|
|
858
|
+
)
|
|
859
|
+
}
|
|
860
|
+
const backoff = Math.min(
|
|
861
|
+
SIDEBAR_LOCK_RETRY_CAP_MS,
|
|
862
|
+
SIDEBAR_LOCK_RETRY_BASE_MS + attempt * 10,
|
|
863
|
+
)
|
|
864
|
+
const jitter = Math.floor(Math.random() * SIDEBAR_LOCK_JITTER_MS)
|
|
865
|
+
await sleep(backoff + jitter)
|
|
866
|
+
attempt++
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function performSidebarWrite(
|
|
871
|
+
stateFile: string,
|
|
872
|
+
merge: (existing: SidebarStateV1) => SidebarStateV1,
|
|
873
|
+
): Promise<void> {
|
|
874
|
+
await enqueueSidebarWrite(async () => {
|
|
875
|
+
ensureSidebarStateDir(stateFile)
|
|
876
|
+
const lock = await acquireSidebarLockWithRetry(stateFile)
|
|
877
|
+
try {
|
|
878
|
+
await lock.assertOwned()
|
|
879
|
+
const existing = readSidebarState(stateFile)
|
|
880
|
+
await emitMergeStep('read-state')
|
|
881
|
+
const merged = merge(existing)
|
|
882
|
+
await emitMergeStep('merged-state')
|
|
883
|
+
await writeJsonAtomic(stateFile, merged)
|
|
884
|
+
// `writeJsonAtomic` stages a tmp file with mode 0o600 and renames onto
|
|
885
|
+
// the target; POSIX rename replaces the inode so the new file inherits
|
|
886
|
+
// the staged mode bits. Windows ignores POSIX modes so the assertion
|
|
887
|
+
// in tests is best-effort there.
|
|
888
|
+
await emitMergeStep('wrote-state')
|
|
889
|
+
} finally {
|
|
890
|
+
await lock.release().catch(() => {})
|
|
891
|
+
}
|
|
892
|
+
})
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Merge a new machine-state payload against the on-disk state.
|
|
897
|
+
*
|
|
898
|
+
* Stale writes (new `checkedAt` < existing) are dropped — only newer/equal
|
|
899
|
+
* `checkedAt` may replace machine fields. The merge preserves
|
|
900
|
+
* `routingAuthoritative: true` once it is true (sticky-true), keeps the
|
|
901
|
+
* existing `activeRouting` intact (routing is merged via its own writer),
|
|
902
|
+
* and prunes any expired routing entries as a side effect.
|
|
903
|
+
*/
|
|
904
|
+
function mergeMachineState(
|
|
905
|
+
existing: SidebarStateV1,
|
|
906
|
+
next: SidebarMachineState,
|
|
907
|
+
): SidebarStateV1 {
|
|
908
|
+
if (next.checkedAt < existing.checkedAt) {
|
|
909
|
+
return {
|
|
910
|
+
...existing,
|
|
911
|
+
activeRouting: pruneActiveRouting(existing.activeRouting, Date.now()),
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return {
|
|
915
|
+
version: SIDEBAR_STATE_VERSION,
|
|
916
|
+
checkedAt: next.checkedAt,
|
|
917
|
+
accounts: next.accounts,
|
|
918
|
+
quotaBackoffUntil: next.quotaBackoffUntil,
|
|
919
|
+
lastError: next.lastError,
|
|
920
|
+
routingAuthoritative:
|
|
921
|
+
existing.routingAuthoritative === true ||
|
|
922
|
+
next.routingAuthoritative === true,
|
|
923
|
+
// Symmetric with the stale-write branch: every machine-write merge
|
|
924
|
+
// re-prunes activeRouting so a long-running TUI session eventually
|
|
925
|
+
// drops dead routes even when no fresh routing upsert lands. Cheap
|
|
926
|
+
// (a single Object.entries + sort over ≤100 entries) and bounded.
|
|
927
|
+
activeRouting: pruneActiveRouting(existing.activeRouting, Date.now()),
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Upsert a single session's active routing entry. The fetch interceptor calls
|
|
933
|
+
* this with `authoritative: true` after every final route selection; the
|
|
934
|
+
* `accountId`/`modelFamily`/`headerStyle` fields are already redacted by the
|
|
935
|
+
* caller (the writer never sees token or project fields).
|
|
936
|
+
*/
|
|
937
|
+
export async function upsertSidebarActiveRouting(
|
|
938
|
+
sessionId: string,
|
|
939
|
+
entry: SidebarRoutingEntry,
|
|
940
|
+
options: SidebarStateWriteOptions & { authoritative?: boolean } = {},
|
|
941
|
+
): Promise<void> {
|
|
942
|
+
const stateFile = options.stateFile ?? getSidebarStateFile()
|
|
943
|
+
await performSidebarWrite(stateFile, (existing) => {
|
|
944
|
+
const activeRouting = { ...existing.activeRouting, [sessionId]: entry }
|
|
945
|
+
return {
|
|
946
|
+
...existing,
|
|
947
|
+
routingAuthoritative:
|
|
948
|
+
options.authoritative === true ? true : existing.routingAuthoritative,
|
|
949
|
+
activeRouting: pruneActiveRouting(activeRouting, Date.now()),
|
|
950
|
+
}
|
|
951
|
+
})
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Remove one session's active routing entry. The event handler calls this
|
|
956
|
+
* when a session is deleted so the sidebar does not retain dead routes.
|
|
957
|
+
*/
|
|
958
|
+
export async function removeSidebarActiveRouting(
|
|
959
|
+
sessionId: string,
|
|
960
|
+
options: SidebarStateWriteOptions = {},
|
|
961
|
+
): Promise<void> {
|
|
962
|
+
const stateFile = options.stateFile ?? getSidebarStateFile()
|
|
963
|
+
await performSidebarWrite(stateFile, (existing) => {
|
|
964
|
+
if (!(sessionId in existing.activeRouting)) {
|
|
965
|
+
return existing
|
|
966
|
+
}
|
|
967
|
+
const activeRouting = { ...existing.activeRouting }
|
|
968
|
+
delete activeRouting[sessionId]
|
|
969
|
+
return {
|
|
970
|
+
...existing,
|
|
971
|
+
activeRouting,
|
|
972
|
+
}
|
|
973
|
+
})
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Write a new machine-state snapshot. The fetch interceptor and quota
|
|
978
|
+
* manager call this after each refresh; auth-loader calls it after the
|
|
979
|
+
* account pool is materialized.
|
|
980
|
+
*/
|
|
981
|
+
export async function setSidebarMachineState(
|
|
982
|
+
next: SidebarMachineState,
|
|
983
|
+
options: SidebarStateWriteOptions = {},
|
|
984
|
+
): Promise<void> {
|
|
985
|
+
const stateFile = options.stateFile ?? getSidebarStateFile()
|
|
986
|
+
await performSidebarWrite(stateFile, (existing) =>
|
|
987
|
+
mergeMachineState(existing, next),
|
|
988
|
+
)
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
void SIDEBAR_STATE_FILE_MODE
|