@askalf/dario 6.20.3 → 6.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -213,7 +213,7 @@ dario is the routing layer of **[Own Your Stack](https://github.com/askalf)**, o
213
213
 
214
214
  dario is part of **Own Your Stack**, the open toolkit behind **[Sprayberry Labs](https://sprayberrylabs.com)**, the software studio with one human on staff, run by [askalf](https://askalf.org), the AI operation these tools are part of.
215
215
 
216
- Built in the open, scars included. Follow the build: **[@ask_alf](https://x.com/ask_alf)** · **[ownyourstack.sprayberrylabs.com](https://ownyourstack.sprayberrylabs.com)**
216
+ Built in the open, scars included. Follow the build: **[@ask_alf](https://x.com/ask_alf)**
217
217
 
218
218
  ### Sponsors
219
219
 
@@ -19,32 +19,81 @@
19
19
  * made after it, so the instances' clocks must agree (NTP) to well under the
20
20
  * time between a removal and a later login.
21
21
  *
22
+ * Admin API logins that are started but not yet completed ride the same
23
+ * service (`/logins/<claude|codex>/...`, see SharedPendingLogins below), so
24
+ * a login started on one replica completes on another.
25
+ *
22
26
  * Every call fails open: with the service unreachable an instance serves the
23
27
  * accounts on its own disk, logging once per outage. The registry holds
24
28
  * every seat's full credentials, refresh tokens included, with no expiry, so
25
29
  * DARIO_REFRESH_LOCK_TOKEN reads every seat; that is why this is an opt-in
26
30
  * separate from `--pool-shared-state`.
31
+ *
32
+ * ChatGPT seats ride the same switch and the same rules in a registry of their
33
+ * own (`/codex-accounts/...`, codex-account-sync.ts), since aliases are a
34
+ * namespace per engine: a second AccountSync, with an AccountRegistry that
35
+ * says where its records live and how to read them.
27
36
  */
28
37
  import { type AccountCredentials } from './accounts.js';
38
+ /** What the sync needs of a record, whichever engine it belongs to. */
39
+ export interface SyncedCredentials {
40
+ alias: string;
41
+ accessToken: string;
42
+ refreshToken: string;
43
+ expiresAt: number;
44
+ grantedAt?: number;
45
+ }
29
46
  /** Where the sync reads and writes this instance's accounts. accounts.ts in production; a test passes its own. */
30
- export interface AccountSyncStore {
31
- loadAll(): Promise<AccountCredentials[]>;
32
- save(creds: AccountCredentials, opts: {
47
+ export interface AccountSyncStore<R extends SyncedCredentials = AccountCredentials> {
48
+ loadAll(): Promise<R[]>;
49
+ save(creds: R, opts: {
33
50
  fetchedAt: number;
34
51
  removedAt?: number;
35
52
  }): Promise<{
36
53
  saved: boolean;
37
- replaced?: AccountCredentials;
54
+ replaced?: R;
38
55
  }>;
39
56
  remove(alias: string, removedAt: number): Promise<boolean>;
40
57
  /** Removals this process made, alias to epoch ms. */
41
58
  localRemovals(): ReadonlyMap<string, number>;
42
59
  }
43
60
  export declare const accountFileStore: AccountSyncStore;
61
+ /** A started, not yet completed admin API login: the PKCE secrets and when they lapse. */
62
+ export interface SharedLogin {
63
+ codeVerifier: string;
64
+ state: string;
65
+ expiresAt: number;
66
+ }
67
+ /** Which engine's pending logins: they are separate namespaces, as the seats are. */
68
+ export type LoginEngine = 'claude' | 'codex';
69
+ /**
70
+ * Pending admin API logins held on the shared service, so any instance can
71
+ * complete one that another started. The verifier and state are secrets and
72
+ * are stored as the registry stores credentials. 'failed' is the service
73
+ * not answering or not knowing the endpoint (an older service); the caller
74
+ * then keeps the login on this instance only.
75
+ */
76
+ export interface SharedPendingLogins {
77
+ /**
78
+ * Publish a login for `alias`, replacing any other. 'full' when the service already holds its cap of other aliases.
79
+ * With `create`, a live login under the alias is left alone and the answer is 'taken'.
80
+ */
81
+ put(engine: LoginEngine, alias: string, login: SharedLogin, ttlMs: number, create?: boolean): Promise<'stored' | 'full' | 'taken' | 'failed'>;
82
+ /** The login for `alias`, null when the service holds none (or it lapsed). Does not consume it. */
83
+ get(engine: LoginEngine, alias: string): Promise<SharedLogin | null | 'failed'>;
84
+ /**
85
+ * Delete the login for `alias` whose state is `state`, and only that one. True when this call removed
86
+ * it live; false when none was there, it lapsed, or another login has replaced it (another instance
87
+ * completed or restarted it first), which the caller must treat as lost.
88
+ */
89
+ remove(engine: LoginEngine, alias: string, state: string): Promise<boolean | 'failed'>;
90
+ /** The aliases with a pending login on any instance. */
91
+ aliases(engine: LoginEngine): Promise<string[] | 'failed'>;
92
+ }
44
93
  /** What one sync changed on this instance, handed to the running proxy. */
45
- export interface AccountSyncChange {
94
+ export interface AccountSyncChange<R extends SyncedCredentials = AccountCredentials> {
46
95
  /** Records the pool lacks or holds older credentials for. */
47
- records: AccountCredentials[];
96
+ records: R[];
48
97
  /** Seats the pool still holds whose account was removed. */
49
98
  removed: Array<{
50
99
  alias: string;
@@ -72,19 +121,35 @@ export interface AccountSyncStatus {
72
121
  errors: number;
73
122
  lastError: string | null;
74
123
  }
75
- export interface AccountSyncOptions {
124
+ /** One engine's registry on the service: where its records live and how to read them. */
125
+ export interface AccountRegistry<R extends SyncedCredentials> {
126
+ /** The service path it answers under: `<path>/list`, `<path>/<alias>/put`, `<path>/<alias>/remove`. */
127
+ path: string;
128
+ /** What the log calls one record. */
129
+ noun: string;
130
+ store: AccountSyncStore<R>;
131
+ /** A registry record complete enough to serve from, stored under its own alias. */
132
+ isRecord(alias: string, rec: unknown): rec is R;
133
+ /** Names a record in a log line, never with its credentials. */
134
+ describe(creds: R): string;
135
+ /** Whether two records under one alias are different subscriptions. */
136
+ differ(a: R, b: R): boolean;
137
+ }
138
+ export interface AccountSyncOptions<R extends SyncedCredentials = AccountCredentials> {
76
139
  baseUrl: string;
77
140
  token: string;
78
141
  /** Sync interval, ms. Default 15000. */
79
142
  intervalMs?: number;
80
- store?: AccountSyncStore;
143
+ store?: AccountSyncStore<R>;
81
144
  log?: (line: string) => void;
145
+ /** Default: the Claude accounts. */
146
+ registry?: AccountRegistry<R>;
82
147
  }
83
148
  export declare const DEFAULT_ACCOUNT_SYNC_INTERVAL_MS = 15000;
84
- export interface AccountSyncPlan {
149
+ export interface AccountSyncPlan<R extends SyncedCredentials = AccountCredentials> {
85
150
  /** Registry records to write here, each with the removal time it must outlive. */
86
151
  save: Array<{
87
- creds: AccountCredentials;
152
+ creds: R;
88
153
  removedAt?: number;
89
154
  }>;
90
155
  /** Local files a removal covers. */
@@ -93,7 +158,7 @@ export interface AccountSyncPlan {
93
158
  removedAt: number;
94
159
  }>;
95
160
  /** Local records newer than the registry's. */
96
- push: AccountCredentials[];
161
+ push: R[];
97
162
  }
98
163
  /** The latest removal of `alias` either side knows of, or undefined. */
99
164
  export declare function removalTime(alias: string, markers: ReadonlyMap<string, number>, localRemovals: ReadonlyMap<string, number>): number | undefined;
@@ -104,7 +169,7 @@ export declare function removalTime(alias: string, markers: ReadonlyMap<string,
104
169
  * newer wins. The registry's goes to disk, ours goes to the registry, and a
105
170
  * local file no record outlives is removed.
106
171
  */
107
- export declare function planAccountSync(local: readonly AccountCredentials[], registry: ReadonlyMap<string, AccountCredentials>, markers: ReadonlyMap<string, number>, localRemovals: ReadonlyMap<string, number>): AccountSyncPlan;
172
+ export declare function planAccountSync<R extends SyncedCredentials>(local: readonly R[], registry: ReadonlyMap<string, R>, markers: ReadonlyMap<string, number>, localRemovals: ReadonlyMap<string, number>): AccountSyncPlan<R>;
108
173
  /** A registry record complete enough to serve from, stored under its own alias. */
109
174
  export declare function isRegistryRecord(alias: string, rec: unknown): rec is AccountCredentials;
110
175
  /** How `dario accounts remove` ended with the shared account registry on. */
@@ -124,16 +189,19 @@ export type SharedRemovalResult =
124
189
  * goes only once the registry took the removal, and only when it holds no
125
190
  * login granted after it. Throws when the service does not answer.
126
191
  */
127
- export declare function removeAccountEverywhere(baseUrl: string, token: string, alias: string, opts?: {
128
- store?: AccountSyncStore;
192
+ export declare function removeAccountEverywhere<R extends SyncedCredentials = AccountCredentials>(baseUrl: string, token: string, alias: string, opts?: {
193
+ store?: AccountSyncStore<R>;
129
194
  removedAt?: number;
195
+ registry?: AccountRegistry<R>;
130
196
  }): Promise<SharedRemovalResult>;
131
- export declare class AccountSync {
197
+ export declare class AccountSync<R extends SyncedCredentials = AccountCredentials> implements SharedPendingLogins {
132
198
  readonly intervalMs: number;
133
199
  private readonly baseUrl;
134
200
  private readonly token;
201
+ private readonly registry;
135
202
  private readonly store;
136
203
  private readonly log;
204
+ private readonly tag;
137
205
  private onChange;
138
206
  private live;
139
207
  private timer;
@@ -143,14 +211,14 @@ export declare class AccountSync {
143
211
  private markers;
144
212
  private down;
145
213
  private stats;
146
- constructor(opts: AccountSyncOptions);
214
+ constructor(opts: AccountSyncOptions<R>);
147
215
  /**
148
216
  * Connect a running pool: `live` reads a seat's credentials, `onChange`
149
217
  * gets what each later sync changed. Until then a sync updates the
150
218
  * accounts directory only, which is all the startup sync needs, since the
151
219
  * pool loads from that directory after it.
152
220
  */
153
- attach(live: (alias: string) => LiveSeat | undefined, onChange: (change: AccountSyncChange) => Promise<void> | void): void;
221
+ attach(live: (alias: string) => LiveSeat | undefined, onChange: (change: AccountSyncChange<R>) => Promise<void> | void): void;
154
222
  /** Sync on the interval. The timer never keeps the process alive. */
155
223
  start(): void;
156
224
  stop(): void;
@@ -162,8 +230,14 @@ export declare class AccountSync {
162
230
  * login the sync just removed.
163
231
  */
164
232
  removalOf(alias: string): number | undefined;
233
+ private loginPath;
234
+ private loginFailed;
235
+ put(engine: LoginEngine, alias: string, login: SharedLogin, ttlMs: number, create?: boolean): Promise<'stored' | 'full' | 'taken' | 'failed'>;
236
+ get(engine: LoginEngine, alias: string): Promise<SharedLogin | null | 'failed'>;
237
+ remove(engine: LoginEngine, alias: string, state: string): Promise<boolean | 'failed'>;
238
+ aliases(engine: LoginEngine): Promise<string[] | 'failed'>;
165
239
  /** One sync. Null when the registry could not be read; never throws. */
166
- syncOnce(): Promise<AccountSyncChange | null>;
240
+ syncOnce(): Promise<AccountSyncChange<R> | null>;
167
241
  private run;
168
242
  /**
169
243
  * One POST to the service. Null on any failure: the caller carries on with
@@ -19,11 +19,20 @@
19
19
  * made after it, so the instances' clocks must agree (NTP) to well under the
20
20
  * time between a removal and a later login.
21
21
  *
22
+ * Admin API logins that are started but not yet completed ride the same
23
+ * service (`/logins/<claude|codex>/...`, see SharedPendingLogins below), so
24
+ * a login started on one replica completes on another.
25
+ *
22
26
  * Every call fails open: with the service unreachable an instance serves the
23
27
  * accounts on its own disk, logging once per outage. The registry holds
24
28
  * every seat's full credentials, refresh tokens included, with no expiry, so
25
29
  * DARIO_REFRESH_LOCK_TOKEN reads every seat; that is why this is an opt-in
26
30
  * separate from `--pool-shared-state`.
31
+ *
32
+ * ChatGPT seats ride the same switch and the same rules in a registry of their
33
+ * own (`/codex-accounts/...`, codex-account-sync.ts), since aliases are a
34
+ * namespace per engine: a second AccountSync, with an AccountRegistry that
35
+ * says where its records live and how to read them.
27
36
  */
28
37
  import { loadAllAccounts, saveSharedAccount, removeSharedAccount, localAccountRemovals, credentialsAreNewer, grantedAfter, isValidAccountAlias, } from './accounts.js';
29
38
  import { maskEmail } from './pool.js';
@@ -34,6 +43,18 @@ export const accountFileStore = {
34
43
  remove: removeSharedAccount,
35
44
  localRemovals: localAccountRemovals,
36
45
  };
46
+ function parseSharedLogin(raw) {
47
+ const r = raw;
48
+ if (!r || typeof r !== 'object')
49
+ return null;
50
+ if (typeof r.codeVerifier !== 'string' || r.codeVerifier.length === 0)
51
+ return null;
52
+ if (typeof r.state !== 'string' || r.state.length === 0)
53
+ return null;
54
+ if (typeof r.expiresAt !== 'number' || !Number.isFinite(r.expiresAt))
55
+ return null;
56
+ return { codeVerifier: r.codeVerifier, state: r.state, expiresAt: r.expiresAt };
57
+ }
37
58
  export const DEFAULT_ACCOUNT_SYNC_INTERVAL_MS = 15_000;
38
59
  const CALL_TIMEOUT_MS = 3_000;
39
60
  /** The latest removal of `alias` either side knows of, or undefined. */
@@ -83,12 +104,12 @@ export function isRegistryRecord(alias, rec) {
83
104
  && typeof r.expiresAt === 'number' && Number.isFinite(r.expiresAt)
84
105
  && Array.isArray(r.scopes) && typeof r.deviceId === 'string' && typeof r.accountUuid === 'string';
85
106
  }
86
- function parseRegistry(raw) {
107
+ function parseRegistry(raw, isRecord) {
87
108
  const out = new Map();
88
109
  if (!raw || typeof raw !== 'object')
89
110
  return out;
90
111
  for (const [alias, rec] of Object.entries(raw))
91
- if (isRegistryRecord(alias, rec))
112
+ if (isRecord(alias, rec))
92
113
  out.set(alias, rec);
93
114
  return out;
94
115
  }
@@ -121,9 +142,10 @@ async function registryCall(baseUrl, token, path, body) {
121
142
  * login granted after it. Throws when the service does not answer.
122
143
  */
123
144
  export async function removeAccountEverywhere(baseUrl, token, alias, opts = {}) {
124
- const store = opts.store ?? accountFileStore;
145
+ const registry = opts.registry ?? CLAUDE_REGISTRY;
146
+ const store = opts.store ?? registry.store;
125
147
  const removedAt = opts.removedAt ?? Date.now();
126
- const res = await registryCall(baseUrl, token, `/accounts/${encodeURIComponent(alias)}/remove`, { removedAt });
148
+ const res = await registryCall(baseUrl, token, `${registry.path}/${encodeURIComponent(alias)}/remove`, { removedAt });
127
149
  if (res.removed !== true)
128
150
  return 'registry-newer';
129
151
  if (await store.remove(alias, removedAt))
@@ -139,12 +161,22 @@ export async function removeAccountEverywhere(baseUrl, token, alias, opts = {})
139
161
  function describeAccount(c) {
140
162
  return maskEmail(c.accountEmail) ?? c.accountId ?? 'unknown account';
141
163
  }
164
+ const CLAUDE_REGISTRY = {
165
+ path: '/accounts',
166
+ noun: 'account',
167
+ store: accountFileStore,
168
+ isRecord: isRegistryRecord,
169
+ describe: describeAccount,
170
+ differ: (a, b) => !!a.accountId && !!b.accountId && a.accountId !== b.accountId,
171
+ };
142
172
  export class AccountSync {
143
173
  intervalMs;
144
174
  baseUrl;
145
175
  token;
176
+ registry;
146
177
  store;
147
178
  log;
179
+ tag;
148
180
  onChange = null;
149
181
  live = null;
150
182
  timer = null;
@@ -166,8 +198,10 @@ export class AccountSync {
166
198
  this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
167
199
  this.token = opts.token;
168
200
  this.intervalMs = clampTimerMs(Math.max(1_000, opts.intervalMs ?? DEFAULT_ACCOUNT_SYNC_INTERVAL_MS));
169
- this.store = opts.store ?? accountFileStore;
201
+ this.registry = opts.registry ?? CLAUDE_REGISTRY;
202
+ this.store = opts.store ?? this.registry.store;
170
203
  this.log = opts.log ?? ((line) => console.error(line));
204
+ this.tag = `[dario] shared ${this.registry.noun}s:`;
171
205
  }
172
206
  /**
173
207
  * Connect a running pool: `live` reads a seat's credentials, `onChange`
@@ -203,6 +237,42 @@ export class AccountSync {
203
237
  removalOf(alias) {
204
238
  return removalTime(alias, this.markers, this.store.localRemovals());
205
239
  }
240
+ loginPath(engine, alias, action) {
241
+ return `/logins/${engine}/${encodeURIComponent(alias)}/${action}`;
242
+ }
243
+ // Never logs the login: the line carries the alias and the transport error only.
244
+ loginFailed(what, alias) {
245
+ this.log(`${this.tag} could not ${what} the pending login for "${alias}" on the service (${this.stats.lastError ?? 'no answer'}); using this instance's copy`);
246
+ return 'failed';
247
+ }
248
+ async put(engine, alias, login, ttlMs, create = false) {
249
+ const out = await this.call(this.loginPath(engine, alias, 'put'), create ? { login, ttlMs, create: true } : { login, ttlMs });
250
+ if (!out)
251
+ return this.loginFailed('publish', alias);
252
+ if (out.stored === true)
253
+ return 'stored';
254
+ if (out.full === true)
255
+ return 'full';
256
+ return out.taken === true ? 'taken' : this.loginFailed('publish', alias);
257
+ }
258
+ async get(engine, alias) {
259
+ const out = await this.call(this.loginPath(engine, alias, 'get'), {});
260
+ if (!out)
261
+ return this.loginFailed('read', alias);
262
+ return parseSharedLogin(out.login);
263
+ }
264
+ async remove(engine, alias, state) {
265
+ const out = await this.call(this.loginPath(engine, alias, 'remove'), { state });
266
+ if (!out)
267
+ return this.loginFailed('remove', alias);
268
+ return out.removed === true;
269
+ }
270
+ async aliases(engine) {
271
+ const out = await this.call(`/logins/${engine}/list`, {});
272
+ if (!out)
273
+ return 'failed';
274
+ return Array.isArray(out.aliases) ? out.aliases.filter((a) => typeof a === 'string') : [];
275
+ }
206
276
  /** One sync. Null when the registry could not be read; never throws. */
207
277
  syncOnce() {
208
278
  if (this.queued)
@@ -212,7 +282,7 @@ export class AccountSync {
212
282
  return this.run().catch((err) => {
213
283
  this.stats.errors++;
214
284
  this.stats.lastError = err instanceof Error ? err.message : String(err);
215
- this.log(`[dario] shared accounts: sync failed (${this.stats.lastError}); retrying on the next sync`);
285
+ this.log(`${this.tag} sync failed (${this.stats.lastError}); retrying on the next sync`);
216
286
  return null;
217
287
  });
218
288
  });
@@ -227,17 +297,17 @@ export class AccountSync {
227
297
  for (const [alias, at] of this.store.localRemovals()) {
228
298
  if ((this.published.get(alias) ?? -Infinity) >= at)
229
299
  continue;
230
- const res = await this.call(`/accounts/${encodeURIComponent(alias)}/remove`, { removedAt: at });
300
+ const res = await this.call(`${this.registry.path}/${encodeURIComponent(alias)}/remove`, { removedAt: at });
231
301
  if (!res)
232
302
  break;
233
303
  this.published.set(alias, at);
234
304
  this.stats.removalsPublished++;
235
305
  }
236
306
  const fetchedAt = Date.now();
237
- const res = await this.call('/accounts/list', {});
307
+ const res = await this.call(`${this.registry.path}/list`, {});
238
308
  if (!res)
239
309
  return null;
240
- const registry = parseRegistry(res.accounts);
310
+ const registry = parseRegistry(res.accounts, this.registry.isRecord);
241
311
  const markers = parseMarkers(res.removed);
242
312
  this.markers = markers;
243
313
  this.stats.registryAccounts = registry.size;
@@ -260,7 +330,7 @@ export class AccountSync {
260
330
  kept.delete(alias);
261
331
  removed.push({ alias, removedAt });
262
332
  this.stats.removed++;
263
- this.log(`[dario] shared accounts: removed account "${alias}"; it was removed on another instance`);
333
+ this.log(`${this.tag} removed ${this.registry.noun} "${alias}"; it was removed on another instance`);
264
334
  }
265
335
  for (const { creds, removedAt } of plan.save) {
266
336
  const out = await this.store.save(creds, removedAt === undefined ? { fetchedAt } : { fetchedAt, removedAt });
@@ -270,13 +340,13 @@ export class AccountSync {
270
340
  this.stats.saved++;
271
341
  const was = out.replaced;
272
342
  if (!was) {
273
- this.log(`[dario] shared accounts: added account "${creds.alias}" (${describeAccount(creds)}) from the registry`);
343
+ this.log(`${this.tag} added ${this.registry.noun} "${creds.alias}" (${this.registry.describe(creds)}) from the registry`);
274
344
  }
275
- else if (was.accountId && creds.accountId && was.accountId !== creds.accountId) {
345
+ else if (this.registry.differ(was, creds)) {
276
346
  // Aliases are one namespace across every instance. Two instances
277
347
  // that each logged a different subscription in under one alias
278
348
  // (`login`, most often) keep only the newer grant.
279
- this.log(`[dario] shared accounts: account "${creds.alias}" here was ${describeAccount(was)} and is now ${describeAccount(creds)}, a newer login under the same alias on another instance; aliases are shared across instances, so give each subscription its own alias`);
349
+ this.log(`${this.tag} ${this.registry.noun} "${creds.alias}" here was ${this.registry.describe(was)} and is now ${this.registry.describe(creds)}, a newer login under the same alias on another instance; aliases are shared across instances, so give each subscription its own alias`);
280
350
  }
281
351
  }
282
352
  for (const creds of plan.push) {
@@ -285,14 +355,14 @@ export class AccountSync {
285
355
  const removedSince = this.store.localRemovals().get(creds.alias);
286
356
  if (!grantedAfter(creds, removedSince))
287
357
  continue;
288
- const out = await this.call(`/accounts/${encodeURIComponent(creds.alias)}/put`, { credentials: creds });
358
+ const out = await this.call(`${this.registry.path}/${encodeURIComponent(creds.alias)}/put`, { credentials: creds });
289
359
  if (!out)
290
360
  break;
291
361
  if (!out.stored)
292
362
  continue;
293
363
  this.stats.pushed++;
294
364
  if (!registry.has(creds.alias))
295
- this.log(`[dario] shared accounts: published account "${creds.alias}" to the registry`);
365
+ this.log(`${this.tag} published ${this.registry.noun} "${creds.alias}" to the registry`);
296
366
  }
297
367
  const change = { records: [], removed };
298
368
  if (this.live) {
@@ -334,7 +404,7 @@ export class AccountSync {
334
404
  this.stats.lastOkAt = Date.now();
335
405
  if (this.down) {
336
406
  this.down = false;
337
- this.log(`[dario] shared accounts: service reachable again (${this.baseUrl})`);
407
+ this.log(`${this.tag} service reachable again (${this.baseUrl})`);
338
408
  }
339
409
  return out;
340
410
  }
@@ -343,7 +413,7 @@ export class AccountSync {
343
413
  this.stats.lastError = err instanceof Error ? err.message : String(err);
344
414
  if (!this.down) {
345
415
  this.down = true;
346
- this.log(`[dario] shared accounts: ${this.baseUrl} unreachable (${this.stats.lastError}); serving the accounts on this instance's disk until it answers again`);
416
+ this.log(`${this.tag} ${this.baseUrl} unreachable (${this.stats.lastError}); serving the accounts on this instance's disk until it answers again`);
347
417
  }
348
418
  return null;
349
419
  }
@@ -77,6 +77,7 @@
77
77
  import type { IncomingMessage, ServerResponse } from 'node:http';
78
78
  import type { CodexSeatState } from './codex-accounts.js';
79
79
  import { type CodexUsageView } from './codex-usage.js';
80
+ import type { SharedPendingLogins } from './account-sync.js';
80
81
  import { type KeyStore } from './keys.js';
81
82
  /** Persisted account metadata surfaced by `GET /admin/accounts`. */
82
83
  export interface AdminAccountRecord {
@@ -226,6 +227,13 @@ export interface AdminDeps {
226
227
  * off on this proxy; the routes answer 404.
227
228
  */
228
229
  keys?: KeyStore | null;
230
+ /**
231
+ * The shared account registry's pending-login store (account-sync.ts), set
232
+ * when `--pool-shared-accounts` is on. A login started here is also
233
+ * published there, so any instance can complete it. Absent: pending logins
234
+ * live in this process only.
235
+ */
236
+ sharedLogins?: SharedPendingLogins | null;
229
237
  }
230
238
  /**
231
239
  * Handle an `/admin/*` request. Returns `true` if it owned the request (matched