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.
- package/LICENSE +21 -0
- package/README.md +275 -2
- package/build/cli.js +197 -0
- package/build/core/claude-processes.d.ts +51 -0
- package/build/core/claude-processes.js +288 -0
- package/build/core/claude-profile.d.ts +2 -0
- package/build/core/claude-profile.js +146 -0
- package/build/core/claude-renew.d.ts +12 -0
- package/build/core/claude-renew.js +79 -0
- package/build/core/continuity.d.ts +90 -0
- package/build/core/continuity.js +1244 -0
- package/build/core/diagnostics.d.ts +25 -0
- package/build/core/diagnostics.js +229 -0
- package/build/core/environment.d.ts +12 -0
- package/build/core/environment.js +25 -0
- package/build/core/errors.d.ts +23 -0
- package/build/core/errors.js +48 -0
- package/build/core/files.d.ts +20 -0
- package/build/core/files.js +136 -0
- package/build/core/glance.d.ts +25 -0
- package/build/core/glance.js +107 -0
- package/build/core/handover.d.ts +61 -0
- package/build/core/handover.js +333 -0
- package/build/core/history.d.ts +19 -0
- package/build/core/history.js +134 -0
- package/build/core/launch.d.ts +78 -0
- package/build/core/launch.js +256 -0
- package/build/core/measurements.d.ts +11 -0
- package/build/core/measurements.js +75 -0
- package/build/core/model-families.d.ts +12 -0
- package/build/core/model-families.js +61 -0
- package/build/core/model.d.ts +32 -0
- package/build/core/model.js +86 -0
- package/build/core/models.d.ts +25 -0
- package/build/core/models.js +164 -0
- package/build/core/redact.d.ts +2 -0
- package/build/core/redact.js +64 -0
- package/build/core/selection.d.ts +65 -0
- package/build/core/selection.js +173 -0
- package/build/core/service.d.ts +35 -0
- package/build/core/service.js +484 -0
- package/build/core/sessions.d.ts +87 -0
- package/build/core/sessions.js +868 -0
- package/build/core/settings.d.ts +16 -0
- package/build/core/settings.js +377 -0
- package/build/core/tray-icon.d.ts +5 -0
- package/build/core/tray-icon.js +91 -0
- package/build/core/updater.d.ts +29 -0
- package/build/core/updater.js +88 -0
- package/build/core/updates.d.ts +5 -0
- package/build/core/updates.js +29 -0
- package/build/launchers/discover-antigravity.ps1 +68 -0
- package/build/launchers/terminal.ps1 +78 -0
- package/build/main/main.js +587 -0
- package/build/providers/antigravity.d.ts +48 -0
- package/build/providers/antigravity.js +463 -0
- package/build/providers/claude-keychain.d.ts +13 -0
- package/build/providers/claude-keychain.js +42 -0
- package/build/providers/claude.d.ts +33 -0
- package/build/providers/claude.js +218 -0
- package/build/providers/codex.d.ts +21 -0
- package/build/providers/codex.js +221 -0
- package/build/providers/common.d.ts +63 -0
- package/build/providers/common.js +294 -0
- package/build/providers/cursor.d.ts +14 -0
- package/build/providers/cursor.js +141 -0
- package/build/providers/profiles.d.ts +10 -0
- package/build/providers/profiles.js +69 -0
- package/build/providers/tls-trust.d.ts +7 -0
- package/build/providers/tls-trust.js +29 -0
- package/build/providers/types.d.ts +19 -0
- package/build/providers/types.js +1 -0
- package/build/shared/account-state.d.ts +19 -0
- package/build/shared/account-state.js +75 -0
- package/build/shared/catalog.d.ts +85 -0
- package/build/shared/catalog.js +125 -0
- package/build/shared/format.d.ts +40 -0
- package/build/shared/format.js +134 -0
- package/build/shared/i18n/index.d.ts +12 -0
- package/build/shared/i18n/index.js +40 -0
- package/build/shared/i18n/locales/de.d.ts +2 -0
- package/build/shared/i18n/locales/de.js +328 -0
- package/build/shared/i18n/locales/en.d.ts +328 -0
- package/build/shared/i18n/locales/en.js +330 -0
- package/build/shared/i18n/locales/es.d.ts +2 -0
- package/build/shared/i18n/locales/es.js +328 -0
- package/build/shared/i18n/locales/fr.d.ts +2 -0
- package/build/shared/i18n/locales/fr.js +328 -0
- package/build/shared/i18n/locales/it.d.ts +2 -0
- package/build/shared/i18n/locales/it.js +327 -0
- package/build/shared/i18n/locales/ja.d.ts +2 -0
- package/build/shared/i18n/locales/ja.js +322 -0
- package/build/shared/i18n/locales/ko.d.ts +2 -0
- package/build/shared/i18n/locales/ko.js +322 -0
- package/build/shared/i18n/locales/nl.d.ts +2 -0
- package/build/shared/i18n/locales/nl.js +327 -0
- package/build/shared/i18n/locales/pl.d.ts +2 -0
- package/build/shared/i18n/locales/pl.js +332 -0
- package/build/shared/i18n/locales/pt.d.ts +2 -0
- package/build/shared/i18n/locales/pt.js +328 -0
- package/build/shared/i18n/locales/ru.d.ts +2 -0
- package/build/shared/i18n/locales/ru.js +331 -0
- package/build/shared/i18n/locales/tr.d.ts +2 -0
- package/build/shared/i18n/locales/tr.js +322 -0
- package/build/shared/i18n/locales/zh.d.ts +2 -0
- package/build/shared/i18n/locales/zh.js +322 -0
- package/build/shared/i18n/messages.d.ts +10 -0
- package/build/shared/i18n/messages.js +1 -0
- package/build/shared/ipc.d.ts +138 -0
- package/build/shared/ipc.js +2 -0
- package/build/shared/tsconfig.tsbuildinfo +1 -0
- package/build/shared/types.d.ts +205 -0
- package/build/shared/types.js +1 -0
- package/package.json +144 -4
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
// The quota service: owns the settings, discovers accounts, reads quotas and hands out
|
|
2
|
+
// snapshots. Settings writes are serialized; reads run per provider, deduplicated.
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { createAntigravityProvider } from "../providers/antigravity.js";
|
|
5
|
+
import { createClaudeProvider } from "../providers/claude.js";
|
|
6
|
+
import { createCodexProvider } from "../providers/codex.js";
|
|
7
|
+
import { createCursorProvider } from "../providers/cursor.js";
|
|
8
|
+
import { HEALTHY_STATUSES } from "../shared/account-state.js";
|
|
9
|
+
import { ACCOUNT_ID_PATTERN, DURATION_MS, MAX_DISMISSED_ACCOUNTS, PROFILE_PROVIDER_IDS, PROVIDER_IDS } from "../shared/catalog.js";
|
|
10
|
+
import { profileFolders } from "../providers/profiles.js";
|
|
11
|
+
import { AppError, ProviderError, providerErrorCode } from "./errors.js";
|
|
12
|
+
import { isObject, pathKey } from "./files.js";
|
|
13
|
+
import { loadMeasurements, measurementOf, saveMeasurements } from "./measurements.js";
|
|
14
|
+
import { normalizeWindow } from "./model.js";
|
|
15
|
+
import { defaultSettings, loadSettingsWithRecovery, saveSettings, validateSettings } from "./settings.js";
|
|
16
|
+
const ADAPTER_FACTORIES = {
|
|
17
|
+
claude: () => createClaudeProvider(),
|
|
18
|
+
codex: () => createCodexProvider(),
|
|
19
|
+
cursor: () => createCursorProvider(),
|
|
20
|
+
antigravity: () => createAntigravityProvider()
|
|
21
|
+
};
|
|
22
|
+
const FETCH_TIMEOUT_MS = 12000;
|
|
23
|
+
const DISCOVERY_INTERVAL_MS = DURATION_MS.MINUTE;
|
|
24
|
+
// A discovered account disappears only after this many clean discovery passes without it,
|
|
25
|
+
// so a transient read failure never deletes it.
|
|
26
|
+
const MISSES_BEFORE_REMOVAL = 3;
|
|
27
|
+
const DEFAULT_RATE_LIMIT_COOLDOWN_SECONDS = 120;
|
|
28
|
+
// An expired sign-in that could not be renewed is tried again after this.
|
|
29
|
+
const RENEW_INTERVAL_MS = 30 * DURATION_MS.MINUTE;
|
|
30
|
+
// Accounts that mirror running processes rather than files on disk; their absence means
|
|
31
|
+
// "app closed", not "account removed".
|
|
32
|
+
const PROCESS_BOUND_PROVIDERS = new Set(['antigravity']);
|
|
33
|
+
// A sign-in finishes in the browser at the user's pace; a pending account waits this long for it.
|
|
34
|
+
export const SIGN_IN_WINDOW_MS = 10 * DURATION_MS.MINUTE;
|
|
35
|
+
// Whether the app may move work to an account by itself: continuing work that ran out, taking it
|
|
36
|
+
// back to the main account, or opening it again after a restart. Such an account is read first.
|
|
37
|
+
const movesWorkOnItsOwn = (settings) => settings.autoContinue !== 'off' || settings.returnToPrimary === 'auto' || settings.reopenAfterRestart === 'auto';
|
|
38
|
+
// Rejects with request_timeout if `promise` has not settled within `ms`.
|
|
39
|
+
function withTimeout(promise, ms) {
|
|
40
|
+
let timer;
|
|
41
|
+
const timeout = new Promise((_, reject) => {
|
|
42
|
+
timer = setTimeout(() => {
|
|
43
|
+
reject(new ProviderError('request_timeout'));
|
|
44
|
+
}, ms);
|
|
45
|
+
});
|
|
46
|
+
return Promise.race([promise, timeout]).finally(() => {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// Any user edit (name, primary, enabled, position) makes an account user-managed, except a
|
|
51
|
+
// pending one: it waits for its sign-in whatever the user changed, and discovery makes it the
|
|
52
|
+
// user's. Origin, configDirExplicit and a pending sign-in's time are never taken from the
|
|
53
|
+
// client. A pending account missing from the edit was added after the client's view of the
|
|
54
|
+
// list was taken (accounts are removed by dismissal, not by an edit): it is kept.
|
|
55
|
+
function mergeAccountEdits(current, edited) {
|
|
56
|
+
const editedIds = new Set(edited.map(a => a.id));
|
|
57
|
+
const currentIds = new Set(current.map(a => a.id));
|
|
58
|
+
// Relative order of the accounts present in both, so a removal is not seen as a move.
|
|
59
|
+
const before = current.map(a => a.id).filter(id => editedIds.has(id));
|
|
60
|
+
const after = edited.map(a => a.id).filter(id => currentIds.has(id));
|
|
61
|
+
const addedSince = current.filter(a => a.origin === 'pending' && !editedIds.has(a.id));
|
|
62
|
+
const merged = edited.map((account) => {
|
|
63
|
+
const existing = current.find(a => a.id === account.id);
|
|
64
|
+
const { origin: _origin, configDirExplicit: _explicit, signInUntil: _signInUntil, ...fields } = account;
|
|
65
|
+
const configDirExplicit = existing ? existing.configDirExplicit : account.configDirExplicit;
|
|
66
|
+
const userEdited = existing !== undefined &&
|
|
67
|
+
(account.label !== existing.label ||
|
|
68
|
+
account.labelKey !== existing.labelKey ||
|
|
69
|
+
account.primary !== existing.primary ||
|
|
70
|
+
account.enabled !== existing.enabled ||
|
|
71
|
+
before.indexOf(account.id) !== after.indexOf(account.id));
|
|
72
|
+
const origin = existing?.origin === 'pending' ? 'pending' : userEdited ? 'user' : (existing?.origin ?? 'user');
|
|
73
|
+
return {
|
|
74
|
+
...fields,
|
|
75
|
+
...(configDirExplicit === undefined ? {} : { configDirExplicit }),
|
|
76
|
+
origin,
|
|
77
|
+
...(origin === 'pending' && existing?.signInUntil !== undefined ? { signInUntil: existing.signInUntil } : {})
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
return [...merged, ...addedSince];
|
|
81
|
+
}
|
|
82
|
+
export async function createService({ settingsPath, measurementsPath, renewSignIn, onChange, providers: injected, clock = Date.now, home = os.homedir() }) {
|
|
83
|
+
const adapters = injected ?? PROVIDER_IDS.map(id => ADAPTER_FACTORIES[id]());
|
|
84
|
+
const loaded = await loadSettingsWithRecovery(settingsPath);
|
|
85
|
+
let settings = loaded.settings;
|
|
86
|
+
let notice = loaded.notice;
|
|
87
|
+
// Unreadable or newer settings are not overwritten by ordinary changes: the app runs on
|
|
88
|
+
// defaults in memory until the user resets or imports.
|
|
89
|
+
const locked = () => notice === 'unrecoverable' || notice === 'future';
|
|
90
|
+
const providers = adapters.map(adapter => ({ id: adapter.id, status: 'pending', accounts: [] }));
|
|
91
|
+
const saved = measurementsPath ? await loadMeasurements(measurementsPath, clock()) : new Map();
|
|
92
|
+
let savingMeasurements = Promise.resolve();
|
|
93
|
+
const lastRenewal = new Map();
|
|
94
|
+
const renewing = new Set();
|
|
95
|
+
let pendingSave = Promise.resolve();
|
|
96
|
+
const cooldownUntil = new Map();
|
|
97
|
+
const inFlight = new Map();
|
|
98
|
+
const consecutiveMisses = new Map();
|
|
99
|
+
const providerGenerations = new Map();
|
|
100
|
+
const accountGenerations = new Map();
|
|
101
|
+
let lastDiscovery = clock();
|
|
102
|
+
let discoveryRunning = null;
|
|
103
|
+
const stamp = () => new Date(clock()).toISOString();
|
|
104
|
+
// Settings writes run one at a time, each on the result of the previous one; a failed write
|
|
105
|
+
// does not block the next.
|
|
106
|
+
function serialized(task) {
|
|
107
|
+
const run = pendingSave.catch(() => undefined).then(task);
|
|
108
|
+
pendingSave = run;
|
|
109
|
+
return run;
|
|
110
|
+
}
|
|
111
|
+
function rebuild() {
|
|
112
|
+
for (const provider of providers) {
|
|
113
|
+
const previous = provider.accounts;
|
|
114
|
+
provider.accounts = settings.accounts
|
|
115
|
+
.filter(a => a.providerId === provider.id && a.enabled)
|
|
116
|
+
.map((account) => {
|
|
117
|
+
const measured = previous.find(b => b.id === account.id);
|
|
118
|
+
if (measured && measured.configDir === account.configDir)
|
|
119
|
+
return { ...measured, ...account };
|
|
120
|
+
// Not read yet in this run: the last good reading, as stale. It shows the quota last
|
|
121
|
+
// known; nothing acts on it, as only a fresh, ok reading counts as quota.
|
|
122
|
+
const last = saved.get(account.id);
|
|
123
|
+
return last?.configDir === (account.configDir ?? null)
|
|
124
|
+
? {
|
|
125
|
+
...account,
|
|
126
|
+
status: 'stale',
|
|
127
|
+
windows: structuredClone(last.windows),
|
|
128
|
+
fetchedAt: last.fetchedAt,
|
|
129
|
+
...(last.plan ? { plan: last.plan } : {})
|
|
130
|
+
}
|
|
131
|
+
: { ...account, status: 'pending', windows: [] };
|
|
132
|
+
});
|
|
133
|
+
if (provider.status === 'pending' && provider.accounts.some(a => a.status === 'stale'))
|
|
134
|
+
provider.status = 'stale';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Each account's latest good reading is written after every read, one write at a time.
|
|
138
|
+
function keepMeasurements() {
|
|
139
|
+
if (!measurementsPath)
|
|
140
|
+
return;
|
|
141
|
+
for (const account of providers.flatMap(p => p.accounts)) {
|
|
142
|
+
const reading = measurementOf(account);
|
|
143
|
+
if (reading)
|
|
144
|
+
saved.set(account.id, reading);
|
|
145
|
+
}
|
|
146
|
+
savingMeasurements = savingMeasurements.then(() => saveMeasurements(measurementsPath, saved)).catch(() => undefined);
|
|
147
|
+
}
|
|
148
|
+
function snapshot() {
|
|
149
|
+
return structuredClone({ providers, refreshing: inFlight.size > 0, settings, ...(notice ? { settingsNotice: notice } : {}) });
|
|
150
|
+
}
|
|
151
|
+
// Discovery still shows the accounts it finds while the settings are locked, in memory only.
|
|
152
|
+
async function commit(next, { discovered = false } = {}) {
|
|
153
|
+
if (locked() && !discovered)
|
|
154
|
+
throw new AppError('settings_locked');
|
|
155
|
+
settings = locked() ? validateSettings(next) : await saveSettings(settingsPath, next);
|
|
156
|
+
rebuild();
|
|
157
|
+
return snapshot();
|
|
158
|
+
}
|
|
159
|
+
// An explicit replacement may write over an unreadable file (which is kept aside).
|
|
160
|
+
async function replace(next) {
|
|
161
|
+
if (notice === 'future')
|
|
162
|
+
throw new AppError('settings_locked');
|
|
163
|
+
settings = await saveSettings(settingsPath, next);
|
|
164
|
+
notice = undefined;
|
|
165
|
+
rebuild();
|
|
166
|
+
return snapshot();
|
|
167
|
+
}
|
|
168
|
+
rebuild();
|
|
169
|
+
// Removes the account from `next`; the provider's next enabled account becomes primary in its place.
|
|
170
|
+
function removeAccount(next, accountId) {
|
|
171
|
+
const removed = next.accounts.find(a => a.id === accountId);
|
|
172
|
+
next.accounts = next.accounts.filter(a => a.id !== accountId);
|
|
173
|
+
consecutiveMisses.delete(accountId);
|
|
174
|
+
if (!removed?.primary)
|
|
175
|
+
return;
|
|
176
|
+
const successor = next.accounts.find(a => a.providerId === removed.providerId && a.enabled);
|
|
177
|
+
if (successor)
|
|
178
|
+
successor.primary = true;
|
|
179
|
+
}
|
|
180
|
+
// Merges one adapter's discovery, made from `seenAt`, into `next`; true when anything changed.
|
|
181
|
+
function reconcile(next, { adapter, found }, seenAt) {
|
|
182
|
+
let modified = false;
|
|
183
|
+
const dismissed = new Set(next.dismissedAccountIds);
|
|
184
|
+
const foundIds = new Set(found.map(a => a.id));
|
|
185
|
+
for (const account of found) {
|
|
186
|
+
consecutiveMisses.delete(account.id);
|
|
187
|
+
const existing = next.accounts.find(a => a.id === account.id);
|
|
188
|
+
// Its sign-in is done: the account the user added is theirs from now on.
|
|
189
|
+
if (existing?.origin === 'pending') {
|
|
190
|
+
existing.origin = 'user';
|
|
191
|
+
delete existing.signInUntil;
|
|
192
|
+
modified = true;
|
|
193
|
+
}
|
|
194
|
+
// Dismissal is the user's decision; discovery never re-adds those accounts.
|
|
195
|
+
if (existing || dismissed.has(account.id))
|
|
196
|
+
continue;
|
|
197
|
+
const hasPrimary = next.accounts.some(a => a.providerId === adapter.id && a.primary);
|
|
198
|
+
next.accounts.push({ ...account, origin: 'discovered', primary: account.primary && !hasPrimary });
|
|
199
|
+
modified = true;
|
|
200
|
+
}
|
|
201
|
+
// Only untouched discovered accounts are removed; anything the user disabled or customized
|
|
202
|
+
// stays until the user removes it.
|
|
203
|
+
const vanished = next.accounts.filter(a => a.providerId === adapter.id &&
|
|
204
|
+
!PROCESS_BOUND_PROVIDERS.has(a.providerId) &&
|
|
205
|
+
a.origin === 'discovered' &&
|
|
206
|
+
a.enabled &&
|
|
207
|
+
!foundIds.has(a.id));
|
|
208
|
+
for (const account of vanished) {
|
|
209
|
+
const misses = (consecutiveMisses.get(account.id) ?? 0) + 1;
|
|
210
|
+
consecutiveMisses.set(account.id, misses);
|
|
211
|
+
if (misses < MISSES_BEFORE_REMOVAL)
|
|
212
|
+
continue;
|
|
213
|
+
next.accounts = next.accounts.filter(a => a !== account);
|
|
214
|
+
consecutiveMisses.delete(account.id);
|
|
215
|
+
modified = true;
|
|
216
|
+
}
|
|
217
|
+
// A pending account not found by a look made after its sign-in closed never held a sign-in;
|
|
218
|
+
// a look begun before then may predate a sign-in finished since.
|
|
219
|
+
const withdrawn = next.accounts.filter(a => a.providerId === adapter.id && a.origin === 'pending' && !foundIds.has(a.id) && (a.signInUntil ?? 0) <= seenAt);
|
|
220
|
+
for (const account of withdrawn)
|
|
221
|
+
removeAccount(next, account.id);
|
|
222
|
+
return modified || withdrawn.length > 0;
|
|
223
|
+
}
|
|
224
|
+
async function runDiscovery() {
|
|
225
|
+
const seenAt = clock();
|
|
226
|
+
// A failed discovery says nothing about which accounts exist: it never counts as a miss.
|
|
227
|
+
const results = await Promise.all(adapters.map(adapter => adapter.discover().then(found => ({ adapter, found }), () => null)));
|
|
228
|
+
// Discovery runs outside the write queue; only the reconciliation is serialized.
|
|
229
|
+
await serialized(async () => {
|
|
230
|
+
const next = structuredClone(settings);
|
|
231
|
+
let modified = false;
|
|
232
|
+
for (const result of results)
|
|
233
|
+
if (result)
|
|
234
|
+
modified = reconcile(next, result, seenAt) || modified;
|
|
235
|
+
if (modified)
|
|
236
|
+
await commit(next, { discovered: true });
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
function reconcileDiscovery() {
|
|
240
|
+
discoveryRunning ??= runDiscovery().finally(() => {
|
|
241
|
+
discoveryRunning = null;
|
|
242
|
+
});
|
|
243
|
+
return discoveryRunning;
|
|
244
|
+
}
|
|
245
|
+
await reconcileDiscovery().catch(() => undefined);
|
|
246
|
+
// The account as configured now, or null when it was removed, disabled or re-pointed while
|
|
247
|
+
// a read was in flight, or a newer read already reported.
|
|
248
|
+
function currentTarget(provider, account, generation) {
|
|
249
|
+
if ((accountGenerations.get(account.id) ?? 0) > generation)
|
|
250
|
+
return null;
|
|
251
|
+
accountGenerations.set(account.id, generation);
|
|
252
|
+
const configured = settings.accounts.find(a => a.id === account.id);
|
|
253
|
+
if (!configured || configured.configDir !== account.configDir || !configured.enabled)
|
|
254
|
+
return null;
|
|
255
|
+
return provider.accounts.find(a => a.id === account.id) ?? account;
|
|
256
|
+
}
|
|
257
|
+
function applyResult(target, result, fetchedAt) {
|
|
258
|
+
const windows = result.windows.map(normalizeWindow);
|
|
259
|
+
if (!windows.length && result.status !== 'no_limits_reported')
|
|
260
|
+
throw new ProviderError('invalid_response');
|
|
261
|
+
if (result.plan)
|
|
262
|
+
target.plan = result.plan;
|
|
263
|
+
if (result.signedInAs)
|
|
264
|
+
target.signedInAs = result.signedInAs;
|
|
265
|
+
else
|
|
266
|
+
delete target.signedInAs;
|
|
267
|
+
if (result.status === 'no_limits_reported') {
|
|
268
|
+
Object.assign(target, { windows: [], status: 'no_limits_reported', messageCode: 'no_limits_reported', fetchedAt });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
// A window missing from this response keeps its last value, marked stale.
|
|
272
|
+
// One that never had a value has nothing to keep.
|
|
273
|
+
for (const prior of target.windows) {
|
|
274
|
+
if (!windows.some(w => w.id === prior.id) && prior.remainingPercent !== null)
|
|
275
|
+
windows.push({ ...prior, stale: true, detailKey: 'staleWindowDetail' });
|
|
276
|
+
}
|
|
277
|
+
Object.assign(target, { windows, status: 'ok', fetchedAt });
|
|
278
|
+
delete target.messageCode;
|
|
279
|
+
}
|
|
280
|
+
function applyFailure(target, error, attemptedAt) {
|
|
281
|
+
const code = providerErrorCode(error);
|
|
282
|
+
// A failed read keeps the last good measurement, labelled stale.
|
|
283
|
+
target.status = target.fetchedAt ? 'stale' : code;
|
|
284
|
+
target.messageCode = code;
|
|
285
|
+
if (code === 'rate_limited') {
|
|
286
|
+
const seconds = (error instanceof ProviderError ? error.retryAfterSeconds : undefined) ?? DEFAULT_RATE_LIMIT_COOLDOWN_SECONDS;
|
|
287
|
+
cooldownUntil.set(target.id, attemptedAt + seconds * 1000);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// An expired sign-in is renewed when work may move to the account on its own (it could not
|
|
291
|
+
// take it over unread): once at a time per account, at most once in a while. It runs beside the
|
|
292
|
+
// refresh, which reports the expiry meanwhile and does not wait; once renewed, the tool's
|
|
293
|
+
// accounts are read again and the change announced.
|
|
294
|
+
function renewInBackground(adapter, account, error) {
|
|
295
|
+
const renew = renewSignIn?.[account.providerId];
|
|
296
|
+
if (!renew || !movesWorkOnItsOwn(settings) || providerErrorCode(error) !== 'auth_expired' || renewing.has(account.id))
|
|
297
|
+
return;
|
|
298
|
+
if (clock() - (lastRenewal.get(account.id) ?? Number.NEGATIVE_INFINITY) < RENEW_INTERVAL_MS)
|
|
299
|
+
return;
|
|
300
|
+
lastRenewal.set(account.id, clock());
|
|
301
|
+
renewing.add(account.id);
|
|
302
|
+
void renew(account)
|
|
303
|
+
.catch(() => false)
|
|
304
|
+
.then(async (renewed) => {
|
|
305
|
+
if (!renewed)
|
|
306
|
+
return;
|
|
307
|
+
// A read already under way may predate the renewal: the tool is read again after it.
|
|
308
|
+
await inFlight.get(adapter.id)?.catch(() => undefined);
|
|
309
|
+
await startRefresh(adapter);
|
|
310
|
+
onChange?.();
|
|
311
|
+
})
|
|
312
|
+
.finally(() => renewing.delete(account.id));
|
|
313
|
+
}
|
|
314
|
+
async function refreshAccount(adapter, provider, account, generation) {
|
|
315
|
+
const attemptedAt = clock();
|
|
316
|
+
if ((cooldownUntil.get(account.id) ?? 0) > attemptedAt)
|
|
317
|
+
return;
|
|
318
|
+
const outcome = await withTimeout(adapter.fetch(account), adapter.fetchTimeoutMs ?? FETCH_TIMEOUT_MS).then(result => ({ result }), (error) => ({ error }));
|
|
319
|
+
const target = currentTarget(provider, account, generation);
|
|
320
|
+
if (!target)
|
|
321
|
+
return;
|
|
322
|
+
try {
|
|
323
|
+
if ('error' in outcome)
|
|
324
|
+
throw outcome.error;
|
|
325
|
+
applyResult(target, outcome.result, stamp());
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
applyFailure(target, error, attemptedAt);
|
|
329
|
+
renewInBackground(adapter, target, error);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const isHealthy = (account) => HEALTHY_STATUSES.has(account.status);
|
|
333
|
+
async function refreshProvider(adapter) {
|
|
334
|
+
const provider = providers.find(p => p.id === adapter.id);
|
|
335
|
+
if (!provider)
|
|
336
|
+
return;
|
|
337
|
+
const generation = (providerGenerations.get(adapter.id) ?? 0) + 1;
|
|
338
|
+
providerGenerations.set(adapter.id, generation);
|
|
339
|
+
await Promise.all(provider.accounts.map(account => refreshAccount(adapter, provider, account, generation)));
|
|
340
|
+
const { accounts } = provider;
|
|
341
|
+
const allHealthy = accounts.length > 0 && accounts.every(isHealthy);
|
|
342
|
+
provider.status = allHealthy ? 'ok' : accounts.some(a => a.fetchedAt) ? 'stale' : (accounts[0]?.status ?? 'unavailable');
|
|
343
|
+
if (allHealthy || accounts.some(a => a.status === 'ok'))
|
|
344
|
+
provider.fetchedAt = stamp();
|
|
345
|
+
if (accounts.length)
|
|
346
|
+
delete provider.messageCode;
|
|
347
|
+
else
|
|
348
|
+
provider.messageCode = 'no_accounts';
|
|
349
|
+
}
|
|
350
|
+
function startRefresh(adapter, fresh = false) {
|
|
351
|
+
const running = inFlight.get(adapter.id);
|
|
352
|
+
// Callers wanting a fresh read share the one that starts after the running one.
|
|
353
|
+
if (running && fresh)
|
|
354
|
+
return running.catch(() => undefined).then(() => startRefresh(adapter));
|
|
355
|
+
if (running)
|
|
356
|
+
return running;
|
|
357
|
+
const promise = refreshProvider(adapter).finally(() => {
|
|
358
|
+
if (inFlight.get(adapter.id) === promise)
|
|
359
|
+
inFlight.delete(adapter.id);
|
|
360
|
+
keepMeasurements();
|
|
361
|
+
});
|
|
362
|
+
inFlight.set(adapter.id, promise);
|
|
363
|
+
return promise;
|
|
364
|
+
}
|
|
365
|
+
async function refresh(providerId, { fresh = false } = {}) {
|
|
366
|
+
const targets = providerId === undefined ? adapters : adapters.filter(a => a.id === providerId);
|
|
367
|
+
if (!targets.length)
|
|
368
|
+
throw new AppError('invalid_provider');
|
|
369
|
+
if (clock() - lastDiscovery > DISCOVERY_INTERVAL_MS) {
|
|
370
|
+
lastDiscovery = clock();
|
|
371
|
+
await reconcileDiscovery().catch(() => undefined);
|
|
372
|
+
}
|
|
373
|
+
// Every provider is started and awaited, also those a narrower refresh already started.
|
|
374
|
+
await Promise.allSettled(targets.map(adapter => startRefresh(adapter, fresh)));
|
|
375
|
+
// The readings just taken are on disk before the refresh counts as done.
|
|
376
|
+
await savingMeasurements;
|
|
377
|
+
return snapshot();
|
|
378
|
+
}
|
|
379
|
+
async function dismissAccount(accountId) {
|
|
380
|
+
if (typeof accountId !== 'string' || !ACCOUNT_ID_PATTERN.test(accountId))
|
|
381
|
+
throw new AppError('invalid_argument');
|
|
382
|
+
if (!settings.accounts.some(a => a.id === accountId))
|
|
383
|
+
throw new AppError('account_not_found');
|
|
384
|
+
return serialized(() => {
|
|
385
|
+
const next = structuredClone(settings);
|
|
386
|
+
next.dismissedAccountIds = [...new Set([...next.dismissedAccountIds, accountId])].slice(-MAX_DISMISSED_ACCOUNTS);
|
|
387
|
+
removeAccount(next, accountId);
|
|
388
|
+
return commit(next);
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
function addPendingAccount(account) {
|
|
392
|
+
return serialized(() => {
|
|
393
|
+
const next = structuredClone(settings);
|
|
394
|
+
const primary = !next.accounts.some(a => a.providerId === account.providerId);
|
|
395
|
+
next.accounts.push({ ...account, primary, origin: 'pending', signInUntil: clock() + SIGN_IN_WINDOW_MS });
|
|
396
|
+
// Adding it again undoes an earlier dismissal of the same folder.
|
|
397
|
+
next.dismissedAccountIds = next.dismissedAccountIds.filter(id => id !== account.id);
|
|
398
|
+
return commit(next);
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
function withdrawPendingAccount(accountId) {
|
|
402
|
+
return serialized(() => {
|
|
403
|
+
if (!settings.accounts.some(a => a.id === accountId && a.origin === 'pending'))
|
|
404
|
+
return Promise.resolve(snapshot());
|
|
405
|
+
const next = structuredClone(settings);
|
|
406
|
+
removeAccount(next, accountId);
|
|
407
|
+
return commit(next);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
function signInOpened(accountId) {
|
|
411
|
+
return serialized(() => {
|
|
412
|
+
const pending = settings.accounts.find(a => a.id === accountId && a.origin === 'pending');
|
|
413
|
+
if (!pending)
|
|
414
|
+
return Promise.resolve(snapshot());
|
|
415
|
+
const next = structuredClone(settings);
|
|
416
|
+
next.accounts = next.accounts.map(a => (a.id === accountId ? { ...a, signInUntil: clock() + SIGN_IN_WINDOW_MS } : a));
|
|
417
|
+
return commit(next);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
// Typed or not, every change takes this one validated path.
|
|
421
|
+
function applySettings(patch) {
|
|
422
|
+
return serialized(() => {
|
|
423
|
+
const next = validateSettings({ ...settings, ...patch });
|
|
424
|
+
return commit({ ...next, accounts: mergeAccountEdits(settings.accounts, next.accounts) });
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
const updateSettings = (patch) => applySettings(patch);
|
|
428
|
+
function updateSettingsFrom(input) {
|
|
429
|
+
if (!isObject(input))
|
|
430
|
+
throw new AppError('invalid_argument');
|
|
431
|
+
return applySettings(input);
|
|
432
|
+
}
|
|
433
|
+
async function resetSettings() {
|
|
434
|
+
await serialized(() => replace({ ...defaultSettings(), ...(settings.window ? { window: settings.window } : {}) }));
|
|
435
|
+
consecutiveMisses.clear();
|
|
436
|
+
await reconcileDiscovery();
|
|
437
|
+
return snapshot();
|
|
438
|
+
}
|
|
439
|
+
async function importSettings(document) {
|
|
440
|
+
if (!isObject(document))
|
|
441
|
+
throw new AppError('invalid_settings');
|
|
442
|
+
const { window: _ignored, ...imported } = document;
|
|
443
|
+
const next = validateSettings({ ...imported, ...(settings.window ? { window: settings.window } : {}) });
|
|
444
|
+
// An imported account is kept only when its folder is a profile folder of this computer, as
|
|
445
|
+
// discovery finds them. Another computer's paths mean nothing here, and a folder named by a
|
|
446
|
+
// file someone handed over would have the app run the tool there, with that folder's
|
|
447
|
+
// settings and hooks. Discovery adds back each local account it finds.
|
|
448
|
+
const local = new Set((await Promise.all(PROFILE_PROVIDER_IDS.map(id => profileFolders(id, home)))).flat().map(dir => pathKey(dir)));
|
|
449
|
+
next.accounts = next.accounts.filter(a => a.configDir === undefined || local.has(pathKey(a.configDir)));
|
|
450
|
+
await serialized(() => replace(next));
|
|
451
|
+
await reconcileDiscovery();
|
|
452
|
+
return snapshot();
|
|
453
|
+
}
|
|
454
|
+
function exportSettings() {
|
|
455
|
+
const { window: _window, ...document } = structuredClone(settings);
|
|
456
|
+
return document;
|
|
457
|
+
}
|
|
458
|
+
function dismissSettingsNotice() {
|
|
459
|
+
if (notice === 'recovered')
|
|
460
|
+
notice = undefined;
|
|
461
|
+
return snapshot();
|
|
462
|
+
}
|
|
463
|
+
async function restoreDismissedAccounts() {
|
|
464
|
+
await updateSettings({ dismissedAccountIds: [] });
|
|
465
|
+
await reconcileDiscovery();
|
|
466
|
+
return snapshot();
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
snapshot,
|
|
470
|
+
refresh,
|
|
471
|
+
updateSettings,
|
|
472
|
+
updateSettingsFrom,
|
|
473
|
+
dismissAccount,
|
|
474
|
+
addPendingAccount,
|
|
475
|
+
signInOpened,
|
|
476
|
+
withdrawPendingAccount,
|
|
477
|
+
resetSettings,
|
|
478
|
+
importSettings,
|
|
479
|
+
exportSettings,
|
|
480
|
+
dismissSettingsNotice,
|
|
481
|
+
restoreDismissedAccounts,
|
|
482
|
+
reconcileDiscovery
|
|
483
|
+
};
|
|
484
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type Auto, type ClaudePermissionMode, type ProfileProviderId, type ProviderId } from '../shared/catalog.ts';
|
|
2
|
+
import type { SessionSummary } from '../shared/ipc.ts';
|
|
3
|
+
import type { Account } from '../shared/types.ts';
|
|
4
|
+
export interface SessionSources {
|
|
5
|
+
homeDir: string;
|
|
6
|
+
profiles: Readonly<Record<ProfileProviderId, readonly string[]>>;
|
|
7
|
+
}
|
|
8
|
+
export declare function sessionSources(homeDir: string, accounts?: readonly Account[]): SessionSources;
|
|
9
|
+
export interface Turn {
|
|
10
|
+
role: 'user' | 'assistant';
|
|
11
|
+
text: string;
|
|
12
|
+
}
|
|
13
|
+
export interface Transcript {
|
|
14
|
+
providerId: ProviderId;
|
|
15
|
+
sessionId: string;
|
|
16
|
+
sessionTitle: string;
|
|
17
|
+
sessionModel: string;
|
|
18
|
+
initialGoal: string;
|
|
19
|
+
lastUserMsg: string;
|
|
20
|
+
lastAssistantMsg: string;
|
|
21
|
+
turnCount: number;
|
|
22
|
+
files: string[];
|
|
23
|
+
recent: Turn[];
|
|
24
|
+
}
|
|
25
|
+
export declare const RECENT_TURNS: {
|
|
26
|
+
count: number;
|
|
27
|
+
turnChars: number;
|
|
28
|
+
totalChars: number;
|
|
29
|
+
};
|
|
30
|
+
export interface RecentSession {
|
|
31
|
+
providerId: ProviderId;
|
|
32
|
+
sessionId: string;
|
|
33
|
+
projectDir: string;
|
|
34
|
+
updatedAt: number;
|
|
35
|
+
file: string;
|
|
36
|
+
model?: string;
|
|
37
|
+
accountId?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface SessionIdentity {
|
|
40
|
+
projectDir: string;
|
|
41
|
+
sessionId: string;
|
|
42
|
+
}
|
|
43
|
+
export declare function recentSessionModel(session: RecentSession): Promise<string>;
|
|
44
|
+
export declare function claudeProjectDir(file: string): string | null;
|
|
45
|
+
export declare function latestClaudeSession(root: string, identity?: SessionIdentity): RecentSession | null;
|
|
46
|
+
export declare function claudeSessionById(root: string, sessionId: string): RecentSession | null;
|
|
47
|
+
export declare function latestCodexSession(root: string, identity?: SessionIdentity): Promise<RecentSession | null>;
|
|
48
|
+
export declare function latestCursorSession(homeDir: string, identity?: SessionIdentity): RecentSession | null;
|
|
49
|
+
export interface StoppedSession extends RecentSession {
|
|
50
|
+
stoppedAt: number;
|
|
51
|
+
}
|
|
52
|
+
export declare function claudeLimitStop(file: string): {
|
|
53
|
+
stoppedAt: number;
|
|
54
|
+
cwd: string;
|
|
55
|
+
headless: boolean;
|
|
56
|
+
} | null;
|
|
57
|
+
export declare function claudeSessionMode(file: string): {
|
|
58
|
+
model?: string;
|
|
59
|
+
permissionMode?: ClaudePermissionMode;
|
|
60
|
+
};
|
|
61
|
+
export interface BackgroundTask {
|
|
62
|
+
id: string;
|
|
63
|
+
command: string;
|
|
64
|
+
outputFile: string | null;
|
|
65
|
+
startedAt: number;
|
|
66
|
+
}
|
|
67
|
+
export declare function claudeBackgroundTasks(file: string): Promise<BackgroundTask[]>;
|
|
68
|
+
export declare function claudeLastWrittenAt(file: string): number;
|
|
69
|
+
export interface TranscriptWrite {
|
|
70
|
+
lastRecordAt: number;
|
|
71
|
+
size: number;
|
|
72
|
+
}
|
|
73
|
+
export declare function claudeTranscriptWrite(file: string): TranscriptWrite | null;
|
|
74
|
+
export declare function claudeTurnEnded(file: string): boolean;
|
|
75
|
+
export declare function codexLimitStop(file: string): {
|
|
76
|
+
stoppedAt: number;
|
|
77
|
+
} | null;
|
|
78
|
+
export declare function claudeLimitStops(root: string, now: number, maxAgeMs: number): StoppedSession[];
|
|
79
|
+
export declare function codexLimitStops(root: string, now: number, maxAgeMs: number): Promise<StoppedSession[]>;
|
|
80
|
+
export declare function listProviderSessions(providerId: ProviderId | Auto, projectDir: string, sources: SessionSources): Promise<SessionSummary[]>;
|
|
81
|
+
export interface SessionQuery {
|
|
82
|
+
providerId: ProviderId | Auto;
|
|
83
|
+
sessionId: string;
|
|
84
|
+
projectDir: string;
|
|
85
|
+
sources: SessionSources;
|
|
86
|
+
}
|
|
87
|
+
export declare function readSession({ providerId, sessionId, projectDir, sources }: SessionQuery): Promise<Transcript | null>;
|