@phnx-labs/agents-cli 1.22.104 → 1.22.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +19 -1
  3. package/dist/browser.js +0 -0
  4. package/dist/commands/exec.js +135 -233
  5. package/dist/commands/resume.d.ts +6 -21
  6. package/dist/commands/resume.js +18 -55
  7. package/dist/commands/run-device-picker.d.ts +58 -0
  8. package/dist/commands/run-device-picker.js +222 -0
  9. package/dist/commands/sessions-resume.d.ts +4 -0
  10. package/dist/commands/sessions-resume.js +132 -49
  11. package/dist/commands/sessions.js +32 -5
  12. package/dist/commands/setup-computer.js +2 -2
  13. package/dist/index.js +0 -0
  14. package/dist/lib/accounting/account-launch.d.ts +54 -0
  15. package/dist/lib/accounting/account-launch.js +117 -0
  16. package/dist/lib/accounting/account-pool-collect.js +2 -1
  17. package/dist/lib/accounting/account-pool.d.ts +2 -0
  18. package/dist/lib/accounting/account-pool.js +1 -0
  19. package/dist/lib/accounting/rotate.d.ts +32 -6
  20. package/dist/lib/accounting/rotate.js +70 -42
  21. package/dist/lib/accounting/usage.d.ts +71 -0
  22. package/dist/lib/accounting/usage.js +160 -11
  23. package/dist/lib/exec-account-home.d.ts +3 -1
  24. package/dist/lib/exec-account-home.js +2 -2
  25. package/dist/lib/exec.d.ts +27 -1
  26. package/dist/lib/exec.js +150 -27
  27. package/dist/lib/models.d.ts +1 -1
  28. package/dist/lib/models.js +4 -4
  29. package/dist/lib/session/actor-sidecar.d.ts +3 -11
  30. package/dist/lib/session/actor-sidecar.js +3 -0
  31. package/dist/lib/session/claude-accounts.d.ts +12 -73
  32. package/dist/lib/session/claude-accounts.js +32 -70
  33. package/dist/lib/session/db.d.ts +1 -1
  34. package/dist/lib/session/db.js +18 -5
  35. package/dist/lib/session/discover.d.ts +4 -0
  36. package/dist/lib/session/discover.js +116 -15
  37. package/dist/lib/session/recovery.d.ts +30 -34
  38. package/dist/lib/session/recovery.js +212 -76
  39. package/dist/lib/session/types.d.ts +2 -0
  40. package/dist/lib/teams/placement-probe.js +1 -1
  41. package/dist/session-tracker/dist/adapters/claude.d.ts +10 -0
  42. package/dist/session-tracker/dist/adapters/claude.js +45 -0
  43. package/dist/session-tracker/dist/hook.sh +191 -0
  44. package/dist/session-tracker/dist/index.d.ts +19 -0
  45. package/dist/session-tracker/dist/index.js +67 -0
  46. package/dist/session-tracker/dist/install-hook.d.ts +19 -0
  47. package/dist/session-tracker/dist/install-hook.js +245 -0
  48. package/dist/session-tracker/dist/prune-state.d.ts +2 -0
  49. package/dist/session-tracker/dist/prune-state.js +7 -0
  50. package/dist/session-tracker/dist/reader.d.ts +7 -0
  51. package/dist/session-tracker/dist/reader.js +151 -0
  52. package/dist/session-tracker/dist/state-file.d.ts +10 -0
  53. package/dist/session-tracker/dist/state-file.js +119 -0
  54. package/dist/session-tracker/dist/types.d.ts +32 -0
  55. package/dist/session-tracker/dist/types.js +1 -0
  56. package/dist/session-tracker/dist/writer.d.ts +12 -0
  57. package/dist/session-tracker/dist/writer.js +27 -0
  58. package/package.json +1 -1
