@bridge4dev/runner 0.65.1 → 0.66.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.
@@ -0,0 +1,1567 @@
1
+ import { execFile } from 'node:child_process';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { sessionClaudePath } from './agent-binary.js';
7
+ import { readAgentAuth, storedClaudeToken, storedClaudeTokenRefused, updateAgentAuth, } from './agent-auth.js';
8
+ import { invalidateUsageCache, lastUsageRows, } from './adapters/claude-usage.js';
9
+ import { MACHINE_ACCOUNT_ID, clearRefusal, refusalActive } from './login-marks.js';
10
+ import { log } from './log.js';
11
+ import { stateDir } from './paths.js';
12
+ import { lowerPriority } from './process-priority.js';
13
+ /**
14
+ * Several Claude logins on one machine – one HOME per account (#422, D1).
15
+ *
16
+ * An account is a small directory of its own: its login (`.credentials.json`)
17
+ * and the CLI's working files (`.claude.json` – where the last measured limits
18
+ * live – `sessions/`, `backups/`). Everything else is a link into the machine's
19
+ * `~/.claude`: the transcripts (without them no conversation resumes across
20
+ * accounts), settings, plugins, agents, commands, hooks, skills. Switching is
21
+ * choosing which home the NEXT session starts with – `CLAUDE_CONFIG_DIR` in the
22
+ * session's own environment, never in the daemon's (risk К1 of the plan).
23
+ *
24
+ * The login of this machine is the default home, `~/.claude`. It is the first
25
+ * row, it cannot be forgotten, and while it is active nothing here sets any
26
+ * variable – the behaviour is exactly the pre-#422 one. No code in this module
27
+ * writes, moves or deletes the machine's own login file: the one legitimate way
28
+ * to touch it stays the person's «Log in again» on that row, which is the old
29
+ * sign-in path in `auth-relay.ts`, not this file.
30
+ *
31
+ * NOT ONE COPY of a login file, by construction rather than by agreement. The
32
+ * CLI's token-refresh lock is taken by the PATH of the file, so a copy in a
33
+ * second home is a second holder of a one-time refresh token – the copy that
34
+ * signed out 47 agents and four sessions on 08.09.2026 (gotcha 460). Every
35
+ * login here exists in exactly one place; a login is only ever moved into its
36
+ * home by `rename`, and a `rename` that would have to cross filesystems is
37
+ * refused instead of falling back to a copy.
38
+ *
39
+ * Proven live on this machine before a line of it was written (S1 item 1,
40
+ * 17.09.2026, CLI 2.1.273, SDK 0.3.226): a session started through the Agent
41
+ * SDK with `CLAUDE_CONFIG_DIR` in `env` ran under that home's subscription, its
42
+ * transcript landed in the machine's `projects/` through the link, and the
43
+ * same session resumed under the machine login – a different subscription –
44
+ * and back again, with its context.
45
+ */
46
+ /** Where the saved homes live – next to the runner's other private state. */
47
+ export function claudeHomesDir() {
48
+ return path.join(stateDir(), 'claude-homes');
49
+ }
50
+ /**
51
+ * The machine's own Claude home. `os.homedir()` rather than anything cleverer:
52
+ * it is what the CLI itself uses when no `CLAUDE_CONFIG_DIR` is set, which is
53
+ * exactly the definition of «the machine login».
54
+ */
55
+ export function machineHome(homedir = os.homedir()) {
56
+ return path.join(homedir, '.claude');
57
+ }
58
+ /**
59
+ * The CLI keeps its global config NEXT TO its home, not inside it, when the home
60
+ * is the default one: `$HOME/.claude.json`, and `~/.claude/.claude.json` is a
61
+ * 309-byte stub on this machine (R19). Under `CLAUDE_CONFIG_DIR` it is inside.
62
+ */
63
+ function configFileOf(home, homedir = os.homedir()) {
64
+ return home === null ? path.join(homedir, '.claude.json') : path.join(home, '.claude.json');
65
+ }
66
+ function credentialsFileOf(home, homedir = os.homedir()) {
67
+ return path.join(home ?? machineHome(homedir), '.credentials.json');
68
+ }
69
+ const ACCOUNT_ID = /^[a-z0-9]{12}$/;
70
+ /** A saved account's id: twelve lowercase letters and digits, never `machine`. */
71
+ export function isAccountId(id) {
72
+ return typeof id === 'string' && ACCOUNT_ID.test(id);
73
+ }
74
+ function newAccountId() {
75
+ // base32-ish from random bytes: 12 characters of [a-z0-9].
76
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
77
+ const bytes = crypto.randomBytes(12);
78
+ return Array.from(bytes, (byte) => alphabet[byte % alphabet.length]).join('');
79
+ }
80
+ /** The home of a saved account. Throws on anything that is not an account id. */
81
+ export function accountHome(id) {
82
+ if (!isAccountId(id))
83
+ throw new AccountError(`not an account id: ${String(id).slice(0, 40)}`);
84
+ return path.join(claudeHomesDir(), id);
85
+ }
86
+ /** A refusal meant for a person: the wire (S2) sends its message as it is. */
87
+ export class AccountError extends Error {
88
+ constructor(message) {
89
+ super(message);
90
+ this.name = 'AccountError';
91
+ }
92
+ }
93
+ function lstatOrNull(target) {
94
+ try {
95
+ return fs.lstatSync(target);
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ function realpathOrResolve(target) {
102
+ try {
103
+ return fs.realpathSync(target);
104
+ }
105
+ catch {
106
+ return path.resolve(target);
107
+ }
108
+ }
109
+ function sameDirectory(a, b) {
110
+ return realpathOrResolve(a) === realpathOrResolve(b);
111
+ }
112
+ /** Is `target` the directory `parent` or anything inside it, links resolved? */
113
+ function isWithin(target, parent) {
114
+ const resolved = realpathOrResolve(target);
115
+ const base = realpathOrResolve(parent);
116
+ return resolved === base || resolved.startsWith(base + path.sep);
117
+ }
118
+ function ensureRoot() {
119
+ const root = claudeHomesDir();
120
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
121
+ // It holds logins; an existing directory keeps whatever mode it was made with.
122
+ fs.chmodSync(root, 0o700);
123
+ return root;
124
+ }
125
+ // ─── What every home shares with the machine ─────────────────────────
126
+ /**
127
+ * What a saved home reaches through a link rather than owning (R20).
128
+ *
129
+ * `projects` is the one that must never be missing: it is where transcripts
130
+ * live, and a session resumes only from the home it can see them in. The rest
131
+ * is parity with the terminal – the machine's settings (hooks, the search
132
+ * guard, enabled plugins), its plugins, agents, commands, hooks and skills.
133
+ * Deliberately NOT here: `.credentials.json` and `.claude.json` (they are the
134
+ * account), and the CLI's per-process scratch (`sessions`, `session-env`,
135
+ * `shell-snapshots`, `backups`, …), which it recreates on its own.
136
+ */
137
+ export const SHARED_ENTRIES = [
138
+ 'projects',
139
+ 'settings.json',
140
+ 'settings.local.json',
141
+ 'CLAUDE.md',
142
+ 'plugins',
143
+ 'agents',
144
+ 'commands',
145
+ 'hooks',
146
+ 'skills',
147
+ ];
148
+ /**
149
+ * Create and repair the links of a saved home into the machine's `~/.claude`.
150
+ *
151
+ * A link is set only where its target exists; `projects` is the exception and
152
+ * gets its target created, because a home without it would quietly start a
153
+ * transcript directory of its own and every later resume across accounts would
154
+ * fail to find the conversation.
155
+ *
156
+ * A link the CLI replaced with a real file or directory (an atomic write of
157
+ * `settings.json` does exactly that) is NOT silently re-linked: the thing in
158
+ * its place may be the only copy of what was written through it. It is moved
159
+ * aside under a dated name and logged, and the link is put back.
160
+ *
161
+ * Never follows a link to remove anything, never writes into the machine home
162
+ * beyond creating its empty `projects/`.
163
+ */
164
+ export function ensureSharedLinks(home, machine = machineHome()) {
165
+ // A real directory, and not the machine home by any spelling: a home that is
166
+ // itself a link resolves `<home>/projects` INTO the machine home, and the
167
+ // repair below would move the machine's own entries aside.
168
+ if (!lstatOrNull(home)?.isDirectory() || sameDirectory(home, machine)) {
169
+ throw new AccountError('the machine home has no links of its own');
170
+ }
171
+ const changes = [];
172
+ for (const name of SHARED_ENTRIES) {
173
+ const link = path.join(home, name);
174
+ const target = path.join(machine, name);
175
+ if (name === 'projects' && !fs.existsSync(target)) {
176
+ fs.mkdirSync(target, { recursive: true });
177
+ }
178
+ const current = lstatOrNull(link);
179
+ const targetExists = fs.existsSync(target);
180
+ if (current?.isSymbolicLink()) {
181
+ const pointsAt = fs.readlinkSync(link);
182
+ if (pointsAt === target) {
183
+ if (!targetExists) {
184
+ // Ours, and pointing at nothing: the target went away. A dangling link
185
+ // is not «the file is missing» to every writer – some open through it
186
+ // and would create the target inside the machine home.
187
+ fs.unlinkSync(link);
188
+ changes.push({ name, action: 'dropped' });
189
+ }
190
+ continue;
191
+ }
192
+ if (!targetExists)
193
+ continue;
194
+ fs.unlinkSync(link);
195
+ fs.symlinkSync(target, link);
196
+ changes.push({ name, action: 'relinked' });
197
+ continue;
198
+ }
199
+ if (!targetExists)
200
+ continue;
201
+ if (current) {
202
+ const aside = `${link}.displaced-${Date.now()}`;
203
+ fs.renameSync(link, aside);
204
+ log.warn('claude-homes: a shared entry had been replaced by a real one – moved aside', {
205
+ home,
206
+ name,
207
+ aside,
208
+ });
209
+ fs.symlinkSync(target, link);
210
+ changes.push({ name, action: 'displaced', displacedTo: aside });
211
+ continue;
212
+ }
213
+ fs.symlinkSync(target, link);
214
+ changes.push({ name, action: 'linked' });
215
+ }
216
+ return changes;
217
+ }
218
+ /**
219
+ * Keys of the machine's `$HOME/.claude.json` a new home is seeded with (R20).
220
+ *
221
+ * Under `CLAUDE_CONFIG_DIR` the CLI reads a different `.claude.json` altogether,
222
+ * and that is where the person's own MCP servers and folder trust live – a home
223
+ * without them is a session without `playwright`. Also carried: onboarding
224
+ * (a fresh file asks first-run questions), the install method and the
225
+ * auto-update switch (a home without `autoUpdates: false` may let the CLI update
226
+ * the machine's binary behind the version the card pins), and the marketplace
227
+ * flags (without them a new home re-installs plugins into the SHARED plugins
228
+ * directory).
229
+ *
230
+ * Never seeded, and the tests pin it: `oauthAccount` and
231
+ * `cachedUsageUtilization` – they say whose login and whose limits the file
232
+ * describes, and a new home is somebody else until its own login says so.
233
+ */
234
+ const SEED_KEYS = [
235
+ 'mcpServers',
236
+ 'projects',
237
+ 'hasCompletedOnboarding',
238
+ 'lastOnboardingVersion',
239
+ 'installMethod',
240
+ 'autoUpdates',
241
+ 'autoUpdatesProtectedForNative',
242
+ 'officialMarketplaceAutoInstallAttempted',
243
+ 'officialMarketplaceAutoInstalled',
244
+ 'bypassPermissionsModeAccepted',
245
+ ];
246
+ /** Per project, only what configures it – not the last session's metrics. */
247
+ const PROJECT_SEED_KEYS = [
248
+ 'allowedTools',
249
+ 'mcpServers',
250
+ 'enabledMcpjsonServers',
251
+ 'disabledMcpjsonServers',
252
+ 'mcpContextUris',
253
+ 'hasTrustDialogAccepted',
254
+ 'hasClaudeMdExternalIncludesApproved',
255
+ 'hasClaudeMdExternalIncludesWarningShown',
256
+ ];
257
+ const isObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
258
+ function readJsonObject(file) {
259
+ try {
260
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
261
+ return isObject(parsed) ? parsed : null;
262
+ }
263
+ catch {
264
+ return null;
265
+ }
266
+ }
267
+ function machineSeeds(machineConfig) {
268
+ const source = readJsonObject(machineConfig);
269
+ if (!source)
270
+ return {};
271
+ const seeds = {};
272
+ for (const key of SEED_KEYS) {
273
+ if (!(key in source))
274
+ continue;
275
+ if (key === 'projects' && isObject(source[key])) {
276
+ const projects = {};
277
+ for (const [dir, entry] of Object.entries(source[key])) {
278
+ if (!isObject(entry))
279
+ continue;
280
+ const kept = Object.fromEntries(PROJECT_SEED_KEYS.filter((sub) => sub in entry).map((sub) => [sub, entry[sub]]));
281
+ if (Object.keys(kept).length > 0)
282
+ projects[dir] = kept;
283
+ }
284
+ seeds[key] = projects;
285
+ continue;
286
+ }
287
+ seeds[key] = source[key];
288
+ }
289
+ return seeds;
290
+ }
291
+ function writeJsonAtomic(file, body) {
292
+ const tmp = `${file}.devbridge-${process.pid}.tmp`;
293
+ fs.writeFileSync(tmp, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 });
294
+ fs.renameSync(tmp, file);
295
+ fs.chmodSync(file, 0o600);
296
+ }
297
+ /**
298
+ * Seed a home's `.claude.json` with the machine's configuration (R20).
299
+ *
300
+ * A home without the file gets one. A home WITH one (a login moved in from
301
+ * elsewhere) gets only the keys it does not have yet, project by project – its
302
+ * own identity and anything it configured for itself stay as they are.
303
+ *
304
+ * Only for a home no CLI is running in: the CLI rewrites this file on its own
305
+ * schedule, and a write racing it would lose one side. That is why it runs when
306
+ * a home is created or moved in, and not at every session start.
307
+ */
308
+ export function ensureHomeConfig(home, machineConfig = configFileOf(null)) {
309
+ const target = configFileOf(home);
310
+ const seeds = machineSeeds(machineConfig);
311
+ if (!fs.existsSync(target)) {
312
+ writeJsonAtomic(target, seeds);
313
+ return 'created';
314
+ }
315
+ const existing = readJsonObject(target);
316
+ if (!existing) {
317
+ // The CLI's own file, unreadable to us – not ours to replace.
318
+ log.warn('claude-homes: the home config could not be read – left as it is', { home });
319
+ return 'unchanged';
320
+ }
321
+ let changed = false;
322
+ for (const [key, value] of Object.entries(seeds)) {
323
+ if (key === 'projects' && isObject(value)) {
324
+ const projects = isObject(existing[key]) ? { ...existing[key] } : {};
325
+ for (const [dir, entry] of Object.entries(value)) {
326
+ if (!isObject(entry))
327
+ continue;
328
+ const own = isObject(projects[dir]) ? projects[dir] : {};
329
+ const merged = { ...entry, ...own };
330
+ if (JSON.stringify(merged) !== JSON.stringify(projects[dir])) {
331
+ projects[dir] = merged;
332
+ changed = true;
333
+ }
334
+ }
335
+ existing[key] = projects;
336
+ continue;
337
+ }
338
+ if (!(key in existing)) {
339
+ existing[key] = value;
340
+ changed = true;
341
+ }
342
+ }
343
+ if (!changed)
344
+ return 'unchanged';
345
+ writeJsonAtomic(target, existing);
346
+ return 'merged';
347
+ }
348
+ const IDENTITY_TIMEOUT_MS = 30_000;
349
+ /**
350
+ * How long a known identity is believed without asking again.
351
+ *
352
+ * A new sign-in in a home is caught without waiting for this (see
353
+ * `identityFreshness`); the bound only covers a plan changed on the provider's
354
+ * side with nothing on this machine touched.
355
+ */
356
+ const IDENTITY_MAX_AGE_MS = 6 * 60 * 60 * 1000;
357
+ /**
358
+ * The environment `claude auth status` runs with (R14) – and, since S2, every
359
+ * CLI run that works in a home as that home's own account: the sign-in through
360
+ * `agent_account_login_start` uses it too (§8), for the reason below.
361
+ *
362
+ * `CLAUDE_CONFIG_DIR` of the home, or none for the machine, and never the
363
+ * operator's `CLAUDE_CODE_OAUTH_TOKEN`: the CLI puts that variable above every
364
+ * login file, so on a machine that has one every home would answer with the
365
+ * token's blind identity – no e-mail, no plan, the same for all of them.
366
+ */
367
+ export function identityProbeEnv(home, base = process.env) {
368
+ const env = { ...base };
369
+ delete env['CLAUDE_CONFIG_DIR'];
370
+ delete env['CLAUDE_CODE_OAUTH_TOKEN'];
371
+ delete env['ANTHROPIC_API_KEY'];
372
+ delete env['ANTHROPIC_AUTH_TOKEN'];
373
+ if (home !== null)
374
+ env['CLAUDE_CONFIG_DIR'] = home;
375
+ return env;
376
+ }
377
+ const text = (value, max) => typeof value === 'string' && value.trim().length > 0 ? value.trim().slice(0, max) : undefined;
378
+ /** `claude auth status --json` → identity. `loggedIn: false` is no identity at all. */
379
+ export function parseAuthStatus(output) {
380
+ let parsed;
381
+ try {
382
+ parsed = JSON.parse(output);
383
+ }
384
+ catch {
385
+ return null;
386
+ }
387
+ if (!isObject(parsed) || parsed['loggedIn'] !== true)
388
+ return null;
389
+ const email = text(parsed['email'], 320);
390
+ const orgId = text(parsed['orgId'], 100);
391
+ const orgName = text(parsed['orgName'], 200);
392
+ const plan = text(parsed['subscriptionType'], 60);
393
+ return {
394
+ ...(email ? { email } : {}),
395
+ ...(orgId ? { orgId } : {}),
396
+ ...(orgName ? { orgName } : {}),
397
+ ...(plan ? { plan } : {}),
398
+ };
399
+ }
400
+ const defaultIdentityRunner = (home) => {
401
+ const binary = sessionClaudePath();
402
+ if (!binary)
403
+ return Promise.resolve(null);
404
+ let cwd;
405
+ try {
406
+ // A directory only this runner can write to: for Claude Code the working
407
+ // directory is the root of a project's settings, hooks included (QA-390
408
+ // BLOCKER – the same reason the usage probe has one).
409
+ cwd = ensureRoot();
410
+ }
411
+ catch {
412
+ return Promise.resolve(null);
413
+ }
414
+ return new Promise((resolve) => {
415
+ const child = execFile(binary, ['auth', 'status', '--json'], {
416
+ cwd,
417
+ env: identityProbeEnv(home),
418
+ timeout: IDENTITY_TIMEOUT_MS,
419
+ maxBuffer: 256 * 1024,
420
+ killSignal: 'SIGKILL',
421
+ }, (error, stdout) => {
422
+ if (error) {
423
+ log.warn('claude-homes: `claude auth status` gave no identity', {
424
+ home: home ?? 'machine',
425
+ error: String(error).slice(0, 300),
426
+ });
427
+ resolve(null);
428
+ return;
429
+ }
430
+ resolve(parseAuthStatus(stdout));
431
+ });
432
+ child.stdin?.end();
433
+ lowerPriority(child.pid);
434
+ });
435
+ };
436
+ let identityRunner = defaultIdentityRunner;
437
+ export function setIdentityRunner(run) {
438
+ identityRunner = run ?? defaultIdentityRunner;
439
+ }
440
+ /**
441
+ * One `claude auth status` at a time, machine-wide – «sequentially, home by
442
+ * home» (R14). Each is ~800 ms and ~300 MB; five homes in parallel would be a
443
+ * gigabyte and a half for a list.
444
+ */
445
+ let identityQueue = Promise.resolve();
446
+ /** Asks already queued or running, per home – two callers of one home share one CLI run. */
447
+ const identityInFlight = new Map();
448
+ /** Ask a home who it is. Only the four callers R14 names reach this. */
449
+ export function readAccountIdentity(home) {
450
+ const key = home ?? MACHINE_ACCOUNT_ID;
451
+ const running = identityInFlight.get(key);
452
+ if (running)
453
+ return running;
454
+ const run = identityQueue
455
+ .then(() => identityRunner(home))
456
+ .catch(() => null)
457
+ .finally(() => {
458
+ identityInFlight.delete(key);
459
+ });
460
+ identityInFlight.set(key, run);
461
+ identityQueue = run;
462
+ return run;
463
+ }
464
+ /**
465
+ * Homes whose last ask came back empty, and what their login file looked like
466
+ * then. A machine that runs on the operator's token alone has no identity to
467
+ * give, and without this every session start there would spawn the CLI again
468
+ * (~800 ms, ~300 MB) and log the same line. Asked again once the login file
469
+ * changes or the back-off has passed.
470
+ */
471
+ const identityMisses = new Map();
472
+ const IDENTITY_RETRY_MS = 30 * 60 * 1000;
473
+ /** Forget remembered misses – tests. */
474
+ export function resetIdentityMisses() {
475
+ identityMisses.clear();
476
+ }
477
+ function recordOf(id) {
478
+ return readAgentAuth().claudeAccounts?.find((record) => record.id === id);
479
+ }
480
+ function storedIdentity(id) {
481
+ if (id === MACHINE_ACCOUNT_ID)
482
+ return readAgentAuth().claudeMachine?.lastSeenIdentity;
483
+ return recordOf(id)?.lastSeenIdentity;
484
+ }
485
+ /**
486
+ * When a file was last written, in WHOLE milliseconds – comparable with the ISO
487
+ * stamps this module writes, which have no fraction. A raw `mtimeMs` of
488
+ * `…123.4` against a stamp of `…123` read a file written before the stamp as
489
+ * written after it.
490
+ */
491
+ function mtimeMsOrNull(file) {
492
+ try {
493
+ return Math.floor(fs.statSync(file).mtimeMs);
494
+ }
495
+ catch {
496
+ return null;
497
+ }
498
+ }
499
+ /**
500
+ * Is a known identity still the home's identity?
501
+ *
502
+ * `contradicted` – the home's own `.claude.json` names a different organization
503
+ * than the identity does: somebody signed in again (the dashboard's «Log in
504
+ * again», or `claude auth login` by hand in the terminal). Nothing that identity
505
+ * says may be used any more – a usage key or a session card built from it would
506
+ * put one subscription's name over another's numbers (#380).
507
+ *
508
+ * `current` – the file agrees, or (file unreadable) the login has not been
509
+ * written since the identity was read. The file is a HINT here, never the
510
+ * identity itself (К12): it only decides whether to ask the public command again.
511
+ */
512
+ function identityFreshness(identity, home) {
513
+ if (!identity)
514
+ return 'stale';
515
+ const organization = readOauthAccount(home).organizationUuid;
516
+ if (organization && identity.orgId && organization !== identity.orgId)
517
+ return 'contradicted';
518
+ const at = Date.parse(identity.at);
519
+ if (!Number.isFinite(at) || Date.now() - at > IDENTITY_MAX_AGE_MS)
520
+ return 'stale';
521
+ if (organization && identity.orgId)
522
+ return 'current';
523
+ const loginWritten = mtimeMsOrNull(credentialsFileOf(home));
524
+ return loginWritten === null || loginWritten <= at ? 'current' : 'stale';
525
+ }
526
+ function homeOfRow(id) {
527
+ return id === MACHINE_ACCOUNT_ID ? null : accountHome(id);
528
+ }
529
+ /** Persist an identity against a row. A row that has gone meanwhile is left gone. */
530
+ function saveIdentity(id, identity) {
531
+ const stamped = { ...identity, at: new Date().toISOString() };
532
+ updateAgentAuth((file) => {
533
+ if (id === MACHINE_ACCOUNT_ID) {
534
+ file.claudeMachine = { ...file.claudeMachine, lastSeenIdentity: stamped };
535
+ return;
536
+ }
537
+ const record = file.claudeAccounts?.find((entry) => entry.id === id);
538
+ if (record)
539
+ record.lastSeenIdentity = stamped;
540
+ });
541
+ return stamped;
542
+ }
543
+ /**
544
+ * Who a row is, from the cache or – when that is unknown, old, or contradicted by
545
+ * the home – from `claude auth status` in its home, persisted (R14).
546
+ *
547
+ * A probe that answers nothing keeps what was known: a CLI that timed out once
548
+ * must not turn a named account into «card not filled». And it is remembered for
549
+ * a while, so a home with no identity to give is not asked at every session start.
550
+ */
551
+ export async function refreshAccountIdentity(id, options = {}) {
552
+ const home = homeOfRow(id);
553
+ if (home !== null && !fs.existsSync(home))
554
+ return null;
555
+ const known = storedIdentity(id);
556
+ if (!options.force && identityFreshness(known, home) === 'current')
557
+ return known ?? null;
558
+ const key = home ?? MACHINE_ACCOUNT_ID;
559
+ const loginWritten = mtimeMsOrNull(credentialsFileOf(home));
560
+ const miss = identityMisses.get(key);
561
+ if (!options.force &&
562
+ miss &&
563
+ miss.loginWritten === loginWritten &&
564
+ Date.now() - miss.atMs < IDENTITY_RETRY_MS) {
565
+ return known ?? null;
566
+ }
567
+ const identity = await readAccountIdentity(home);
568
+ if (!identity) {
569
+ identityMisses.set(key, { atMs: Date.now(), loginWritten });
570
+ return known ?? null;
571
+ }
572
+ identityMisses.delete(key);
573
+ return saveIdentity(id, identity);
574
+ }
575
+ function isNonEmptyString(value) {
576
+ return typeof value === 'string' && value.length > 0;
577
+ }
578
+ /**
579
+ * A millisecond timestamp we are willing to hand to `new Date(...)`.
580
+ *
581
+ * The bound is not decoration: `new Date(1e21).toISOString()` throws
582
+ * `RangeError`, and thrown out of here it takes BOTH agents' verdicts down
583
+ * with it (they share one `Promise.all`) on every poll, forever, because only
584
+ * successes are cached (QA-117 M1).
585
+ */
586
+ const MAX_TIMESTAMP_MS = 8.64e15;
587
+ function asTimestamp(value) {
588
+ if (typeof value !== 'number' || !Number.isFinite(value))
589
+ return undefined;
590
+ return Math.abs(value) <= MAX_TIMESTAMP_MS ? value : undefined;
591
+ }
592
+ /**
593
+ * Judge a Claude credentials file – moved here unchanged from `claudeAuthStatus`
594
+ * so a saved home and the machine are judged by the same lines (#422 R14).
595
+ *
596
+ * `expiresAt` is NOT the login. It is the expiry of a short-lived access token
597
+ * (~8 hours on a live file), and next to it sits `refreshToken` with
598
+ * `refreshTokenExpiresAt` ~26 days out, which the CLI spends silently on its
599
+ * next run. Judging the login by `expiresAt` alone is why every server nobody
600
+ * had touched since the morning reported "login expired — re-login needed"
601
+ * over a login that was good for another three weeks (#121). Codex has carried
602
+ * exactly this guard since day one (`readCodexCredential`); Claude did not.
603
+ *
604
+ * Deliberately NOT asking the CLI. `claude auth status --json` looks like an
605
+ * arbiter and is not one: measured live (SDK binary 2.1.218), it answers
606
+ * `loggedIn: true` for a credential whose access token has expired AND which
607
+ * carries no refresh token at all — i.e. for a genuinely dead login. It never
608
+ * leaves the machine, so it cannot see a server-side revocation either. It
609
+ * would have cost a ~800 ms / ~300 MB subprocess per poll under `MemoryMax=2G`
610
+ * (gotcha #100) and echoed the account's e-mail and org name to every member of
611
+ * the organization, in exchange for no truth at all. A revoked login is caught
612
+ * instead by the refusal marks — from a real refusal, not a guess.
613
+ */
614
+ export function judgeCredentialFile(file) {
615
+ let raw;
616
+ try {
617
+ raw = fs.readFileSync(file, 'utf8');
618
+ }
619
+ catch (error) {
620
+ const code = error.code;
621
+ // Never signed in here is a different answer from "we could not look".
622
+ // EACCES on somebody else's HOME used to read as "not signed in", which
623
+ // sends the user re-authenticating a credential that is sitting right
624
+ // there (the same mistake #121 is about, one layer down).
625
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
626
+ return {
627
+ status: 'missing',
628
+ detail: 'No Claude login on this server',
629
+ fallbackEligible: true,
630
+ };
631
+ }
632
+ // No `log.warn` here: this probe is on a 60-second timer since #121, and a
633
+ // machine with EACCES on that file would write the same line forever.
634
+ // `logVerdictChange` already reports the `unknown`, once, when it starts
635
+ // (QA-117 L5).
636
+ return {
637
+ status: 'unknown',
638
+ detail: `could not read the login on this server (${code ?? 'unknown error'})`,
639
+ fallbackEligible: false,
640
+ };
641
+ }
642
+ let oauth;
643
+ try {
644
+ oauth = JSON.parse(raw).claudeAiOauth;
645
+ }
646
+ catch {
647
+ // Truncated or hand-edited file: the CLI cannot use it either, and signing
648
+ // in again is the fix — so say `missing` (which offers that button) rather
649
+ // than `unknown` (which offers nothing).
650
+ return {
651
+ status: 'missing',
652
+ detail: 'the stored login could not be read — sign in again',
653
+ fallbackEligible: false,
654
+ };
655
+ }
656
+ // A parseable file is not a credential. `{"claudeAiOauth":{}}` is what a
657
+ // partial write and a hand-edit both leave behind, and reading it as a
658
+ // healthy login puts a green dot and NO way out on the panel — «I cannot
659
+ // tell» turned into «all good», which is the rule this ticket exists to
660
+ // uphold, upside down (QA-117 M2).
661
+ if (!oauth || typeof oauth !== 'object' || Array.isArray(oauth)) {
662
+ return {
663
+ status: 'missing',
664
+ detail: 'the stored login could not be read — sign in again',
665
+ fallbackEligible: false,
666
+ };
667
+ }
668
+ const hasAccess = isNonEmptyString(oauth.accessToken);
669
+ const hasRefresh = isNonEmptyString(oauth.refreshToken);
670
+ const now = Date.now();
671
+ const accessExpiry = asTimestamp(oauth.expiresAt);
672
+ const refreshExpiry = asTimestamp(oauth.refreshTokenExpiresAt);
673
+ // Nothing recognisable in the blob at all — no token, not even a date. That
674
+ // is «never signed in here», and it must offer the button that fixes it.
675
+ if (!hasAccess && !hasRefresh && accessExpiry === undefined) {
676
+ return { status: 'missing', detail: 'No subscription login found', fallbackEligible: true };
677
+ }
678
+ // A date we can read outranks the token beside it (the pre-#121 contract, and
679
+ // the reason a dated-but-token-less fixture still reads as expired); with no
680
+ // readable date, the presence of the token is all we have.
681
+ const accessLive = accessExpiry === undefined ? hasAccess : accessExpiry > now;
682
+ // No `refreshTokenExpiresAt` next to a refresh token means the CLI did not
683
+ // record one — that is not evidence of death, so we do not read it as death.
684
+ const refreshLive = hasRefresh && (refreshExpiry === undefined || refreshExpiry > now);
685
+ // Report the date this login actually dies on, not the one that moves every
686
+ // eight hours: a panel reading "token until <today>" is alarming and wrong.
687
+ // Only while the refresh token is the operative one, though — a live access
688
+ // token beside a dead refresh token dies on its OWN date. And when the CLI
689
+ // recorded no date for a live refresh token we say NOTHING: printing the
690
+ // access token's lapsed date beside the word «signed in» is the very screen
691
+ // this function's docblock promises not to draw (QA-117 M3).
692
+ const effectiveExpiry = refreshLive ? refreshExpiry : accessExpiry;
693
+ const expiresAt = effectiveExpiry === undefined ? undefined : new Date(effectiveExpiry).toISOString();
694
+ if (!accessLive && !refreshLive) {
695
+ return {
696
+ status: 'expired',
697
+ ...(expiresAt ? { expiresAt } : {}),
698
+ detail: 'the stored login has expired',
699
+ fallbackEligible: true,
700
+ };
701
+ }
702
+ return {
703
+ status: 'ok',
704
+ ...(expiresAt ? { expiresAt } : {}),
705
+ // Only a plain string, and only a short one: this value is read off disk
706
+ // and printed in every member's panel.
707
+ ...(isNonEmptyString(oauth.subscriptionType)
708
+ ? { detail: `subscription ${oauth.subscriptionType.slice(0, 40)}` }
709
+ : {}),
710
+ fallbackEligible: false,
711
+ };
712
+ }
713
+ const withoutEligibility = ({ fallbackEligible: _, ...rest }) => rest;
714
+ /** Does a refusal mark on disk still stand against a login written at `writtenMs`? */
715
+ function markHolds(markedAt, writtenMs) {
716
+ if (!markedAt)
717
+ return false;
718
+ const marked = Date.parse(markedAt);
719
+ if (!Number.isFinite(marked))
720
+ return false;
721
+ // A login written AFTER the refusal – the CLI refreshed it, somebody signed in
722
+ // again – is newer evidence than the refusal was.
723
+ return writtenMs === null || writtenMs <= marked;
724
+ }
725
+ /**
726
+ * The machine row: the pre-#422 verdict, word for word.
727
+ *
728
+ * The operator's variable first, then the credentials file, then a token this
729
+ * runner captured – the LAST word, never the first. Read from disk rather than
730
+ * from the environment, so `doctor` (a different process, which never applied
731
+ * it) gives the same verdict as the daemon.
732
+ *
733
+ * One change, from S1 item 3: a refusal no longer DISCARDS the captured token
734
+ * (the old discard erased the whole file, and with it every saved account). The
735
+ * refusal is kept as a mark instead, and a token captured after it outranks it.
736
+ */
737
+ export function machineLoginStatus(homedir = os.homedir()) {
738
+ const envToken = process.env['CLAUDE_CODE_OAUTH_TOKEN'];
739
+ const stored = storedClaudeToken();
740
+ const refused = stored !== null && storedClaudeTokenRefused();
741
+ // The variable is the operator's unless it is the very token this runner put
742
+ // there itself – and that one does not answer for the machine once refused.
743
+ if (envToken && !(refused && envToken === stored)) {
744
+ return { status: 'ok', detail: 'CLAUDE_CODE_OAUTH_TOKEN is configured' };
745
+ }
746
+ const verdict = judgeCredentialFile(credentialsFileOf(null, homedir));
747
+ if (verdict.fallbackEligible && stored) {
748
+ if (refused) {
749
+ return {
750
+ status: 'expired',
751
+ detail: 'the agent was refused with the token stored on this server — sign in again',
752
+ };
753
+ }
754
+ return { status: 'ok', detail: 'signed in with a long-lived token stored on this server' };
755
+ }
756
+ return withoutEligibility(verdict);
757
+ }
758
+ /** Settings keys that give the CLI a credential of its own, above any login file. */
759
+ const AUTH_ENV_KEYS = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'];
760
+ /**
761
+ * Do the machine's Claude settings – which every saved home reads through its
762
+ * link – carry a credential that outranks a home's own login?
763
+ *
764
+ * `withAccountHome` takes the operator's token out of the PROCESS environment,
765
+ * but a settings file with `env: { CLAUDE_CODE_OAUTH_TOKEN }`, an API key or an
766
+ * `apiKeyHelper` puts one back inside the CLI, and a switch of account would
767
+ * then be a silent no-op under a green verdict – #121 once more (К14, found by
768
+ * the independent check of S1).
769
+ */
770
+ export function machineSettingsOverrideLogin(machine = machineHome()) {
771
+ for (const name of ['settings.json', 'settings.local.json']) {
772
+ const settings = readJsonObject(path.join(machine, name));
773
+ if (!settings)
774
+ continue;
775
+ if (typeof settings['apiKeyHelper'] === 'string' && settings['apiKeyHelper'].trim())
776
+ return true;
777
+ const env = settings['env'];
778
+ if (isObject(env) && AUTH_ENV_KEYS.some((key) => typeof env[key] === 'string' && env[key])) {
779
+ return true;
780
+ }
781
+ }
782
+ return false;
783
+ }
784
+ /**
785
+ * A saved row: its own file and nothing else (R14).
786
+ *
787
+ * No operator variable and no captured token – a session under a saved account
788
+ * has both taken out of its environment, so neither can make this row green.
789
+ * Anything short of a live login in the file is `expired`: the row stays in the
790
+ * list with that mark (D2), and «missing» would read as «never added».
791
+ */
792
+ export function savedLoginStatus(id) {
793
+ const home = accountHome(id);
794
+ const file = credentialsFileOf(home);
795
+ const verdict = judgeCredentialFile(file);
796
+ if (verdict.status === 'unknown')
797
+ return withoutEligibility(verdict);
798
+ if (machineSettingsOverrideLogin()) {
799
+ return {
800
+ status: 'unknown',
801
+ detail: 'the Claude settings of this server set a token or API key that overrides this account – remove it from ~/.claude/settings.json to use saved accounts',
802
+ };
803
+ }
804
+ if (verdict.status !== 'ok') {
805
+ return {
806
+ status: 'expired',
807
+ ...(verdict.expiresAt ? { expiresAt: verdict.expiresAt } : {}),
808
+ detail: verdict.status === 'missing'
809
+ ? 'this account has no usable login on this server – sign in again'
810
+ : 'the stored login of this account has expired',
811
+ };
812
+ }
813
+ if (markHolds(recordOf(id)?.loginExpiredAt, mtimeMsOrNull(file))) {
814
+ return {
815
+ status: 'expired',
816
+ ...(verdict.expiresAt ? { expiresAt: verdict.expiresAt } : {}),
817
+ detail: 'the agent was refused with this account – sign in again',
818
+ };
819
+ }
820
+ return withoutEligibility(verdict);
821
+ }
822
+ /**
823
+ * A session under this account was refused (D2): mark the row, remove nothing.
824
+ *
825
+ * By the id of the account the SESSION ran under, not «whatever is active now»
826
+ * (R14): a session started under A and refused after the machine was switched to
827
+ * B says nothing about B.
828
+ */
829
+ export function markLoginExpired(id) {
830
+ const at = new Date().toISOString();
831
+ try {
832
+ updateAgentAuth((file) => {
833
+ if (id === MACHINE_ACCOUNT_ID) {
834
+ file.claudeMachine = { ...file.claudeMachine, loginExpiredAt: at };
835
+ return;
836
+ }
837
+ const record = file.claudeAccounts?.find((entry) => entry.id === id);
838
+ if (record)
839
+ record.loginExpiredAt = at;
840
+ });
841
+ }
842
+ catch (error) {
843
+ log.warn('claude-homes: could not record the refusal', { account: id, error: String(error) });
844
+ }
845
+ }
846
+ /** The account worked again. Writes only when there was a mark to clear. */
847
+ export function clearLoginExpired(id) {
848
+ const file = readAgentAuth();
849
+ const marked = id === MACHINE_ACCOUNT_ID
850
+ ? file.claudeMachine?.loginExpiredAt
851
+ : file.claudeAccounts?.find((entry) => entry.id === id)?.loginExpiredAt;
852
+ if (!marked)
853
+ return;
854
+ try {
855
+ updateAgentAuth((next) => {
856
+ if (id === MACHINE_ACCOUNT_ID) {
857
+ if (next.claudeMachine)
858
+ delete next.claudeMachine.loginExpiredAt;
859
+ return;
860
+ }
861
+ const record = next.claudeAccounts?.find((entry) => entry.id === id);
862
+ if (record)
863
+ delete record.loginExpiredAt;
864
+ });
865
+ }
866
+ catch (error) {
867
+ log.warn('claude-homes: could not clear the refusal', { account: id, error: String(error) });
868
+ }
869
+ }
870
+ // ─── Which account is active ──────────────────────────────────────────
871
+ let warnedMissingActive = null;
872
+ /**
873
+ * The account the next session starts under. Absent pointer: the machine login,
874
+ * which is how a pre-#422 file reads.
875
+ *
876
+ * A pointer to a row or a home that is gone reads as the machine too, and says
877
+ * so once – sessions and the verdict then agree on the same login instead of
878
+ * one of them running under a home the other cannot see.
879
+ */
880
+ export function activeAccountId() {
881
+ const file = readAgentAuth();
882
+ const pointer = file.claudeActiveAccount;
883
+ if (!pointer || pointer === MACHINE_ACCOUNT_ID)
884
+ return MACHINE_ACCOUNT_ID;
885
+ const known = file.claudeAccounts?.some((record) => record.id === pointer) ?? false;
886
+ if (known && isAccountId(pointer) && fs.existsSync(accountHome(pointer))) {
887
+ warnedMissingActive = null;
888
+ return pointer;
889
+ }
890
+ if (warnedMissingActive !== pointer) {
891
+ warnedMissingActive = pointer;
892
+ log.warn('claude-homes: the active account is gone from this machine – using the machine login', {
893
+ account: pointer,
894
+ });
895
+ }
896
+ return MACHINE_ACCOUNT_ID;
897
+ }
898
+ /** Make a row active. `activate` leaves the usage cache alone: keys keep accounts apart (R16). */
899
+ export function setActiveAccount(id) {
900
+ if (id !== MACHINE_ACCOUNT_ID) {
901
+ if (!isAccountId(id) || !recordOf(id))
902
+ throw new AccountError('no such account on this server');
903
+ if (!fs.existsSync(accountHome(id))) {
904
+ throw new AccountError('the files of this account are gone from this server – forget it');
905
+ }
906
+ }
907
+ updateAgentAuth((file) => {
908
+ if (id === MACHINE_ACCOUNT_ID)
909
+ delete file.claudeActiveAccount;
910
+ else
911
+ file.claudeActiveAccount = id;
912
+ });
913
+ return activeAccountId();
914
+ }
915
+ function sessionAccountOf(id) {
916
+ const home = homeOfRow(id);
917
+ const known = storedIdentity(id);
918
+ // A contradicted identity names somebody the home no longer is: the session
919
+ // starts unsigned (its limits panel draws as before #422) rather than under
920
+ // the previous login's name.
921
+ const identity = identityFreshness(known, home) === 'contradicted' ? undefined : known;
922
+ return {
923
+ id,
924
+ kind: id === MACHINE_ACCOUNT_ID ? 'machine' : 'saved',
925
+ home,
926
+ ...(identity?.email ? { email: identity.email } : {}),
927
+ ...(identity?.orgId ? { orgId: identity.orgId } : {}),
928
+ ...(identity?.orgName ? { orgName: identity.orgName } : {}),
929
+ ...(identity?.plan ? { plan: identity.plan } : {}),
930
+ };
931
+ }
932
+ /** Who a row is, for the verdict's `activeAccount` (§8) – from what is known, no CLI. */
933
+ export function readActiveAccountSummary(id) {
934
+ const account = sessionAccountOf(id);
935
+ return {
936
+ id,
937
+ ...(account.email ? { email: account.email } : {}),
938
+ ...(account.orgId ? { orgId: account.orgId } : {}),
939
+ };
940
+ }
941
+ /**
942
+ * Which home a one-shot run of the agent should use – the active account, read
943
+ * only: no link repair, no identity. For the commit-message run, which is
944
+ * Claude working as the machine's active account too (found by the independent
945
+ * check of S1: it kept running as the machine login after a switch).
946
+ */
947
+ export function activeAccountHome() {
948
+ const id = activeAccountId();
949
+ return id === MACHINE_ACCOUNT_ID
950
+ ? { kind: 'machine', home: null }
951
+ : { kind: 'saved', home: accountHome(id) };
952
+ }
953
+ /**
954
+ * The active account, ready for a session to start under.
955
+ *
956
+ * A saved home has its links re-asserted first: a link the CLI replaced with a
957
+ * file since the last session is repaired before this session can write through
958
+ * the gap (it would otherwise resume nothing and share nothing).
959
+ */
960
+ export function resolveSessionAccount() {
961
+ const id = activeAccountId();
962
+ const account = sessionAccountOf(id);
963
+ if (account.home !== null) {
964
+ try {
965
+ ensureSharedLinks(account.home);
966
+ }
967
+ catch (error) {
968
+ log.warn('claude-homes: could not repair the links of the active account', {
969
+ account: id,
970
+ error: String(error),
971
+ });
972
+ }
973
+ }
974
+ return account;
975
+ }
976
+ /**
977
+ * The environment of a process that works AS this account.
978
+ *
979
+ * For a saved account: its home, and nothing that outranks the home. The CLI
980
+ * puts `CLAUDE_CODE_OAUTH_TOKEN` above every login file, so with the operator's
981
+ * token left in, switching would be a silent no-op under a green verdict – #121
982
+ * again (К14). The API keys go for the same reason.
983
+ *
984
+ * For the machine login: the input as it was, minus a `CLAUDE_CONFIG_DIR` that
985
+ * would point it elsewhere – the pre-#422 environment.
986
+ */
987
+ export function withAccountHome(env, account) {
988
+ const out = { ...env };
989
+ delete out['CLAUDE_CONFIG_DIR'];
990
+ if (account.kind === 'saved' && account.home !== null) {
991
+ out['CLAUDE_CONFIG_DIR'] = account.home;
992
+ delete out['CLAUDE_CODE_OAUTH_TOKEN'];
993
+ delete out['ANTHROPIC_API_KEY'];
994
+ delete out['ANTHROPIC_AUTH_TOKEN'];
995
+ }
996
+ return out;
997
+ }
998
+ function readOauthAccount(home) {
999
+ const config = readJsonObject(configFileOf(home));
1000
+ const account = isObject(config?.['oauthAccount']) ? config['oauthAccount'] : null;
1001
+ const accountUuid = text(account?.['accountUuid'], 100);
1002
+ const organizationUuid = text(account?.['organizationUuid'], 100);
1003
+ const tier = text(account?.['organizationRateLimitTier'], 80);
1004
+ return {
1005
+ ...(accountUuid ? { accountUuid } : {}),
1006
+ ...(organizationUuid ? { organizationUuid } : {}),
1007
+ ...(tier ? { tier } : {}),
1008
+ config,
1009
+ };
1010
+ }
1011
+ /**
1012
+ * Who a home's `/usage` reading belongs to, read right after the probe (S1 item 5).
1013
+ *
1014
+ * `accountUuid` is where the CLI itself signs its figures; `organizationUuid` is
1015
+ * the same key `claude auth status` calls `orgId` (checked on both homes of this
1016
+ * machine, 17.09.2026). An internal file, so best-effort: nothing read, nothing
1017
+ * signed.
1018
+ */
1019
+ export function usageSignature(account) {
1020
+ const info = readOauthAccount(account.home);
1021
+ return {
1022
+ ...(info.accountUuid ? { accountUuid: info.accountUuid } : {}),
1023
+ ...(info.organizationUuid ? { orgId: info.organizationUuid } : {}),
1024
+ };
1025
+ }
1026
+ /** Which subscription a home's `.claude.json` names now, re-read only when the file moved. */
1027
+ const orgByConfigFile = new Map();
1028
+ /**
1029
+ * The subscription a home's own file names at this moment (S2, found by the
1030
+ * independent check): a live session signs its limits with the subscription it
1031
+ * STARTED under, and has to notice when somebody signed the home in to another
1032
+ * one – the CLI moves the running process onto the new login by path (gotcha
1033
+ * 531). Cheap enough for every frame: a `stat`, and a parse only after a write.
1034
+ */
1035
+ export function currentHomeOrgId(home) {
1036
+ const file = configFileOf(home);
1037
+ let mtimeMs;
1038
+ try {
1039
+ mtimeMs = fs.statSync(file).mtimeMs;
1040
+ }
1041
+ catch {
1042
+ return undefined;
1043
+ }
1044
+ const cached = orgByConfigFile.get(file);
1045
+ if (cached?.mtimeMs === mtimeMs)
1046
+ return cached.orgId;
1047
+ const orgId = readOauthAccount(home).organizationUuid;
1048
+ orgByConfigFile.set(file, { mtimeMs, orgId });
1049
+ return orgId;
1050
+ }
1051
+ /**
1052
+ * The limits the CLI last measured in a home, from its own `.claude.json` (R19).
1053
+ *
1054
+ * Only for the line «when measured»: an internal file, rewritten by any update
1055
+ * of the CLI (§11). Accepted only when it is signed by the account the same file
1056
+ * says it belongs to – half-way through a re-login the two can disagree, and a
1057
+ * number under the wrong name is worse than none.
1058
+ */
1059
+ export function readLastUsage(home) {
1060
+ const info = readOauthAccount(home);
1061
+ const cached = info.config?.['cachedUsageUtilization'];
1062
+ if (!isObject(cached) || !info.accountUuid)
1063
+ return null;
1064
+ if (cached['accountUuid'] !== info.accountUuid)
1065
+ return null;
1066
+ // Through the same bound as the login verdict: `new Date(1e18).toISOString()`
1067
+ // throws, and thrown out of a card it took the whole list down with it – and
1068
+ // turned a sign-in that had succeeded into a reported failure (found by the
1069
+ // independent check of S2).
1070
+ const fetchedAt = asTimestamp(cached['fetchedAtMs']);
1071
+ const utilization = cached['utilization'];
1072
+ if (fetchedAt === undefined || !isObject(utilization)) {
1073
+ return null;
1074
+ }
1075
+ const rows = [];
1076
+ for (const key of ['five_hour', 'seven_day']) {
1077
+ const window = utilization[key];
1078
+ if (!isObject(window))
1079
+ continue;
1080
+ const percent = window['utilization'];
1081
+ if (typeof percent !== 'number' || !Number.isFinite(percent))
1082
+ continue;
1083
+ const resets = window['resets_at'];
1084
+ const resetsMs = typeof resets === 'string' ? Date.parse(resets) : NaN;
1085
+ rows.push({
1086
+ key,
1087
+ label: null,
1088
+ percent: Math.min(100, Math.max(0, percent)),
1089
+ resetsAt: Number.isFinite(resetsMs) ? new Date(resetsMs).toISOString() : null,
1090
+ });
1091
+ }
1092
+ if (rows.length === 0)
1093
+ return null;
1094
+ return {
1095
+ rows,
1096
+ measuredAtMs: fetchedAt,
1097
+ accountUuid: info.accountUuid,
1098
+ ...(info.organizationUuid ? { orgId: info.organizationUuid } : {}),
1099
+ };
1100
+ }
1101
+ // ─── Live sessions per account ───────────────────────────────────────
1102
+ /** sessionId → the account that session's process runs under. */
1103
+ const sessionAccounts = new Map();
1104
+ /** A session's process is starting under this account. */
1105
+ export function noteSessionAccount(sessionId, accountId) {
1106
+ sessionAccounts.set(sessionId, accountId);
1107
+ }
1108
+ /** That process is gone. */
1109
+ export function releaseSessionAccount(sessionId) {
1110
+ sessionAccounts.delete(sessionId);
1111
+ }
1112
+ /** Accounts some live session process is running under right now. */
1113
+ export function liveAccountIds() {
1114
+ return new Set(sessionAccounts.values());
1115
+ }
1116
+ const RETIRED = /^([a-z0-9]{12})\.retired-\d+$/;
1117
+ const STAGING = /^\.staging-[a-z0-9]{12}$/;
1118
+ const STAGING_MAX_AGE_MS = 60 * 60 * 1000;
1119
+ /**
1120
+ * Finish a swap of homes the previous daemon did not finish (R15).
1121
+ *
1122
+ * A replacement moves the old home to `<id>.retired-<ts>` for the length of one
1123
+ * call: long enough to put it back if the new login cannot take its place. A
1124
+ * daemon that died inside that call leaves the name behind. If `<id>` is there,
1125
+ * the swap went through and the retired home is the replaced login – removed. If
1126
+ * `<id>` is missing, the swap did not happen – the retired home is put back,
1127
+ * because it is the row's only login. Run at daemon start, when nothing runs.
1128
+ */
1129
+ export function recoverInterruptedSwaps() {
1130
+ const root = claudeHomesDir();
1131
+ let names;
1132
+ try {
1133
+ names = fs.readdirSync(root);
1134
+ }
1135
+ catch {
1136
+ return [];
1137
+ }
1138
+ const actions = [];
1139
+ for (const name of names) {
1140
+ const match = RETIRED.exec(name);
1141
+ if (!match?.[1])
1142
+ continue;
1143
+ const retired = path.join(root, name);
1144
+ const home = path.join(root, match[1]);
1145
+ if (lstatOrNull(home)) {
1146
+ fs.rmSync(retired, { recursive: true, force: true });
1147
+ actions.push(`removed ${name}`);
1148
+ }
1149
+ else {
1150
+ fs.renameSync(retired, home);
1151
+ log.warn('claude-homes: put back a home whose replacement had not finished', {
1152
+ account: match[1],
1153
+ });
1154
+ actions.push(`restored ${match[1]}`);
1155
+ }
1156
+ }
1157
+ return actions;
1158
+ }
1159
+ function loginOf(status, refused) {
1160
+ const login = status.status === 'unknown' ? 'unknown' : status.status === 'ok' && !refused ? 'ok' : 'expired';
1161
+ return { login, ...(status.expiresAt ? { loginUntil: status.expiresAt } : {}) };
1162
+ }
1163
+ function usageOf(reading) {
1164
+ if (!reading || reading.rows.length === 0)
1165
+ return {};
1166
+ return { usage: reading.rows, usageMeasuredAt: new Date(reading.measuredAtMs).toISOString() };
1167
+ }
1168
+ function identityFields(identity) {
1169
+ return {
1170
+ ...(identity?.email ? { email: identity.email } : {}),
1171
+ ...(identity?.orgId ? { orgId: identity.orgId } : {}),
1172
+ ...(identity?.orgName ? { orgName: identity.orgName } : {}),
1173
+ ...(identity?.plan ? { plan: identity.plan } : {}),
1174
+ };
1175
+ }
1176
+ /**
1177
+ * The limits of a row: for the active one, what the machine measured for its
1178
+ * subscription; for the others – and for an active one nobody has measured yet –
1179
+ * the last figures the CLI left in the home.
1180
+ */
1181
+ function rowUsage(id, identity, active) {
1182
+ const home = homeOfRow(id);
1183
+ if (active) {
1184
+ const measured = lastUsageRows({ id, ...(identity?.orgId ? { orgId: identity.orgId } : {}) });
1185
+ if (measured)
1186
+ return measured;
1187
+ }
1188
+ const last = readLastUsage(home);
1189
+ // Signed by a different subscription than the row says it is: somebody else's.
1190
+ if (last?.orgId && identity?.orgId && last.orgId !== identity.orgId)
1191
+ return null;
1192
+ return last;
1193
+ }
1194
+ /**
1195
+ * The list, from what is already known – no subprocess (§8 `agent_accounts`
1196
+ * reads this after `listAccounts` refreshed the identities).
1197
+ */
1198
+ export function buildAccountList(homedir = os.homedir()) {
1199
+ const file = readAgentAuth();
1200
+ const active = activeAccountId();
1201
+ const machineIdentity = file.claudeMachine?.lastSeenIdentity;
1202
+ const machineTier = readOauthAccount(null).tier;
1203
+ const machineCard = {
1204
+ id: MACHINE_ACCOUNT_ID,
1205
+ kind: 'machine',
1206
+ ...identityFields(machineIdentity),
1207
+ ...(machineTier ? { tier: machineTier } : {}),
1208
+ active: active === MACHINE_ACCOUNT_ID,
1209
+ ...loginOf(machineLoginStatus(homedir), refusalActive('claude', MACHINE_ACCOUNT_ID)),
1210
+ ...usageOf(rowUsage(MACHINE_ACCOUNT_ID, machineIdentity, active === MACHINE_ACCOUNT_ID)),
1211
+ };
1212
+ const saved = [...(file.claudeAccounts ?? [])]
1213
+ .filter((record) => isAccountId(record.id) && fs.existsSync(accountHome(record.id)))
1214
+ .sort((a, b) => Date.parse(a.addedAt) - Date.parse(b.addedAt))
1215
+ .map((record) => {
1216
+ const identity = record.lastSeenIdentity;
1217
+ const tier = readOauthAccount(accountHome(record.id)).tier;
1218
+ const same = identity?.orgId !== undefined &&
1219
+ machineIdentity?.orgId !== undefined &&
1220
+ identity.orgId === machineIdentity.orgId;
1221
+ return {
1222
+ id: record.id,
1223
+ kind: 'saved',
1224
+ ...identityFields(identity),
1225
+ ...(tier ? { tier } : {}),
1226
+ addedAt: record.addedAt,
1227
+ active: active === record.id,
1228
+ ...loginOf(savedLoginStatus(record.id), refusalActive('claude', record.id)),
1229
+ ...usageOf(rowUsage(record.id, identity, active === record.id)),
1230
+ ...(same ? { sameAsMachine: true } : {}),
1231
+ };
1232
+ });
1233
+ return { accounts: [machineCard, ...saved], active };
1234
+ }
1235
+ /**
1236
+ * The list of Claude accounts on this machine (§8 `agent_accounts`).
1237
+ *
1238
+ * `probeIdentity` is the one place in the product where `claude auth status`
1239
+ * runs on request (R14 a): home by home, one at a time, and only for a row whose
1240
+ * known identity is older than its login file or than a few hours. Never from
1241
+ * the minute poll – that reads the panel verdict, which asks no CLI.
1242
+ */
1243
+ export async function listAccounts(options = {}) {
1244
+ if (options.probeIdentity) {
1245
+ const ids = [
1246
+ MACHINE_ACCOUNT_ID,
1247
+ ...(readAgentAuth().claudeAccounts ?? []).map((record) => record.id).filter(isAccountId),
1248
+ ];
1249
+ for (const id of ids) {
1250
+ await refreshAccountIdentity(id).catch((error) => log.warn('claude-homes: identity refresh failed', { account: id, error: String(error) }));
1251
+ }
1252
+ }
1253
+ return buildAccountList();
1254
+ }
1255
+ function cardOf(id) {
1256
+ const card = buildAccountList().accounts.find((row) => row.id === id);
1257
+ if (!card)
1258
+ throw new AccountError('the account is not on this server any more');
1259
+ return card;
1260
+ }
1261
+ // ─── Signing in: a staging home, adopted by rename ───────────────────
1262
+ function isStagingDir(dir) {
1263
+ return (path.dirname(path.resolve(dir)) === path.resolve(claudeHomesDir()) &&
1264
+ STAGING.test(path.basename(dir)));
1265
+ }
1266
+ function sweepStaleStaging(root) {
1267
+ let names;
1268
+ try {
1269
+ names = fs.readdirSync(root);
1270
+ }
1271
+ catch {
1272
+ return;
1273
+ }
1274
+ const now = Date.now();
1275
+ for (const name of names) {
1276
+ if (!STAGING.test(name))
1277
+ continue;
1278
+ const dir = path.join(root, name);
1279
+ const stat = lstatOrNull(dir);
1280
+ if (stat && now - stat.mtimeMs > STAGING_MAX_AGE_MS) {
1281
+ fs.rmSync(dir, { recursive: true, force: true });
1282
+ }
1283
+ }
1284
+ }
1285
+ /**
1286
+ * A fresh home for a sign-in that has not happened yet (§8 `target: 'saved'`).
1287
+ *
1288
+ * Links and config first, so the CLI that signs in here writes its identity
1289
+ * into a file that already has the machine's configuration. Hidden by its name:
1290
+ * the list never shows a staging home, and an abandoned one older than an hour
1291
+ * is removed the next time somebody starts a sign-in.
1292
+ */
1293
+ export function prepareStagingHome() {
1294
+ const root = ensureRoot();
1295
+ sweepStaleStaging(root);
1296
+ const dir = path.join(root, `.staging-${newAccountId()}`);
1297
+ fs.mkdirSync(dir, { mode: 0o700 });
1298
+ ensureSharedLinks(dir);
1299
+ ensureHomeConfig(dir);
1300
+ return dir;
1301
+ }
1302
+ /**
1303
+ * Remove every staging home, whatever its age – at daemon start, and only there.
1304
+ *
1305
+ * A sign-in whose code was accepted leaves the relay before its home is adopted
1306
+ * (the identity is asked first, and waits in the machine-wide queue). A daemon
1307
+ * that stops in that moment – «Update runner», a restart, an OOM – leaves a live
1308
+ * login in a hidden home nobody lists, forgets or adopts; the hourly sweep in
1309
+ * `prepareStagingHome` reaches it only when somebody next signs in (found by the
1310
+ * independent check of S2). At start nothing can be adopting yet, so all of them go.
1311
+ */
1312
+ export function discardAbandonedStagingHomes() {
1313
+ const root = claudeHomesDir();
1314
+ let names;
1315
+ try {
1316
+ names = fs.readdirSync(root);
1317
+ }
1318
+ catch {
1319
+ return [];
1320
+ }
1321
+ const removed = [];
1322
+ for (const name of names) {
1323
+ if (!STAGING.test(name))
1324
+ continue;
1325
+ fs.rmSync(path.join(root, name), { recursive: true, force: true });
1326
+ removed.push(name);
1327
+ }
1328
+ if (removed.length > 0) {
1329
+ log.warn('claude-homes: removed sign-in homes a previous daemon left unadopted', {
1330
+ count: removed.length,
1331
+ });
1332
+ }
1333
+ return removed;
1334
+ }
1335
+ /** Throw a sign-in away, whatever state it reached – it may hold a login nobody adopted. */
1336
+ export function discardStagingHome(dir) {
1337
+ if (!isStagingDir(dir))
1338
+ throw new AccountError('not a staging home');
1339
+ fs.rmSync(dir, { recursive: true, force: true });
1340
+ }
1341
+ /**
1342
+ * A sign-in in a staging home succeeded: make it a saved account (§8
1343
+ * `agent_account_login_code`).
1344
+ *
1345
+ * One subscription – one row (D19, R1). Identity is asked once, in the staging
1346
+ * home, BEFORE anything moves (R14 b). A known `orgId` that a saved row already
1347
+ * has REPLACES that row: the new login takes the row's id and `addedAt` (marks
1348
+ * and a live session's reading are keyed by the id, R15) and the old login is
1349
+ * deleted. An unknown `orgId` merges with nothing («card not filled»). The
1350
+ * machine row takes no part: it is a different file, and a saved row of the same
1351
+ * subscription is marked `sameAsMachine` instead of merged.
1352
+ *
1353
+ * A session running under the replaced row moves onto the new login at its next
1354
+ * token refresh – the CLI finds its home by PATH, and the path now holds the new
1355
+ * login. The plan (R15) asked to keep the old home renamed while such a session
1356
+ * lives, «because the CLI writes its refreshed token into it»; the independent
1357
+ * check of S1 read the CLI and showed it does not – a renamed home is never
1358
+ * touched again, and keeping it would only keep an unused grant on disk. Both
1359
+ * logins are of ONE subscription (that is what made them the same row), so the
1360
+ * session keeps its account.
1361
+ *
1362
+ * Every move is a `rename`; the login is never in two places. A swap that cannot
1363
+ * finish is undone – the old login goes back where it was. The row is recorded
1364
+ * BEFORE a new home is moved into place, so a failed write never leaves a login
1365
+ * nobody can list or forget. The signed-in row becomes active (R15).
1366
+ */
1367
+ export async function adoptLoginResult(stagingDir) {
1368
+ if (!isStagingDir(stagingDir) || !lstatOrNull(stagingDir)?.isDirectory()) {
1369
+ throw new AccountError('no sign-in in progress – start again');
1370
+ }
1371
+ const verdict = judgeCredentialFile(credentialsFileOf(stagingDir));
1372
+ if (verdict.status !== 'ok') {
1373
+ throw new AccountError('the sign-in finished but no usable login was stored – start again');
1374
+ }
1375
+ const identity = await readAccountIdentity(stagingDir);
1376
+ const stamped = identity
1377
+ ? { ...identity, at: new Date().toISOString() }
1378
+ : undefined;
1379
+ // The ask above can wait behind other homes for a while: the sign-in may have
1380
+ // been abandoned meanwhile. Nothing moves unless it is still there.
1381
+ if (!lstatOrNull(stagingDir)?.isDirectory() ||
1382
+ judgeCredentialFile(credentialsFileOf(stagingDir)).status !== 'ok') {
1383
+ throw new AccountError('the sign-in was abandoned before it could be saved – start again');
1384
+ }
1385
+ const root = ensureRoot();
1386
+ const records = readAgentAuth().claudeAccounts ?? [];
1387
+ const same = stamped?.orgId
1388
+ ? records.find((record) => isAccountId(record.id) && record.lastSeenIdentity?.orgId === stamped.orgId)
1389
+ : undefined;
1390
+ if (!same) {
1391
+ const id = newAccountId();
1392
+ updateAgentAuth((file) => {
1393
+ file.claudeAccounts = [
1394
+ ...(file.claudeAccounts ?? []),
1395
+ {
1396
+ id,
1397
+ addedAt: new Date().toISOString(),
1398
+ ...(stamped ? { lastSeenIdentity: stamped } : {}),
1399
+ },
1400
+ ];
1401
+ });
1402
+ try {
1403
+ fs.renameSync(stagingDir, accountHome(id));
1404
+ fs.chmodSync(accountHome(id), 0o700);
1405
+ }
1406
+ catch (error) {
1407
+ // The record without its home would be a row that points at nothing.
1408
+ updateAgentAuth((file) => {
1409
+ file.claudeAccounts = (file.claudeAccounts ?? []).filter((entry) => entry.id !== id);
1410
+ if (file.claudeAccounts.length === 0)
1411
+ delete file.claudeAccounts;
1412
+ });
1413
+ throw error;
1414
+ }
1415
+ updateAgentAuth((file) => {
1416
+ file.claudeActiveAccount = id;
1417
+ });
1418
+ log.info('claude-homes: a new account was added', { account: id });
1419
+ return { account: cardOf(id), active: activeAccountId() };
1420
+ }
1421
+ const home = accountHome(same.id);
1422
+ let retired = null;
1423
+ if (lstatOrNull(home)) {
1424
+ retired = path.join(root, `${same.id}.retired-${Date.now()}`);
1425
+ fs.renameSync(home, retired);
1426
+ }
1427
+ try {
1428
+ fs.renameSync(stagingDir, home);
1429
+ }
1430
+ catch (error) {
1431
+ if (retired)
1432
+ fs.renameSync(retired, home);
1433
+ throw error;
1434
+ }
1435
+ fs.chmodSync(home, 0o700);
1436
+ if (retired)
1437
+ fs.rmSync(retired, { recursive: true, force: true });
1438
+ updateAgentAuth((file) => {
1439
+ const record = file.claudeAccounts?.find((entry) => entry.id === same.id);
1440
+ if (record) {
1441
+ if (stamped)
1442
+ record.lastSeenIdentity = stamped;
1443
+ delete record.loginExpiredAt;
1444
+ }
1445
+ file.claudeActiveAccount = same.id;
1446
+ });
1447
+ // A fresh login outranks anything remembered about the one it replaced – on
1448
+ // disk above, and in this process's memory of refusals here.
1449
+ clearRefusal('claude', same.id);
1450
+ // The replaced login's figures are not the new login's, even on one subscription.
1451
+ invalidateUsageCache({
1452
+ id: same.id,
1453
+ ...(same.lastSeenIdentity?.orgId ? { orgId: same.lastSeenIdentity.orgId } : {}),
1454
+ });
1455
+ log.info('claude-homes: a sign-in replaced the saved login of the same subscription', {
1456
+ account: same.id,
1457
+ underLiveSession: liveAccountIds().has(same.id),
1458
+ });
1459
+ return { account: cardOf(same.id), active: activeAccountId(), replaced: same.id };
1460
+ }
1461
+ /**
1462
+ * Bring a home that already holds a login into the store – by rename (D17, R7).
1463
+ *
1464
+ * For the one login this plan names: the probe's `/root/.probe-422/login/home`.
1465
+ * Refused, rather than done by copy, when the rename would cross filesystems.
1466
+ * Refused for the machine home and for anything already in the store, whatever
1467
+ * the spelling of the path. Refused when a saved row of the same subscription
1468
+ * exists (one subscription – one row, and nothing here is allowed to delete a
1469
+ * login to make room). Recorded before it moves; never activates.
1470
+ */
1471
+ export async function importLoginHome(source) {
1472
+ const stat = lstatOrNull(source);
1473
+ if (!stat?.isDirectory())
1474
+ throw new AccountError('not a directory');
1475
+ if (isWithin(source, machineHome()) || isWithin(machineHome(), source)) {
1476
+ throw new AccountError('the machine login is never moved');
1477
+ }
1478
+ if (isWithin(source, claudeHomesDir()))
1479
+ throw new AccountError('that home is already stored');
1480
+ if (!lstatOrNull(path.join(source, '.credentials.json'))?.isFile()) {
1481
+ throw new AccountError('there is no login in that directory');
1482
+ }
1483
+ const identity = await readAccountIdentity(source);
1484
+ if (identity?.orgId) {
1485
+ const clash = (readAgentAuth().claudeAccounts ?? []).find((record) => record.lastSeenIdentity?.orgId === identity.orgId);
1486
+ if (clash)
1487
+ throw new AccountError(`this subscription is already saved as ${clash.id}`);
1488
+ }
1489
+ if (!lstatOrNull(source)?.isDirectory())
1490
+ throw new AccountError('not a directory');
1491
+ ensureRoot();
1492
+ const id = newAccountId();
1493
+ const home = accountHome(id);
1494
+ updateAgentAuth((file) => {
1495
+ file.claudeAccounts = [
1496
+ ...(file.claudeAccounts ?? []),
1497
+ {
1498
+ id,
1499
+ addedAt: new Date().toISOString(),
1500
+ ...(identity ? { lastSeenIdentity: { ...identity, at: new Date().toISOString() } } : {}),
1501
+ },
1502
+ ];
1503
+ });
1504
+ try {
1505
+ fs.renameSync(source, home);
1506
+ }
1507
+ catch (error) {
1508
+ updateAgentAuth((file) => {
1509
+ file.claudeAccounts = (file.claudeAccounts ?? []).filter((entry) => entry.id !== id);
1510
+ if (file.claudeAccounts.length === 0)
1511
+ delete file.claudeAccounts;
1512
+ });
1513
+ if (error.code === 'EXDEV') {
1514
+ throw new AccountError('the login is on another filesystem – it is moved, never copied');
1515
+ }
1516
+ throw error;
1517
+ }
1518
+ fs.chmodSync(home, 0o700);
1519
+ // Listed and forgettable from here on; a link or seed that fails is logged and
1520
+ // repaired at the first session under this account, not a reason to lose the row.
1521
+ try {
1522
+ ensureSharedLinks(home);
1523
+ ensureHomeConfig(home);
1524
+ }
1525
+ catch (error) {
1526
+ log.warn('claude-homes: a moved-in home could not be fully prepared', {
1527
+ account: id,
1528
+ error: String(error),
1529
+ });
1530
+ }
1531
+ log.info('claude-homes: a login home was moved into the store', { account: id });
1532
+ return cardOf(id);
1533
+ }
1534
+ /**
1535
+ * Forget a saved account: its home goes, its record goes (§8 `agent_account_forget`).
1536
+ *
1537
+ * The machine row cannot be forgotten. A row a live session runs under is
1538
+ * refused – its process writes into that home until it ends. `rmSync` removes
1539
+ * the links of the home as links: nothing in `~/.claude` is followed or touched.
1540
+ * Forgetting the active row makes the machine login active.
1541
+ */
1542
+ export function forgetAccount(id) {
1543
+ if (id === MACHINE_ACCOUNT_ID) {
1544
+ throw new AccountError('the login of this machine cannot be forgotten');
1545
+ }
1546
+ const record = isAccountId(id) ? recordOf(id) : undefined;
1547
+ if (!record)
1548
+ throw new AccountError('no such account on this server');
1549
+ if (liveAccountIds().has(id)) {
1550
+ throw new AccountError('a running session uses this account – stop it first, then forget it');
1551
+ }
1552
+ fs.rmSync(accountHome(id), { recursive: true, force: true });
1553
+ updateAgentAuth((file) => {
1554
+ file.claudeAccounts = (file.claudeAccounts ?? []).filter((entry) => entry.id !== id);
1555
+ if (file.claudeAccounts.length === 0)
1556
+ delete file.claudeAccounts;
1557
+ if (file.claudeActiveAccount === id)
1558
+ delete file.claudeActiveAccount;
1559
+ });
1560
+ invalidateUsageCache({
1561
+ id,
1562
+ ...(record.lastSeenIdentity?.orgId ? { orgId: record.lastSeenIdentity.orgId } : {}),
1563
+ });
1564
+ log.info('claude-homes: an account was forgotten', { account: id });
1565
+ return { active: activeAccountId() };
1566
+ }
1567
+ //# sourceMappingURL=claude-homes.js.map