vibe-usage-cli 0.0.0-stage → 0.1.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.
Files changed (114) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +275 -2
  3. package/build/cli.js +197 -0
  4. package/build/core/claude-processes.d.ts +51 -0
  5. package/build/core/claude-processes.js +288 -0
  6. package/build/core/claude-profile.d.ts +2 -0
  7. package/build/core/claude-profile.js +146 -0
  8. package/build/core/claude-renew.d.ts +12 -0
  9. package/build/core/claude-renew.js +79 -0
  10. package/build/core/continuity.d.ts +90 -0
  11. package/build/core/continuity.js +1244 -0
  12. package/build/core/diagnostics.d.ts +25 -0
  13. package/build/core/diagnostics.js +229 -0
  14. package/build/core/environment.d.ts +12 -0
  15. package/build/core/environment.js +25 -0
  16. package/build/core/errors.d.ts +23 -0
  17. package/build/core/errors.js +48 -0
  18. package/build/core/files.d.ts +20 -0
  19. package/build/core/files.js +136 -0
  20. package/build/core/glance.d.ts +25 -0
  21. package/build/core/glance.js +107 -0
  22. package/build/core/handover.d.ts +61 -0
  23. package/build/core/handover.js +333 -0
  24. package/build/core/history.d.ts +19 -0
  25. package/build/core/history.js +134 -0
  26. package/build/core/launch.d.ts +78 -0
  27. package/build/core/launch.js +256 -0
  28. package/build/core/measurements.d.ts +11 -0
  29. package/build/core/measurements.js +75 -0
  30. package/build/core/model-families.d.ts +12 -0
  31. package/build/core/model-families.js +61 -0
  32. package/build/core/model.d.ts +32 -0
  33. package/build/core/model.js +86 -0
  34. package/build/core/models.d.ts +25 -0
  35. package/build/core/models.js +164 -0
  36. package/build/core/redact.d.ts +2 -0
  37. package/build/core/redact.js +64 -0
  38. package/build/core/selection.d.ts +65 -0
  39. package/build/core/selection.js +173 -0
  40. package/build/core/service.d.ts +35 -0
  41. package/build/core/service.js +484 -0
  42. package/build/core/sessions.d.ts +87 -0
  43. package/build/core/sessions.js +868 -0
  44. package/build/core/settings.d.ts +16 -0
  45. package/build/core/settings.js +377 -0
  46. package/build/core/tray-icon.d.ts +5 -0
  47. package/build/core/tray-icon.js +91 -0
  48. package/build/core/updater.d.ts +29 -0
  49. package/build/core/updater.js +88 -0
  50. package/build/core/updates.d.ts +5 -0
  51. package/build/core/updates.js +29 -0
  52. package/build/launchers/discover-antigravity.ps1 +68 -0
  53. package/build/launchers/terminal.ps1 +78 -0
  54. package/build/main/main.js +587 -0
  55. package/build/providers/antigravity.d.ts +48 -0
  56. package/build/providers/antigravity.js +463 -0
  57. package/build/providers/claude-keychain.d.ts +13 -0
  58. package/build/providers/claude-keychain.js +42 -0
  59. package/build/providers/claude.d.ts +33 -0
  60. package/build/providers/claude.js +218 -0
  61. package/build/providers/codex.d.ts +21 -0
  62. package/build/providers/codex.js +221 -0
  63. package/build/providers/common.d.ts +63 -0
  64. package/build/providers/common.js +294 -0
  65. package/build/providers/cursor.d.ts +14 -0
  66. package/build/providers/cursor.js +141 -0
  67. package/build/providers/profiles.d.ts +10 -0
  68. package/build/providers/profiles.js +69 -0
  69. package/build/providers/tls-trust.d.ts +7 -0
  70. package/build/providers/tls-trust.js +29 -0
  71. package/build/providers/types.d.ts +19 -0
  72. package/build/providers/types.js +1 -0
  73. package/build/shared/account-state.d.ts +19 -0
  74. package/build/shared/account-state.js +75 -0
  75. package/build/shared/catalog.d.ts +85 -0
  76. package/build/shared/catalog.js +125 -0
  77. package/build/shared/format.d.ts +40 -0
  78. package/build/shared/format.js +134 -0
  79. package/build/shared/i18n/index.d.ts +12 -0
  80. package/build/shared/i18n/index.js +40 -0
  81. package/build/shared/i18n/locales/de.d.ts +2 -0
  82. package/build/shared/i18n/locales/de.js +328 -0
  83. package/build/shared/i18n/locales/en.d.ts +328 -0
  84. package/build/shared/i18n/locales/en.js +330 -0
  85. package/build/shared/i18n/locales/es.d.ts +2 -0
  86. package/build/shared/i18n/locales/es.js +328 -0
  87. package/build/shared/i18n/locales/fr.d.ts +2 -0
  88. package/build/shared/i18n/locales/fr.js +328 -0
  89. package/build/shared/i18n/locales/it.d.ts +2 -0
  90. package/build/shared/i18n/locales/it.js +327 -0
  91. package/build/shared/i18n/locales/ja.d.ts +2 -0
  92. package/build/shared/i18n/locales/ja.js +322 -0
  93. package/build/shared/i18n/locales/ko.d.ts +2 -0
  94. package/build/shared/i18n/locales/ko.js +322 -0
  95. package/build/shared/i18n/locales/nl.d.ts +2 -0
  96. package/build/shared/i18n/locales/nl.js +327 -0
  97. package/build/shared/i18n/locales/pl.d.ts +2 -0
  98. package/build/shared/i18n/locales/pl.js +332 -0
  99. package/build/shared/i18n/locales/pt.d.ts +2 -0
  100. package/build/shared/i18n/locales/pt.js +328 -0
  101. package/build/shared/i18n/locales/ru.d.ts +2 -0
  102. package/build/shared/i18n/locales/ru.js +331 -0
  103. package/build/shared/i18n/locales/tr.d.ts +2 -0
  104. package/build/shared/i18n/locales/tr.js +322 -0
  105. package/build/shared/i18n/locales/zh.d.ts +2 -0
  106. package/build/shared/i18n/locales/zh.js +322 -0
  107. package/build/shared/i18n/messages.d.ts +10 -0
  108. package/build/shared/i18n/messages.js +1 -0
  109. package/build/shared/ipc.d.ts +138 -0
  110. package/build/shared/ipc.js +2 -0
  111. package/build/shared/tsconfig.tsbuildinfo +1 -0
  112. package/build/shared/types.d.ts +205 -0
  113. package/build/shared/types.js +1 -0
  114. package/package.json +144 -4