@@ -173,13 +173,13 @@ async function matchLegacyIdentityHome(agent, account, meta) {
173
173
  * Existing slot, then a provisionable worker slot, then a leftover `homes`
174
174
  * label, then identity match across installed homes, then fail loud.
175
175
  */
176
- export async function resolveNativeSpawnHome(agent, account, meta = { accounts: undefined, deviceAccounts: undefined }) {
176
+ export async function resolveNativeSpawnHome(agent, account, meta = { accounts: undefined, deviceAccounts: undefined }, options = {}) {
177
177
  const slot = readSlots(meta)[account.id];
178
178
  if (slot && fs.existsSync(slot.slotDir)) {
179
179
  return { execHome: slot.slotDir, source: 'slot', slot };
180
180
  }
181
181
  const row = nativeRow(account.id, meta);
182
- if (row && isProvisionableWorker(row)) {
182
+ if (!options.readOnly && row && isProvisionableWorker(row)) {
183
183
  const provisioned = provisionWorkerSlot(row);
184
184
  return { execHome: provisioned.slotDir, source: 'provisioned', slot: provisioned };
185
185
  }
@@ -244,6 +244,17 @@ export interface ExecOptions {
244
244
  launchSignedIn?: boolean | null;
245
245
  /** Precomputed account email companion to {@link launchSignedIn}. */
246
246
  launchEmail?: string | null;
247
+ /**
248
+ * Stable native-account registry id this run authenticates as (the same
249
+ * identity `candidateAccountKey`/`modelRefusalAccountKey` key on) —
250
+ * independent of `version`, which is the binary rather than the account
251
+ * identity for a slot launch (PHNX-3940 T5). Exported to
252
+ * `AGENTS_RUN_ACCOUNT_ID` and stamped on the session-actor sidecar so a
253
+ * later model-refusal lookup or recovery pick can resolve the exact account
254
+ * a session ran under, not just the version home it launched from. Absent
255
+ * for a run whose account identity isn't resolved at launch time.
256
+ */
257
+ accountId?: string;
247
258
  }
248
259
  /**
249
260
  * Identity a custom-harness run stamps on env / pid-registry / sidecars.
@@ -656,12 +667,27 @@ export type ClaudeRefusalAction = {
656
667
  resetsAt: Date;
657
668
  } | {
658
669
  action: 'note_out_of_credits';
670
+ } | {
671
+ action: 'note_model_limit';
672
+ model: string;
673
+ family: string;
659
674
  } | {
660
675
  action: 'clear';
661
676
  } | {
662
677
  action: 'none';
663
678
  };
664
- export declare function classifyClaudeRunRefusal(output: string, exitCode: number): ClaudeRefusalAction;
679
+ /**
680
+ * Classify a Claude run's output + exit code, model-limit refusal included.
681
+ * Precedence: a session-limit reset first (it carries a clock), then a
682
+ * clock-less billing exhaustion, then a MODEL-specific refusal ("You've
683
+ * reached your Fable limit…") — checked BEFORE the exit-0 clear so a model
684
+ * refusal that ends the run with exit 0 is never misread as a clean success
685
+ * that clears every other stale marker on the account. A model refusal never
686
+ * maps to `note_out_of_credits`/`note_session`: those are account-wide, this
687
+ * names one model. Only a clean run with NO refusal text clears stale
688
+ * markers; anything else leaves them untouched.
689
+ */
690
+ export declare function classifyClaudeRunRefusal(output: string, exitCode: number, model?: string): ClaudeRefusalAction;
665
691
  /**
666
692
  * Parse Codex's usage-limit refusal reset. Codex prints
667
693
  * `ERROR: You've hit your usage limit. … try again at Sep 12th, 2026 8:32 AM.`
package/dist/lib/exec.js CHANGED
@@ -44,7 +44,8 @@ import { resolveHarnessAdapter, stripForeignConfigDir } from './harness/index.js
44
44
  import { claudeWorkerLoginTrapPreflight } from './harness/adapters/claude.js';
45
45
  import { resolveConfigVersion } from './harness/exec-config-version.js';
46
46
  import { getAccountInfo } from './agents.js';
47
- import { getUsageLookupKey, noteClaudeSessionLimit, noteClaudeOutOfCredits, clearClaudeAccountRefusal, parseClaudeSessionLimitReset } from './accounting/usage.js';
47
+ import { getUsageLookupKey, noteClaudeSessionLimit, noteClaudeOutOfCredits, clearClaudeAccountRefusal, parseClaudeSessionLimitReset, noteClaudeModelRefusal, clearClaudeModelRefusal, parseClaudeModelRefusal, claudeModelRefusalKey, } from './accounting/usage.js';
48
+ import { claudeProjectDirName } from './project-key.js';
48
49
  import { bootMark, flushBootProfile } from './boot-profile.js';
49
50
  /**
50
51
  * Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
@@ -445,6 +446,15 @@ export function buildExecEnv(options) {
445
446
  else {
446
447
  delete result.AGENTS_EXEC_HOME;
447
448
  }
449
+ // Durable account identity for this run (PHNX-3940 model-refusal tracking).
450
+ // Cleared when absent so a run spawned from inside an account-scoped session
451
+ // never inherits its parent's account id.
452
+ if (options.accountId) {
453
+ result.AGENTS_RUN_ACCOUNT_ID = options.accountId;
454
+ }
455
+ else {
456
+ delete result.AGENTS_RUN_ACCOUNT_ID;
457
+ }
448
458
  // Export the run's durable name (companion to AGENT_SESSION_ID) so a
449
459
  // SessionStart hook / the agent can associate its transcript with the handle
450
460
  // the user gave the run. Only set when --name was passed.
@@ -471,7 +481,7 @@ export function ensureVendorHomeDir(agent, versionHome) {
471
481
  }
472
482
  function resolveExecConfigHome(options) {
473
483
  if (options.execHome) {
474
- const resolved = options.version
484
+ const resolved = options.configVersion ?? options.version
475
485
  ?? resolveConfigVersion(options.agent, options.cwd || process.cwd(), options.version).version;
476
486
  return { version: resolved ?? null, versionHome: options.execHome };
477
487
  }
@@ -1430,22 +1440,31 @@ async function runInTmux(options, executable, args) {
1430
1440
  const name = slugifyName(`ag-${options.agent}-${idSeed}`);
1431
1441
  const RED = '\x1b[31m', GRAY = '\x1b[90m', OFF = '\x1b[0m';
1432
1442
  const NO_TMUX_TIP = `${GRAY} This run used the opt-in tmux wrap. Re-run with --no-tmux for a direct launch, or turn the wrap off: agents config set devices.${machineId()}.tmux off${OFF}\n\n`;
1443
+ // Read a dead pane's scrollback. Must run BEFORE killSession — capture-pane
1444
+ // needs the session still alive (remain-on-exit keeps the dead pane readable
1445
+ // until we tear it down). Best-effort: a missing/gone pane just yields ''.
1446
+ // Callers thread this into SpawnResult.stdout for a GENUINELY dead pane only
1447
+ // (positive proof from paneExitStatus) — never for the "still alive, user
1448
+ // detached" or "outcome unknown" paths below, so an interactive detach or an
1449
+ // unresolved tmux race can never masquerade as refusal evidence.
1450
+ const capturePaneTail = async (pane) => {
1451
+ if (!pane)
1452
+ return '';
1453
+ try {
1454
+ const r = await runTmux({ socket, args: ['capture-pane', '-p', '-t', pane, '-S', '-200'], throwOnError: false });
1455
+ return r.code === 0 ? formatPaneTail(r.stdout) : '';
1456
+ }
1457
+ catch {
1458
+ return '';
1459
+ }
1460
+ };
1433
1461
  // Recap a dead pane's tail into THIS shell's stderr. The pane-died hook
1434
1462
  // detaches the client the instant the agent exits, so a fast failure (a
1435
1463
  // gutted install that dies with ENOENT, a bad flag, a crash on startup) would
1436
- // otherwise leave only a bare `[detached]` with no clue why. Must run BEFORE
1437
- // killSession — capture-pane needs the session still alive (remain-on-exit
1438
- // keeps the dead pane readable until we tear it down). Best-effort throughout.
1439
- const surfacePaneFailure = async (pane, status, headline) => {
1464
+ // otherwise leave only a bare `[detached]` with no clue why.
1465
+ const surfacePaneFailure = async (pane, status, headline, tail) => {
1440
1466
  if (!pane)
1441
1467
  return;
1442
- let tail = '';
1443
- try {
1444
- const r = await runTmux({ socket, args: ['capture-pane', '-p', '-t', pane, '-S', '-200'], throwOnError: false });
1445
- if (r.code === 0)
1446
- tail = formatPaneTail(r.stdout);
1447
- }
1448
- catch { /* best-effort — a missing pane just means no recap */ }
1449
1468
  process.stderr.write(`\n${RED}agents: ${headline} (exit ${status ?? UNKNOWN_OUTCOME_EXIT_CODE}).${OFF}\n`);
1450
1469
  if (tail) {
1451
1470
  process.stderr.write(`${GRAY} ── last output from ${options.agent} ──${OFF}\n`);
@@ -1477,18 +1496,25 @@ async function runInTmux(options, executable, args) {
1477
1496
  const resolveAfterAttach = async (pane) => {
1478
1497
  const after = pane ? await paneExitStatus(pane, socket) : { found: false, dead: false, status: undefined };
1479
1498
  if (after.dead) {
1499
+ // Positive proof of a genuinely completed pane (paneExitStatus confirmed
1500
+ // dead) — capture its scrollback as refusal evidence for the caller
1501
+ // (classifyClaudeRunRefusal etc.) BEFORE tearing the session down.
1502
+ // Deliberately not done for the "alive" (detach) or "unknown" branches
1503
+ // below: neither is a demonstrated completion, so neither may carry
1504
+ // evidence a caller could mistake for a real refusal or success.
1505
+ const tail = await capturePaneTail(pane);
1480
1506
  // Nonzero exit after attach → the agent crashed rather than the user
1481
1507
  // detaching cleanly (a clean detach leaves the pane ALIVE, handled below).
1482
1508
  // F2: for interactive runs, also recap a clean exit-0 — the harness exited
1483
1509
  // without error but without starting a REPL, which is still a failure.
1484
1510
  if (shouldRecapDeadPane(after.status, resolveInteractive(options))) {
1485
- await surfacePaneFailure(pane, after.status, `${options.agent} exited`);
1511
+ await surfacePaneFailure(pane, after.status, `${options.agent} exited`, tail);
1486
1512
  }
1487
1513
  await killSession(name, socket).catch(() => { });
1488
1514
  // A dead pane whose status tmux never reported is UNKNOWN, not success —
1489
1515
  // and surfacePaneFailure already printed `exit 1` for it, so the old
1490
1516
  // `?? 0` made the message and the returned code disagree (EXEC-23b).
1491
- return { exitCode: tmuxRunExitCode(after, false), stderr: '', stdout: '' };
1517
+ return { exitCode: tmuxRunExitCode(after, false), stderr: '', stdout: tail };
1492
1518
  }
1493
1519
  // after.dead===false, but that could be a stale/unreadable-pane result.
1494
1520
  // Require positive proof before keeping the session as "user detached".
@@ -1618,6 +1644,7 @@ async function runInTmux(options, executable, args) {
1618
1644
  initiatedBy: resolveActor().kind,
1619
1645
  phoenixId: resolveActor().phoenixId,
1620
1646
  harness: customHarnessName(options),
1647
+ accountId: options.accountId,
1621
1648
  startedAtMs: Date.now(),
1622
1649
  });
1623
1650
  }
@@ -1626,18 +1653,22 @@ async function runInTmux(options, executable, args) {
1626
1653
  // already-dead pane — surface its output + status directly and tear down.
1627
1654
  const before = pane ? await paneExitStatus(pane, socket) : { found: false, dead: false, status: undefined };
1628
1655
  if (before.dead) {
1656
+ // Positive proof of a genuinely completed pane — capture scrollback as
1657
+ // refusal evidence before tearing the session down (see the matching
1658
+ // comment in resolveAfterAttach).
1659
+ const tail = await capturePaneTail(pane);
1629
1660
  // F2 (RUSH-2185 / EXEC-23a): for interactive runs, ALWAYS recap — a clean
1630
1661
  // exit-0 before attach means the harness has no interactive REPL and the
1631
1662
  // user would see only a bare `[detached]` with no clue why. For headless
1632
1663
  // runs the old quiet behaviour stands: exit-0 is a successful quick run.
1633
1664
  if (shouldRecapDeadPane(before.status, resolveInteractive(options))) {
1634
- await surfacePaneFailure(pane, before.status, `${options.agent} exited before it could start`);
1665
+ await surfacePaneFailure(pane, before.status, `${options.agent} exited before it could start`, tail);
1635
1666
  }
1636
1667
  await killSession(name, socket).catch(() => { });
1637
1668
  // A dead pane whose status tmux never reported is an UNKNOWN outcome, not a
1638
1669
  // success — and the banner one line up already printed `exit 1` for it, so
1639
1670
  // the old `?? 0` also made the message and the returned code disagree.
1640
- return { exitCode: tmuxRunExitCode(before, false), stderr: '', stdout: '' };
1671
+ return { exitCode: tmuxRunExitCode(before, false), stderr: '', stdout: tail };
1641
1672
  }
1642
1673
  await attachTmux({ socket, args: ['attach-session', '-t', name] });
1643
1674
  return resolveAfterAttach(pane);
@@ -1756,10 +1787,74 @@ async function emitRunLaunch(ctx) {
1756
1787
  }
1757
1788
  }
1758
1789
  async function spawnAgent(options) {
1790
+ if (options.agent === 'claude' && !options.resume && !options.sessionId)
1791
+ options = { ...options, sessionId: randomUUID() };
1759
1792
  const version = options.version ?? resolveVersion(options.agent, options.cwd || process.cwd());
1760
- if (!version || !isVersionInstalled(options.agent, version))
1761
- return spawnAgentLeased(options);
1762
- return withInstallationLease(options.agent, version, () => spawnAgentLeased(options));
1793
+ const home = resolveExecConfigHome(options).versionHome;
1794
+ const modelKey = claudeModelRefusalKey(options.accountId, home);
1795
+ const transcript = options.agent === 'claude' && home && options.sessionId
1796
+ ? path.join(home, '.claude', 'projects', claudeProjectDirName(options.cwd || process.cwd()), `${options.sessionId}.jsonl`)
1797
+ : undefined;
1798
+ let offset = transcript && fs.existsSync(transcript) ? fs.statSync(transcript).size : 0;
1799
+ const observeTranscript = () => {
1800
+ if (!transcript || !modelKey)
1801
+ return;
1802
+ try {
1803
+ const size = fs.statSync(transcript).size;
1804
+ if (size <= offset)
1805
+ return;
1806
+ const start = Math.max(offset, size - 64 * 1024);
1807
+ const fd = fs.openSync(transcript, 'r');
1808
+ const bytes = Buffer.alloc(size - start);
1809
+ try {
1810
+ fs.readSync(fd, bytes, 0, bytes.length, start);
1811
+ }
1812
+ finally {
1813
+ fs.closeSync(fd);
1814
+ }
1815
+ const text = bytes.toString('utf8');
1816
+ const lastNewline = text.lastIndexOf('\n');
1817
+ if (lastNewline < 0)
1818
+ return;
1819
+ offset = start + Buffer.byteLength(text.slice(0, lastNewline + 1));
1820
+ for (const line of text.slice(0, lastNewline).split('\n')) {
1821
+ try {
1822
+ const row = JSON.parse(line);
1823
+ if (row.type !== 'assistant')
1824
+ continue;
1825
+ const content = Array.isArray(row.message?.content) ? row.message.content.filter((block) => block.type === 'text').map((block) => block.text ?? '').join('\n') : '';
1826
+ const refusal = row.isApiErrorMessage ? parseClaudeModelRefusal(content) : null;
1827
+ if (refusal)
1828
+ noteClaudeModelRefusal(modelKey, refusal.family, { family: refusal.family });
1829
+ else if (!row.isApiErrorMessage && row.message?.model && content.trim())
1830
+ clearClaudeModelRefusal(modelKey, row.message.model);
1831
+ }
1832
+ catch { /* Partial or non-message transcript rows provide no evidence. */ }
1833
+ }
1834
+ }
1835
+ catch { /* Transcript observation must not interrupt the harness. */ }
1836
+ };
1837
+ const observer = transcript ? setInterval(observeTranscript, 2_000) : undefined;
1838
+ observer?.unref();
1839
+ try {
1840
+ const observedOptions = options.agent === 'claude' ? { ...options, captureStdoutTail: true } : options;
1841
+ const result = !version || !isVersionInstalled(options.agent, version)
1842
+ ? await spawnAgentLeased(observedOptions)
1843
+ : await withInstallationLease(options.agent, version, () => spawnAgentLeased(observedOptions));
1844
+ observeTranscript();
1845
+ if (options.agent === 'claude' && modelKey) {
1846
+ const refusal = parseClaudeModelRefusal(`${result.stderr}\n${result.stdout}`);
1847
+ if (refusal) {
1848
+ noteClaudeModelRefusal(modelKey, refusal.family, { family: refusal.family });
1849
+ return { ...result, exitCode: result.exitCode || 1 };
1850
+ }
1851
+ }
1852
+ return result;
1853
+ }
1854
+ finally {
1855
+ if (observer)
1856
+ clearInterval(observer);
1857
+ }
1763
1858
  }
1764
1859
  async function spawnAgentLeased(options) {
1765
1860
  bootMark('spawn-agent:enter');
@@ -1987,6 +2082,7 @@ async function spawnAgentLeased(options) {
1987
2082
  initiatedBy: resolveActor().kind,
1988
2083
  phoenixId: resolveActor().phoenixId,
1989
2084
  harness: customHarnessName(options),
2085
+ accountId: options.accountId,
1990
2086
  startedAtMs: Date.now(),
1991
2087
  });
1992
2088
  }
@@ -2193,12 +2289,27 @@ const OUT_OF_CREDITS_PATTERNS = [
2193
2289
  export function detectOutOfCredits(text) {
2194
2290
  return OUT_OF_CREDITS_PATTERNS.some(pattern => pattern.test(text));
2195
2291
  }
2196
- export function classifyClaudeRunRefusal(output, exitCode) {
2292
+ /**
2293
+ * Classify a Claude run's output + exit code, model-limit refusal included.
2294
+ * Precedence: a session-limit reset first (it carries a clock), then a
2295
+ * clock-less billing exhaustion, then a MODEL-specific refusal ("You've
2296
+ * reached your Fable limit…") — checked BEFORE the exit-0 clear so a model
2297
+ * refusal that ends the run with exit 0 is never misread as a clean success
2298
+ * that clears every other stale marker on the account. A model refusal never
2299
+ * maps to `note_out_of_credits`/`note_session`: those are account-wide, this
2300
+ * names one model. Only a clean run with NO refusal text clears stale
2301
+ * markers; anything else leaves them untouched.
2302
+ */
2303
+ export function classifyClaudeRunRefusal(output, exitCode, model) {
2197
2304
  const sessionLimitReset = parseClaudeSessionLimitReset(output);
2198
2305
  if (sessionLimitReset)
2199
2306
  return { action: 'note_session', resetsAt: sessionLimitReset };
2200
2307
  if (detectOutOfCredits(output))
2201
2308
  return { action: 'note_out_of_credits' };
2309
+ const modelRefusal = parseClaudeModelRefusal(output);
2310
+ if (modelRefusal) {
2311
+ return { action: 'note_model_limit', model: model ?? modelRefusal.family, family: modelRefusal.family };
2312
+ }
2202
2313
  if (exitCode === 0)
2203
2314
  return { action: 'clear' };
2204
2315
  return { action: 'none' };
@@ -2500,29 +2611,41 @@ export async function runWithFallback(options) {
2500
2611
  // harnesses), so one path notes either. Decisions extracted + unit-tested in
2501
2612
  // classify{Claude,Codex}RunRefusal.
2502
2613
  const refusal = agent === 'claude'
2503
- ? classifyClaudeRunRefusal(output, result.exitCode ?? 1)
2614
+ ? classifyClaudeRunRefusal(output, result.exitCode ?? 1, execOpts.model)
2504
2615
  : agent === 'codex'
2505
2616
  ? classifyCodexRunRefusal(output, result.exitCode ?? 1)
2506
2617
  : null;
2507
2618
  const sessionLimitReset = refusal?.action === 'note_session' ? refusal.resetsAt : null;
2508
2619
  if (refusal && version && refusal.action !== 'none') {
2509
- const account = await getAccountInfo(agent, getVersionHomePath(agent, version));
2620
+ // Resolve the account from the HOME this attempt actually authenticated
2621
+ // from — execHome (a slot dir, PHNX-3940 T5) or configVersion wins over
2622
+ // the bare managed-binary version home. Reading `getVersionHomePath(agent,
2623
+ // version)` unconditionally attributed a refusal to the wrong account for
2624
+ // every account-slot / configVersion launch, since `version` there is the
2625
+ // BINARY, not the credential's home.
2626
+ const { versionHome: refusalHome } = resolveExecConfigHome(execOpts);
2627
+ const account = await getAccountInfo(agent, refusalHome ?? getVersionHomePath(agent, version));
2510
2628
  const usageKey = getUsageLookupKey(account);
2511
- if (usageKey) {
2629
+ if (usageKey && refusal.action !== 'note_model_limit') {
2512
2630
  if (refusal.action === 'note_session')
2513
2631
  noteClaudeSessionLimit(usageKey, refusal.resetsAt);
2514
2632
  else if (refusal.action === 'note_out_of_credits')
2515
2633
  noteClaudeOutOfCredits(usageKey);
2516
- else if (refusal.action === 'clear')
2634
+ else if (refusal.action === 'clear' && !resolveInteractive(execOpts))
2517
2635
  clearClaudeAccountRefusal(usageKey);
2518
2636
  }
2519
2637
  }
2520
- if (result.exitCode === 0 && !sessionLimitReset)
2638
+ // A model-limit refusal commonly ends the CLI turn with exit 0 (Claude
2639
+ // just refuses to keep going on that model rather than crashing) — so,
2640
+ // like a session-limit reset, it must not be read as a clean success that
2641
+ // short-circuits the cascade before the fallback chain ever runs.
2642
+ const modelLimited = refusal?.action === 'note_model_limit';
2643
+ if (result.exitCode === 0 && !sessionLimitReset && !modelLimited)
2521
2644
  return 0;
2522
2645
  const isLast = i === chain.length - 1;
2523
2646
  if (isLast)
2524
2647
  return result.exitCode || 1;
2525
- if (!sessionLimitReset && !detectRateLimit(result.stderr) && !detectRateLimit(result.stdout)) {
2648
+ if (!sessionLimitReset && !modelLimited && !detectRateLimit(result.stderr) && !detectRateLimit(result.stdout)) {
2526
2649
  return result.exitCode;
2527
2650
  }
2528
2651
  const next = chain[i + 1];
@@ -187,7 +187,7 @@ export interface ConfiguredModel {
187
187
  * Each layer is a real source the agent consults; `version` must be concrete.
188
188
  * Returns null only when the agent exposes no model catalog at all.
189
189
  */
190
- export declare function resolveConfiguredModel(agent: AgentId, version: string): ConfiguredModel | null;
190
+ export declare function resolveConfiguredModel(agent: AgentId, version: string, home?: string): ConfiguredModel | null;
191
191
  /**
192
192
  * Join the identity cluster — `agent@version · model · account` — with a dim
193
193
  * separator, dropping empty pieces. Pieces are pre-colored by the caller so the
@@ -1007,11 +1007,11 @@ export function resolveEffectiveModel(agent, version, requested) {
1007
1007
  * Each layer is a real source the agent consults; `version` must be concrete.
1008
1008
  * Returns null only when the agent exposes no model catalog at all.
1009
1009
  */
1010
- export function resolveConfiguredModel(agent, version) {
1010
+ export function resolveConfiguredModel(agent, version, home) {
1011
1011
  const runModel = resolveRunDefaults(agent, version).model;
1012
1012
  if (runModel && runModel.trim() !== '')
1013
1013
  return { model: runModel, source: 'run-default' };
1014
- const nativeModel = readNativeConfigModel(agent, version);
1014
+ const nativeModel = readNativeConfigModel(agent, version, home);
1015
1015
  if (nativeModel)
1016
1016
  return { model: nativeModel, source: 'config' };
1017
1017
  const catalog = getModelCatalog(agent, version);
@@ -1026,9 +1026,9 @@ export function resolveConfiguredModel(agent, version) {
1026
1026
  * (e.g. `~/.agents/.history/versions/claude/<ver>/home/.claude/settings.json`).
1027
1027
  * A missing/malformed file is a fall-through, not an error.
1028
1028
  */
1029
- function readNativeConfigModel(agent, version) {
1029
+ function readNativeConfigModel(agent, version, home) {
1030
1030
  try {
1031
- const settingsPath = path.join(getVersionHomePath(agent, version), agentConfigDirName(agent), 'settings.json');
1031
+ const settingsPath = path.join(home ?? getVersionHomePath(agent, version), agentConfigDirName(agent), 'settings.json');
1032
1032
  const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
1033
1033
  return typeof parsed.model === 'string' && parsed.model.trim() !== '' ? parsed.model : null;
1034
1034
  }
@@ -14,18 +14,10 @@ export interface SessionActorRecord {
14
14
  phoenixId?: string;
15
15
  /** Effective permissions mode used by the launcher. */
16
16
  mode?: SessionRunMode;
17
- /**
18
- * The agents-cli version-home id this session launched under (e.g. `2.1.207`,
19
- * codex `0.146.0`) — the same namespace `listInstalledVersions` /
20
- * `collectRunCandidates` use, so a native resume can pin the exact origin
21
- * version. Recorded at launch by the SessionStart hook (from `AGENTS_RUN_VERSION`),
22
- * because a harness coins its real session id only AFTER spawn — the same reason
23
- * `mode` rides the hook rather than a spawn-time `writeSessionActorRecord`. Joined
24
- * onto the session index at scan time so a session whose transcript carries no
25
- * embedded/derivable version (codex's `.codex-homes/<version>/` layout) no longer
26
- * degrades native resume to `/continue` for lack of a recorded origin (PHNX-3626).
27
- */
17
+ /** Installed executable label at launch; provenance only, never account identity. */
28
18
  version?: string;
19
+ /** Credential account used at launch, independent of the installed executable. */
20
+ accountId?: string;
29
21
  /**
30
22
  * Custom harness / profile name when launched via `agents run <profile>`
31
23
  * (e.g. `deepseek`). Joined onto the session index at scan time so a
@@ -42,6 +42,7 @@ function hasRecordData(record) {
42
42
  || typeof record.phoenixId === 'string'
43
43
  || typeof record.mode === 'string'
44
44
  || typeof record.version === 'string'
45
+ || typeof record.accountId === 'string'
45
46
  || typeof record.harness === 'string'
46
47
  || (Array.isArray(record.aliases) && record.aliases.some(alias => typeof alias === 'string'));
47
48
  }
@@ -69,6 +70,7 @@ export function writeSessionActorRecord(record) {
69
70
  writeRecord({
70
71
  ...previous,
71
72
  ...record,
73
+ accountId: previous?.accountId ?? record.accountId,
72
74
  aliases: normalizedAliases([...(previous?.aliases ?? []), ...(record.aliases ?? [])]),
73
75
  });
74
76
  }
@@ -88,6 +90,7 @@ export function writeSessionAliasRecord(sessionId, alias) {
88
90
  phoenixId: previous?.phoenixId,
89
91
  mode: previous?.mode,
90
92
  version: previous?.version,
93
+ accountId: previous?.accountId,
91
94
  harness: previous?.harness,
92
95
  aliases: normalizedAliases([...(previous?.aliases ?? []), alias]),
93
96
  startedAtMs: previous?.startedAtMs ?? Date.now(),
@@ -1,63 +1,3 @@
1
- /**
2
- * Which Claude account produced a transcript.
3
- *
4
- * A Claude `.jsonl` records `sessionId`, `cwd`, `version`, `gitBranch` and per-message
5
- * `usage`, but carries **no account identity** — no `accountUuid`, no
6
- * `organizationUuid`, no email. What agents-cli does have is the version layout: every
7
- * installed version gets its own home with its own `.claude.json` (`CLAUDE_CONFIG_DIR`
8
- * is swapped per version, see lib/exec.ts), so a home identifies an account.
9
- *
10
- * This matters because the default run strategy is `balanced` (lib/rotate.ts), which
11
- * sprays sessions across every signed-in account. Before this module the scanner
12
- * resolved ONE email process-globally and stamped it on every Claude session, so a
13
- * machine with several accounts reported all of its history under whichever one
14
- * happened to resolve first.
15
- *
16
- * Grouping is keyed on the **org** (`usageKey`), never the email: two orgs under one
17
- * email (a Team seat and a personal Max plan) are separate quota buckets and must stay
18
- * distinct — the same invariant `candidateIdentity` enforces in lib/rotate.ts.
19
- *
20
- * ## Evidence tiers
21
- *
22
- * Attribution is a pure function of (path, recorded version). It performs no per-file
23
- * I/O and does not need the transcript to still exist, which is what lets the v33
24
- * migration backfill already-indexed rows without re-parsing anything.
25
- *
26
- * 1. **The path names a home we can identify.** Strongest: the file physically lives in
27
- * that home, including a retired `trash/` snapshot, which keeps its `.claude.json`.
28
- * 1b. **The path names a home that exists but is signed out.** Dark, named after that
29
- * home. The location proves which config dir Claude used, so this deliberately beats
30
- * a recorded version — attributing it to some other version's account would be a
31
- * guess dressed as evidence.
32
- * 2. **The path is outside every known home, and the row records a version.** Resolve
33
- * that version's own home. Covers the mutable `~/.claude` symlink and the routine
34
- * archives under `<historyDir>/runs` that `readRoutineArchiveMeta` feeds in. The
35
- * symlink's target moves with `agents use`, so "whatever it points at now" is weak
36
- * evidence for old rows: on the machine this was developed against only 684 of 1,334
37
- * such rows came from the version the symlink currently names, and 322 came from
38
- * versions belonging to a *different* org.
39
- * 3. **Under the symlink with no recorded version at all.** Its current target is the
40
- * only evidence there is, and the bucket says so via `evidence`. A version that IS
41
- * recorded but resolves to no home stops at tier 2 and stays dark — it never
42
- * reaches here.
43
- * 4. **None of the above.** An explicitly dark bucket, labelled with why. Never folded
44
- * into a real account and never dropped.
45
- *
46
- * ## Harness scope: Claude only, deliberately
47
- *
48
- * Attribution is implemented for Claude and no other harness. It depends on the
49
- * per-version home carrying an `oauthAccount` in `.claude.json`, which is what makes a
50
- * home equal an account. The other harnesses do have per-version credential files
51
- * (`CREDENTIAL_FILE_SEGMENTS` in lib/agents.ts), so the mechanism generalizes — codex
52
- * stores an `auth.json` JWT, gemini a `google_accounts.json` — but each needs its own
53
- * identity extractor and its own notion of a quota bucket, and none of them has the
54
- * two-orgs-one-email problem that motivated keying on the org here.
55
- *
56
- * Until that lands, a non-Claude session has a NULL `account_key` and rolls up under
57
- * `unattributed:<agent>` — named after its harness rather than implying we tried and
58
- * failed. `--by account` on `agents insights cost` / `agents insights output` therefore reports Claude
59
- * accounts plus one bucket per other harness.
60
- */
61
1
  /** The account a transcript is attributed to. */
62
2
  export interface ClaudeAccountBucket {
63
3
  /**
@@ -84,7 +24,7 @@ interface HomeEntry {
84
24
  }
85
25
  /** Resolver over the Claude homes present on this machine. */
86
26
  export interface ClaudeAccountIndex {
87
- /** Version- and trash-home prefixes, longest first. Excludes the `~/.claude` symlink. */
27
+ /** Version-, account-slot-, and trash-home prefixes, longest first. Excludes the `~/.claude` symlink. */
88
28
  entries: HomeEntry[];
89
29
  /**
90
30
  * Config-dir prefixes of homes that exist but carry no `oauthAccount`. Kept
@@ -102,6 +42,15 @@ export interface ClaudeAccountIndex {
102
42
  * as dark rather than guessed.
103
43
  */
104
44
  byVersion: Map<string, ClaudeAccountBucket | 'ambiguous'>;
45
+ /**
46
+ * Account-slot id (`<historyDir>/accounts/claude/<accountId>/`, PHNX-3940) →
47
+ * the identity read from that slot's own `.claude.json`. A slot's identity is
48
+ * proven the same way a version home's is — tier 1 evidence, see
49
+ * {@link resolveClaudeAccount} — so this map exists only to let a launch-
50
+ * recorded accountId (once the actor sidecar carries one) resolve straight to
51
+ * a bucket without re-deriving it from a path.
52
+ */
53
+ byAccountId: Map<string, ClaudeAccountBucket>;
105
54
  /** Whatever `~/.claude` points at right now; tier-3 evidence only. */
106
55
  symlinkBucket: ClaudeAccountBucket | null;
107
56
  /** Literal prefix of the live symlinked config dir. */
@@ -113,16 +62,6 @@ export interface ClaudeAccountIndex {
113
62
  * its version was rotated out stays attributable.
114
63
  */
115
64
  export declare function buildClaudeAccountIndex(): ClaudeAccountIndex;
116
- /**
117
- * The account bucket a transcript belongs to. `recordedVersion` is the Claude CLI
118
- * version stored on the session row (`sessions.version`), which is what disambiguates
119
- * rows sitting under the mutable `~/.claude` symlink.
120
- *
121
- * Never returns null: a transcript that matches no known home resolves to an
122
- * explicitly dark bucket rather than being dropped or folded into a real account.
123
- * Backup mirrors (`<historyDir>/backups/claude/<stamp>/projects/…`) carry no
124
- * `.claude.json` of their own, so they resolve by recorded version like any other
125
- * out-of-home path, and go dark only when that version names no home.
126
- */
127
- export declare function resolveClaudeAccount(index: ClaudeAccountIndex, filePath: string, recordedVersion?: string | null): ClaudeAccountBucket;
65
+ /** Resolve a quota bucket without replacing recorded login provenance. */
66
+ export declare function resolveClaudeAccount(index: ClaudeAccountIndex, filePath: string, recordedVersion?: string | null, launchAccountId?: string | null): ClaudeAccountBucket;
128
67
  export {};