@@ -0,0 +1,25 @@
1
+ import { type ProviderId } from '../shared/catalog.ts';
2
+ import type { Diagnostics, ProviderStatus, Remedy, Snapshot, StatusCode } from '../shared/types.ts';
3
+ export type { AccountDiagnostics, Diagnostics, ProviderDiagnostics, Remedy } from '../shared/types.ts';
4
+ import type { Environment } from './environment.ts';
5
+ export declare const REMEDY_TEXT: Readonly<Record<Remedy, string>>;
6
+ export declare const remedyFor: (status: ProviderStatus, code: StatusCode | null) => Remedy | null;
7
+ export interface ToolProbe {
8
+ installed(providerId: ProviderId): Promise<boolean | null>;
9
+ }
10
+ export declare function collectDiagnostics(snapshot: Snapshot, probe: ToolProbe, now: number): Promise<Diagnostics>;
11
+ export interface ToolProbeOptions {
12
+ homeDir?: string;
13
+ platform?: NodeJS.Platform;
14
+ env?: Environment;
15
+ }
16
+ export declare function createToolProbe({ homeDir, platform, env }?: ToolProbeOptions): ToolProbe;
17
+ export interface ReportContext {
18
+ appVersion: string;
19
+ platform: string;
20
+ arch: string;
21
+ generatedAt: number;
22
+ homeDir?: string;
23
+ }
24
+ export declare function redactReport(text: string, homeDir?: string): string;
25
+ export declare function renderDiagnosticsReport(diagnostics: Diagnostics, context: ReportContext): string;
@@ -0,0 +1,229 @@
1
+ // What the diagnostics screen and its copyable report show: whether each tool is present,
2
+ // the state of its accounts, how fresh their readings are, and a concrete next step.
3
+ // Accounts appear only as ordinals; nothing here carries a label, address, path or token.
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { findBinary, TOOL_INSTALLS, windowsAppPath } from "../providers/common.js";
7
+ import { APP_NAME, DURATION_MS, PROVIDER_IDS, providerById } from "../shared/catalog.js";
8
+ import { isDirectory, isFile } from "./files.js";
9
+ // A reading older than this many refresh intervals is shown as stale.
10
+ const STALE_INTERVALS = 3;
11
+ // English wording for the report, which is meant to be pasted into a GitHub issue.
12
+ export const REMEDY_TEXT = {
13
+ remedyInstallTool: 'Install the tool, then refresh.',
14
+ remedyInstallCli: "Install the tool's command-line interface, or check where it is installed.",
15
+ remedySignIn: 'Sign in again with the tool, then refresh.',
16
+ remedyOpenApp: 'Open the app, then refresh.',
17
+ remedyUnlockKeychain: 'Unlock the Keychain and allow access when asked, then refresh.',
18
+ remedyWaitRateLimit: 'Wait a few minutes; the provider is limiting requests.',
19
+ remedyCheckNetwork: 'Check the internet connection, proxy or VPN, then refresh.',
20
+ remedyCheckCertificates: "Check the system's certificate trust and any TLS-inspecting proxy, then refresh.",
21
+ remedyNotSupported: 'Reading this account is not supported on this system yet.',
22
+ remedyAddAccount: 'Sign in with the tool or add an account in Settings.',
23
+ remedyWaitForRefresh: 'Wait for the current reading to finish.',
24
+ remedyUpdateTool: 'Update the tool to its latest version, then refresh.',
25
+ remedyRetryLater: 'Refresh again later; if it keeps failing, report an issue with this report.',
26
+ remedyReportIssue: 'This looks like a Vibe Usage bug; report an issue with this report.'
27
+ };
28
+ // Every status and message code the app knows. Doubles as the allow-list of what may be
29
+ // reported: anything else in a snapshot is treated as unknown.
30
+ const REMEDIES = {
31
+ pending: 'remedyWaitForRefresh',
32
+ ok: null,
33
+ stale: 'remedyRetryLater',
34
+ no_limits_reported: null,
35
+ unavailable: 'remedyRetryLater',
36
+ no_accounts: 'remedyAddAccount',
37
+ auth_missing: 'remedySignIn',
38
+ auth_expired: 'remedySignIn',
39
+ auth_invalid: 'remedySignIn',
40
+ auth_rejected: 'remedySignIn',
41
+ rate_limited: 'remedyWaitRateLimit',
42
+ transport_error: 'remedyCheckNetwork',
43
+ request_timeout: 'remedyCheckNetwork',
44
+ tls_certificate_error: 'remedyCheckCertificates',
45
+ trust_store_unavailable: 'remedyCheckCertificates',
46
+ response_too_large: 'remedyUpdateTool',
47
+ invalid_response: 'remedyUpdateTool',
48
+ rpc_error: 'remedyUpdateTool',
49
+ http_rejected: 'remedyRetryLater',
50
+ endpoint_not_allowed: 'remedyReportIssue',
51
+ binary_missing: 'remedyInstallCli',
52
+ invalid_binary: 'remedyInstallCli',
53
+ process_failed: 'remedyRetryLater',
54
+ process_timeout: 'remedyRetryLater',
55
+ rpc_timeout: 'remedyRetryLater',
56
+ server_closed: 'remedyRetryLater',
57
+ app_closed: 'remedyOpenApp',
58
+ local_service_unavailable: 'remedyOpenApp',
59
+ unsupported_platform: 'remedyNotSupported',
60
+ unsupported_profile: 'remedyNotSupported',
61
+ keychain_unavailable: 'remedyUnlockKeychain',
62
+ provider_failed: 'remedyRetryLater'
63
+ };
64
+ const isCode = (value) => typeof value === 'string' && Object.hasOwn(REMEDIES, value);
65
+ const accountStatus = (value) => isCode(value) && value !== 'unavailable' && value !== 'no_accounts' ? value : 'provider_failed';
66
+ const providerStatus = (value) => (isCode(value) && value !== 'no_accounts' ? value : 'unavailable');
67
+ const messageCode = (value) => isCode(value) && value !== 'pending' && value !== 'ok' && value !== 'stale' && value !== 'unavailable' ? value : null;
68
+ // A stale reading keeps the failure that made it stale in its message code; that failure is
69
+ // what the user can act on.
70
+ export const remedyFor = (status, code) => REMEDIES[code ?? status];
71
+ function freshness(fetchedAt, status, staleAfterMs, now) {
72
+ const time = typeof fetchedAt === 'string' ? Date.parse(fetchedAt) : NaN;
73
+ // A reading stamped in the future (a clock change) counts as just taken.
74
+ const ageMs = Number.isFinite(time) ? Math.max(0, now - time) : null;
75
+ return { ageMs, stale: status === 'stale' || (ageMs !== null && ageMs > staleAfterMs) };
76
+ }
77
+ // A failing tool usually takes its state from its accounts, whose remedy is more specific.
78
+ function providerRemedy(installed, status, code, accounts) {
79
+ // A tool the probe misses but that still reads fine is present in a form the probe does not know.
80
+ if (installed === false && !accounts.some(a => a.status === 'ok'))
81
+ return 'remedyInstallTool';
82
+ if (status === 'ok' || code)
83
+ return remedyFor(status, code);
84
+ return accounts.find(a => a.remedy)?.remedy ?? remedyFor(status, null);
85
+ }
86
+ async function probeSafely(probe, id) {
87
+ try {
88
+ return await probe.installed(id);
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ export async function collectDiagnostics(snapshot, probe, now) {
95
+ const { settings } = snapshot;
96
+ const staleAfterMs = STALE_INTERVALS * settings.intervalSeconds * 1000;
97
+ const installed = await Promise.all(PROVIDER_IDS.map(id => probeSafely(probe, id)));
98
+ const providers = PROVIDER_IDS.map((id, index) => {
99
+ const state = snapshot.providers.find(provider => provider.id === id);
100
+ const measured = state?.accounts ?? [];
101
+ // Settings list every account, disabled ones too; the service measures only enabled ones.
102
+ const configured = settings.accounts.filter(a => a.providerId === id);
103
+ const accounts = [
104
+ ...configured.map(a => measured.find(m => m.id === a.id) ?? { ...a, status: 'pending' }),
105
+ ...measured.filter(m => !configured.some(a => a.id === m.id))
106
+ ];
107
+ const rows = accounts.map((a, i) => {
108
+ const status = accountStatus(a.status);
109
+ const code = messageCode(a.messageCode);
110
+ return {
111
+ ordinal: i + 1,
112
+ enabled: a.enabled,
113
+ primary: a.primary,
114
+ status,
115
+ messageCode: code,
116
+ ...freshness(a.fetchedAt, status, staleAfterMs, now),
117
+ // A disabled account is the user's choice, not a fault.
118
+ remedy: a.enabled ? remedyFor(status, code) : null
119
+ };
120
+ });
121
+ const status = providerStatus(state?.status);
122
+ const code = messageCode(state?.messageCode);
123
+ const isInstalled = installed[index] ?? null;
124
+ return {
125
+ id,
126
+ installed: isInstalled,
127
+ status,
128
+ messageCode: code,
129
+ accountCount: rows.length,
130
+ enabledCount: rows.filter(a => a.enabled).length,
131
+ ...freshness(state?.fetchedAt, status, staleAfterMs, now),
132
+ remedy: providerRemedy(isInstalled, status, code, rows),
133
+ accounts: rows
134
+ };
135
+ });
136
+ return { generatedAt: now, intervalSeconds: settings.intervalSeconds, refreshing: snapshot.refreshing, providers };
137
+ }
138
+ // Looks tools up the way launches do (TOOL_INSTALLS), but only looks for files: it never starts
139
+ // a tool, which could ask to sign in or touch credentials.
140
+ export function createToolProbe({ homeDir = os.homedir(), platform = process.platform, env = process.env } = {}) {
141
+ // null when this platform has no known app location.
142
+ async function hasApp(app) {
143
+ if (platform === 'win32')
144
+ return isFile(windowsAppPath(app, env, homeDir));
145
+ if (platform === 'darwin') {
146
+ const bundles = ['/Applications', path.join(homeDir, 'Applications')].map(dir => path.join(dir, `${app.mac}.app`));
147
+ return (await Promise.all(bundles.map(isDirectory))).some(Boolean);
148
+ }
149
+ return null;
150
+ }
151
+ async function hasBinary(name) {
152
+ return findBinary(name, { env, home: homeDir, platform }).then(() => true, () => false);
153
+ }
154
+ return {
155
+ async installed(providerId) {
156
+ const { binaries, app } = TOOL_INSTALLS[providerId];
157
+ const found = await Promise.all([...binaries.map(hasBinary), app ? hasApp(app) : false]);
158
+ if (found.includes(true))
159
+ return true;
160
+ return found.includes(null) ? null : false;
161
+ }
162
+ };
163
+ }
164
+ const yesNo = (value) => (value === null ? 'unknown' : value ? 'yes' : 'no');
165
+ const safeToken = (value, pattern) => (pattern.test(value) ? value : 'unknown');
166
+ const VERSION_PATTERN = /^[0-9A-Za-z.+-]{1,40}$/;
167
+ const SYSTEM_PATTERN = /^[a-z0-9_]{1,20}$/i;
168
+ function formatAge(ageMs) {
169
+ if (ageMs === null)
170
+ return 'never';
171
+ const minutes = Math.floor(ageMs / DURATION_MS.MINUTE);
172
+ if (minutes < 1)
173
+ return 'under 1 min ago';
174
+ if (minutes < 60)
175
+ return `${minutes} min ago`;
176
+ const hours = Math.floor(minutes / 60);
177
+ return hours < 48 ? `${hours} h ${minutes % 60} min ago` : `${Math.floor(hours / 24)} d ago`;
178
+ }
179
+ const formatStatus = (status, code) => (code && code !== status ? `${status} (${code})` : status);
180
+ const formatRemedy = (remedy) => (remedy ? REMEDY_TEXT[remedy] : 'none');
181
+ // Anything with an @, absolute Windows, UNC and POSIX paths, and home-relative paths.
182
+ const REDACTIONS = [/[^\s|`]*@[^\s|`]*/g, /(?:\b[A-Za-z]:[\\/]|\\\\)[^\s|`]*/g, /(?<![\w.-])~?\/[^\s|`]*/g];
183
+ const REDACTED = '[redacted]';
184
+ // A last pass over the finished report, so a mistake upstream cannot leak identity.
185
+ export function redactReport(text, homeDir) {
186
+ let result = text;
187
+ if (homeDir) {
188
+ // win32 parsing accepts both separators, whatever the host.
189
+ const user = path.win32.basename(homeDir);
190
+ const variants = new Set([homeDir, homeDir.replaceAll('\\', '/'), homeDir.replaceAll('/', '\\')]);
191
+ for (const variant of variants)
192
+ result = result.replace(new RegExp(RegExp.escape(variant), 'gi'), REDACTED);
193
+ // Short names would also match ordinary words in the report.
194
+ if (user.length >= 3)
195
+ result = result.replace(new RegExp(`\\b${RegExp.escape(user)}\\b`, 'gi'), REDACTED);
196
+ }
197
+ for (const pattern of REDACTIONS)
198
+ result = result.replace(pattern, REDACTED);
199
+ return result;
200
+ }
201
+ // Plain Markdown in English, to paste into a GitHub issue: codes, counts, ages and ordinals only.
202
+ export function renderDiagnosticsReport(diagnostics, context) {
203
+ const lines = [
204
+ `## ${APP_NAME} diagnostics`,
205
+ '',
206
+ `- Version: ${safeToken(context.appVersion, VERSION_PATTERN)}`,
207
+ `- OS: ${safeToken(context.platform, SYSTEM_PATTERN)} ${safeToken(context.arch, SYSTEM_PATTERN)}`,
208
+ `- Generated: ${Number.isFinite(context.generatedAt) ? new Date(context.generatedAt).toISOString() : 'unknown'}`,
209
+ `- Refresh interval: ${diagnostics.intervalSeconds} s`,
210
+ `- Refreshing: ${yesNo(diagnostics.refreshing)}`
211
+ ];
212
+ for (const provider of diagnostics.providers) {
213
+ lines.push('', `### ${providerById(provider.id).name}`, '', `- Installed: ${yesNo(provider.installed)}`, `- Status: ${formatStatus(provider.status, provider.messageCode)}`, `- Accounts: ${provider.accountCount} configured, ${provider.enabledCount} enabled`, `- Last measured: ${formatAge(provider.ageMs)}${provider.stale ? ', stale' : ''}`, `- Next step: ${formatRemedy(provider.remedy)}`);
214
+ if (!provider.accounts.length)
215
+ continue;
216
+ lines.push('', '| Account | Enabled | Primary | Status | Last measured | Stale | Next step |', '| --- | --- | --- | --- | --- | --- | --- |', ...provider.accounts
217
+ .map(a => [
218
+ `Account ${a.ordinal}`,
219
+ yesNo(a.enabled),
220
+ yesNo(a.primary),
221
+ formatStatus(a.status, a.messageCode),
222
+ formatAge(a.ageMs),
223
+ yesNo(a.stale),
224
+ formatRemedy(a.remedy)
225
+ ].join(' | '))
226
+ .map(row => `| ${row} |`));
227
+ }
228
+ return `${redactReport(lines.join('\n'), context.homeDir)}\n`;
229
+ }
@@ -0,0 +1,12 @@
1
+ export type Environment = Readonly<Record<string, string | undefined>>;
2
+ export interface EnvironmentRequest {
3
+ set?: Readonly<Record<string, string>>;
4
+ unset?: readonly string[];
5
+ }
6
+ export interface EnvironmentChanges {
7
+ set: Readonly<Record<string, string>>;
8
+ unset: readonly string[];
9
+ }
10
+ export declare function environmentChanges({ set, unset }?: EnvironmentRequest): EnvironmentChanges;
11
+ export declare function applyEnvironment(changes: EnvironmentChanges, source?: Environment, platform?: NodeJS.Platform): Record<string, string>;
12
+ export declare function cleanEnvironment(set?: Readonly<Record<string, string>>, platform?: NodeJS.Platform, source?: Environment): Record<string, string>;
@@ -0,0 +1,25 @@
1
+ // Environments for processes the app starts. Credential overrides are always removed so that
2
+ // a tool uses the account the user picked, never one injected by the parent environment, and so
3
+ // are Claude Code's session markers, so a tool started from it is never taken for a child session.
4
+ import { CLAUDE_SESSION_ENV_KEYS, CREDENTIAL_ENV_KEYS } from "../shared/catalog.js";
5
+ import { AppError } from "./errors.js";
6
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
7
+ export function environmentChanges({ set = {}, unset = [] } = {}) {
8
+ if ([...Object.keys(set), ...unset].some(name => !ENV_NAME.test(name)))
9
+ throw new AppError('invalid_argument');
10
+ return {
11
+ set,
12
+ unset: [...new Set([...CREDENTIAL_ENV_KEYS, ...CLAUDE_SESSION_ENV_KEYS, ...unset])].filter(name => !Object.hasOwn(set, name))
13
+ };
14
+ }
15
+ // A copy of `source` with the changes applied (names are case-insensitive on Windows).
16
+ export function applyEnvironment(changes, source = process.env, platform = process.platform) {
17
+ const normalize = platform === 'win32' ? (name) => name.toLowerCase() : (name) => name;
18
+ const removed = new Set([...changes.unset, ...Object.keys(changes.set)].map(normalize));
19
+ const kept = Object.entries(source).filter((entry) => entry[1] !== undefined && !removed.has(normalize(entry[0])));
20
+ return { ...Object.fromEntries(kept), ...changes.set };
21
+ }
22
+ // `source` without credential overrides, plus `set`.
23
+ export function cleanEnvironment(set = {}, platform = process.platform, source = process.env) {
24
+ return applyEnvironment(environmentChanges({ set }), source, platform);
25
+ }
@@ -0,0 +1,23 @@
1
+ import type { ErrorCode, ErrorPayload, ProviderErrorCode } from '../shared/types.ts';
2
+ type Params = Readonly<Record<string, string>>;
3
+ export declare class AppError extends Error {
4
+ readonly code: ErrorCode;
5
+ readonly params: Params;
6
+ constructor(code: ErrorCode, params?: Params, options?: ErrorOptions);
7
+ }
8
+ export declare const systemErrorCode: (error: unknown) => unknown;
9
+ export declare class ProviderError extends AppError {
10
+ readonly code: ProviderErrorCode;
11
+ readonly retryAfterSeconds: number | undefined;
12
+ constructor(code: ProviderErrorCode, { retryAfterSeconds, cause }?: {
13
+ retryAfterSeconds?: number;
14
+ cause?: unknown;
15
+ });
16
+ }
17
+ export declare class InvalidSettingsError extends AppError {
18
+ readonly reason: string;
19
+ constructor(reason: string);
20
+ }
21
+ export declare function providerErrorCode(error: unknown): ProviderErrorCode;
22
+ export declare function toErrorPayload(error: unknown): ErrorPayload;
23
+ export {};
@@ -0,0 +1,48 @@
1
+ // A failure the user can act on. `code` maps to an i18n message; the main process and the
2
+ // CLI translate it, so no layer below them produces display text.
3
+ export class AppError extends Error {
4
+ code;
5
+ params;
6
+ constructor(code, params = {}, options) {
7
+ super(code, options);
8
+ this.name = 'AppError';
9
+ this.code = code;
10
+ this.params = params;
11
+ }
12
+ }
13
+ // The `code` a system call or child process failed with (`ENOENT`, an exit status); undefined
14
+ // for anything else thrown.
15
+ export const systemErrorCode = (error) => (error instanceof Error && 'code' in error ? error.code : undefined);
16
+ const RETRY_AFTER_BOUNDS_SECONDS = { min: 60, max: 900 };
17
+ // A failed quota read. `retryAfterSeconds` is the server's back-off hint, kept within bounds.
18
+ export class ProviderError extends AppError {
19
+ retryAfterSeconds;
20
+ constructor(code, { retryAfterSeconds, cause } = {}) {
21
+ super(code, {}, cause === undefined ? undefined : { cause });
22
+ this.name = 'ProviderError';
23
+ this.retryAfterSeconds =
24
+ retryAfterSeconds === undefined
25
+ ? undefined
26
+ : Math.max(RETRY_AFTER_BOUNDS_SECONDS.min, Math.min(RETRY_AFTER_BOUNDS_SECONDS.max, Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : 0));
27
+ }
28
+ }
29
+ // Settings that do not match the schema. `reason` names the rule, for logs and tests.
30
+ export class InvalidSettingsError extends AppError {
31
+ reason;
32
+ constructor(reason) {
33
+ super('invalid_settings');
34
+ this.name = 'InvalidSettingsError';
35
+ this.reason = reason;
36
+ this.message = `invalid_settings: ${reason}`;
37
+ }
38
+ }
39
+ // The code a failed read is reported with; anything but a ProviderError is internal.
40
+ export function providerErrorCode(error) {
41
+ return error instanceof ProviderError ? error.code : 'provider_failed';
42
+ }
43
+ // Errors cross the IPC boundary as plain data: Electron would otherwise flatten them into
44
+ // "Error invoking remote method …" strings and drop the code. Anything that is not an
45
+ // AppError is an internal fault; its message is never exposed.
46
+ export function toErrorPayload(error) {
47
+ return error instanceof AppError ? { code: error.code, params: error.params } : { code: 'unexpected', params: {} };
48
+ }
@@ -0,0 +1,20 @@
1
+ export type JsonObject = Record<string, unknown>;
2
+ export declare const isObject: (value: unknown) => value is JsonObject;
3
+ export declare const asArray: (value: unknown) => readonly unknown[];
4
+ export declare const parseJson: (text: string) => unknown;
5
+ export declare const field: (value: unknown, key: string) => unknown;
6
+ export declare const pathKey: (dir: string, platform?: NodeJS.Platform) => string;
7
+ export declare const samePath: (a: string, b: string) => boolean;
8
+ export declare const JSON_LINES_EXTENSION = ".jsonl";
9
+ export declare function jsonLines(text: string): Generator<JsonObject>;
10
+ export declare function readHeadSync(file: string, bytes: number): string;
11
+ export declare function readTailSync(file: string, bytes: number): string;
12
+ export declare function newestFiles(dir: string, extension: string): string[];
13
+ export declare function readJsonSync(file: string): unknown;
14
+ export declare function readJson(file: string): Promise<unknown>;
15
+ export declare const isFile: (file: string) => Promise<boolean>;
16
+ export declare const isDirectory: (file: string) => Promise<boolean>;
17
+ export declare function writeFileAtomic(file: string, text: string): Promise<void>;
18
+ export declare function writeFileAtomicSync(file: string, text: string): void;
19
+ export declare function attempt<T>(task: () => Promise<T>): Promise<T | null>;
20
+ export declare function attemptSync<T>(task: () => T): T | null;
@@ -0,0 +1,136 @@
1
+ // Reading data that other tools write (session transcripts, model caches, settings). It is
2
+ // read best effort: a missing, unreadable or malformed file counts as absent and never fails
3
+ // the caller, so one damaged session cannot hide all the others.
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import fsp from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ export const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
9
+ export const asArray = (value) => (Array.isArray(value) ? value : []);
10
+ // Text written on Windows (PowerShell 5's UTF-8, Notepad) may start with a byte order mark.
11
+ export const parseJson = (text) => JSON.parse(text.replace(/^\uFEFF/, ''));
12
+ // A field of a JSON object; undefined for anything else.
13
+ export const field = (value, key) => (isObject(value) ? value[key] : undefined);
14
+ // A folder as a key: resolved, and in one case where the file system ignores case (Windows,
15
+ // macOS), so that two spellings of one folder compare equal.
16
+ export const pathKey = (dir, platform = process.platform) => {
17
+ const resolved = path.resolve(dir);
18
+ return platform === 'win32' || platform === 'darwin' ? resolved.toLowerCase() : resolved;
19
+ };
20
+ export const samePath = (a, b) => pathKey(a) === pathKey(b);
21
+ // The extension of JSON Lines files: the session transcripts both CLIs write.
22
+ export const JSON_LINES_EXTENSION = '.jsonl';
23
+ // Parsed records of a JSON Lines document; blank, malformed and non-object lines are skipped
24
+ // (the last line of a file another process is writing may be incomplete).
25
+ export function* jsonLines(text) {
26
+ for (const line of text.split('\n')) {
27
+ if (!line.trim())
28
+ continue;
29
+ const record = attemptSync(() => JSON.parse(line));
30
+ if (isObject(record))
31
+ yield record;
32
+ }
33
+ }
34
+ // The first `bytes` of a file as text ('' when unreadable); a trailing partial line is
35
+ // dropped by jsonLines.
36
+ export function readHeadSync(file, bytes) {
37
+ return (attemptSync(() => {
38
+ const fd = fs.openSync(file, 'r');
39
+ try {
40
+ const buffer = Buffer.alloc(bytes);
41
+ return buffer.toString('utf8', 0, fs.readSync(fd, buffer, 0, bytes, 0));
42
+ }
43
+ finally {
44
+ fs.closeSync(fd);
45
+ }
46
+ }) ?? '');
47
+ }
48
+ // The last `bytes` of a file as text ('' when unreadable); a leading partial line is dropped
49
+ // by jsonLines.
50
+ export function readTailSync(file, bytes) {
51
+ return (attemptSync(() => {
52
+ const fd = fs.openSync(file, 'r');
53
+ try {
54
+ const { size } = fs.fstatSync(fd);
55
+ const length = Math.min(bytes, size);
56
+ const buffer = Buffer.alloc(length);
57
+ return buffer.toString('utf8', 0, fs.readSync(fd, buffer, 0, length, size - length));
58
+ }
59
+ finally {
60
+ fs.closeSync(fd);
61
+ }
62
+ }) ?? '');
63
+ }
64
+ // Files in `dir` with the given extension, newest first ([] when unreadable).
65
+ export function newestFiles(dir, extension) {
66
+ return (attemptSync(() => fs.readdirSync(dir)) ?? [])
67
+ .filter(name => name.endsWith(extension))
68
+ .map(name => path.join(dir, name))
69
+ .map(file => ({ file, mtimeMs: attemptSync(() => fs.statSync(file).mtimeMs) ?? 0 }))
70
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
71
+ .map(entry => entry.file);
72
+ }
73
+ export function readJsonSync(file) {
74
+ return attemptSync(() => JSON.parse(fs.readFileSync(file, 'utf8')));
75
+ }
76
+ export async function readJson(file) {
77
+ return attempt(async () => JSON.parse(await fsp.readFile(file, 'utf8')));
78
+ }
79
+ export const isFile = (file) => fsp.stat(file).then(stat => stat.isFile(), () => false);
80
+ export const isDirectory = (file) => fsp.stat(file).then(stat => stat.isDirectory(), () => false);
81
+ // Writes the app's own data atomically and private to the user: a crash mid-write never
82
+ // leaves a truncated file.
83
+ export async function writeFileAtomic(file, text) {
84
+ await fsp.mkdir(path.dirname(file), { recursive: true });
85
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${crypto.randomBytes(8).toString('hex')}.tmp`);
86
+ try {
87
+ const handle = await fsp.open(temporary, 'wx', 0o600);
88
+ try {
89
+ await handle.writeFile(text, 'utf8');
90
+ await handle.sync();
91
+ }
92
+ finally {
93
+ await handle.close();
94
+ }
95
+ await fsp.rename(temporary, file);
96
+ }
97
+ finally {
98
+ await fsp.rm(temporary, { force: true });
99
+ }
100
+ }
101
+ // The same, for the moment the app quits, when nothing asynchronous is waited for.
102
+ export function writeFileAtomicSync(file, text) {
103
+ fs.mkdirSync(path.dirname(file), { recursive: true });
104
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${crypto.randomBytes(8).toString('hex')}.tmp`);
105
+ try {
106
+ const handle = fs.openSync(temporary, 'wx', 0o600);
107
+ try {
108
+ fs.writeFileSync(handle, text, 'utf8');
109
+ fs.fsyncSync(handle);
110
+ }
111
+ finally {
112
+ fs.closeSync(handle);
113
+ }
114
+ fs.renameSync(temporary, file);
115
+ }
116
+ finally {
117
+ fs.rmSync(temporary, { force: true });
118
+ }
119
+ }
120
+ // The task's result, or null if it throws.
121
+ export async function attempt(task) {
122
+ try {
123
+ return await task();
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
129
+ export function attemptSync(task) {
130
+ try {
131
+ return task();
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }
@@ -0,0 +1,25 @@
1
+ import { type ProviderId } from '../shared/catalog.ts';
2
+ import { type FormatContext } from '../shared/format.ts';
3
+ import type { Account, MeasuredAccount, Snapshot } from '../shared/types.ts';
4
+ import type { Notice } from './continuity.ts';
5
+ import { type KnownWindow } from './selection.ts';
6
+ export declare function trayTool(snapshot: Snapshot): {
7
+ providerId: ProviderId;
8
+ percent: number | null;
9
+ } | null;
10
+ export declare function trayTooltip(snapshot: Snapshot, { locale, now }: FormatContext): string;
11
+ export interface QuotaAlert {
12
+ kind: 'low' | 'available';
13
+ providerId: ProviderId;
14
+ account: MeasuredAccount;
15
+ window: KnownWindow;
16
+ }
17
+ export declare function createQuotaWatch(threshold?: number): (snapshot: Snapshot) => QuotaAlert[];
18
+ export declare function describeNotice(notice: Notice, context: FormatContext, accounts: readonly Account[]): {
19
+ title: string;
20
+ body: string;
21
+ };
22
+ export declare function describeAlert({ kind, providerId, account, window }: QuotaAlert, context: FormatContext): {
23
+ title: string;
24
+ body: string;
25
+ };
@@ -0,0 +1,107 @@
1
+ // What the tray tells without the window being opened: each tool's best limit in the tooltip,
2
+ // and a notification when an account's tightest limit runs low or becomes available again.
3
+ import path from 'node:path';
4
+ import { APP_NAME, displayOrder, providerById } from "../shared/catalog.js";
5
+ import { describeCode, describeWindow, formatAccountName, formatDuration, formatPercent, successorName } from "../shared/format.js";
6
+ import { tightestWindow } from "./selection.js";
7
+ const UNMEASURED = '—';
8
+ const isMeasured = (account) => account.status === 'ok';
9
+ // The most headroom any measured account of the tool has: its tightest limit.
10
+ function bestWindow(snapshot, providerId) {
11
+ return (snapshot.providers.find(p => p.id === providerId)?.accounts ?? [])
12
+ .filter(isMeasured)
13
+ .flatMap(account => tightestWindow(account) ?? [])
14
+ .reduce((most, window) => (most && most.remainingPercent >= window.remainingPercent ? most : window), null);
15
+ }
16
+ // The tool the tray icon shows: the first one in the window's order that has accounts.
17
+ export function trayTool(snapshot) {
18
+ const providerId = displayOrder(snapshot.settings).find(id => snapshot.providers.some(p => p.id === id && p.accounts.length));
19
+ return providerId ? { providerId, percent: bestWindow(snapshot, providerId)?.remainingPercent ?? null } : null;
20
+ }
21
+ // One line per tool with accounts: the most headroom any of its accounts has, and when that
22
+ // account's tightest limit resets.
23
+ export function trayTooltip(snapshot, { locale, now = Date.now() }) {
24
+ const lines = displayOrder(snapshot.settings).flatMap(providerId => {
25
+ const accounts = snapshot.providers.find(p => p.id === providerId)?.accounts ?? [];
26
+ if (!accounts.length)
27
+ return [];
28
+ const best = bestWindow(snapshot, providerId);
29
+ const name = providerById(providerId).name;
30
+ if (!best)
31
+ return [`${name} ${UNMEASURED}`];
32
+ const resetMs = best.resetsAt ? Date.parse(best.resetsAt) - now : NaN;
33
+ const reset = resetMs > 0 ? ` · ${formatDuration(resetMs, locale)}` : '';
34
+ return [`${name} ${formatPercent(best.remainingPercent, locale)}${reset}`];
35
+ });
36
+ return [APP_NAME, ...lines].join('\n');
37
+ }
38
+ // Remembers each account's last measured headroom and reports the readings that cross
39
+ // `threshold` (by default the low line the user set). An account's first reading only sets the
40
+ // baseline.
41
+ export function createQuotaWatch(threshold) {
42
+ const lastHeadroom = new Map();
43
+ return snapshot => {
44
+ const line = threshold ?? snapshot.settings.lowQuotaPercent;
45
+ return snapshot.providers.flatMap(provider => provider.accounts.filter(isMeasured).flatMap((account) => {
46
+ const window = tightestWindow(account);
47
+ if (!window)
48
+ return [];
49
+ const before = lastHeadroom.get(account.id);
50
+ lastHeadroom.set(account.id, window.remainingPercent);
51
+ if (before === undefined)
52
+ return [];
53
+ const isLow = window.remainingPercent <= line;
54
+ if (isLow === before <= line)
55
+ return [];
56
+ return [{ kind: isLow ? 'low' : 'available', providerId: provider.id, account, window }];
57
+ }));
58
+ };
59
+ }
60
+ // What a failed notice says, by what failed: work moved off an account that ran out, work brought
61
+ // back to the main account (which has quota again), or a session opened again after a restart.
62
+ const FAILED_BODIES = {
63
+ move: 'noticeFailedBody',
64
+ return: 'noticeReturnFailedBody',
65
+ reopen: 'noticeReopenFailedBody'
66
+ };
67
+ export function describeNotice(notice, context, accounts) {
68
+ const { t, locale } = context;
69
+ const subject = notice.kind === 'suggested' ? accounts.find(a => a.id === notice.suggestion.accountId) : notice.from;
70
+ const providerId = notice.kind === 'suggested' ? notice.suggestion.providerId : notice.from.providerId;
71
+ const title = `${providerById(providerId).name}${subject ? ` · ${formatAccountName(subject, t)}` : ''}`;
72
+ switch (notice.kind) {
73
+ case 'suggested': {
74
+ const { suggestion } = notice;
75
+ if (suggestion.kind === 'return')
76
+ return { title, body: t('noticeReturnBody') };
77
+ if (suggestion.kind === 'reopen')
78
+ return { title, body: t('noticeReopenBody') };
79
+ const target = successorName(providerId, suggestion.target, accounts, t);
80
+ return { title, body: t('noticeLowBody', { quota: formatPercent(suggestion.remainingPercent, locale), target }) };
81
+ }
82
+ case 'continued': {
83
+ const target = successorName(providerId, { providerId: notice.to.providerId, accountId: notice.to.account?.id ?? null }, accounts, t);
84
+ return { title, body: t('noticeContinuedBody', { target, project: path.basename(notice.projectDir) }) };
85
+ }
86
+ case 'returned': {
87
+ const target = successorName(providerId, { providerId: notice.to.providerId, accountId: notice.to.account?.id ?? null }, accounts, t);
88
+ return { title, body: t('noticeReturnedBody', { target, project: path.basename(notice.projectDir) }) };
89
+ }
90
+ case 'reopened': {
91
+ const target = successorName(providerId, { providerId: notice.to.providerId, accountId: notice.to.account?.id ?? null }, accounts, t);
92
+ return { title, body: t('noticeReopenedBody', { target, project: path.basename(notice.projectDir) }) };
93
+ }
94
+ case 'stranded':
95
+ return { title, body: t('noticeStrandedBody') };
96
+ case 'failed':
97
+ return { title, body: t(FAILED_BODIES[notice.attempt ?? 'move'], { reason: describeCode(notice.code, t) }) };
98
+ }
99
+ }
100
+ export function describeAlert({ kind, providerId, account, window }, context) {
101
+ const view = describeWindow(window, context);
102
+ const quota = [`${view.label}: ${view.value}`, view.reset].filter(Boolean).join(' · ');
103
+ return {
104
+ title: `${providerById(providerId).name} · ${formatAccountName(account, context.t)}`,
105
+ body: kind === 'low' ? quota : `${context.t('quotaAvailableAgain')}\n${quota}`
106
+ };
107
+ }