@phnx-labs/agents-cli 1.22.105 → 1.22.107

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 (60) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +28 -6
  3. package/dist/browser.js +0 -0
  4. package/dist/commands/exec.d.ts +78 -8
  5. package/dist/commands/exec.js +407 -284
  6. package/dist/commands/resume.d.ts +6 -21
  7. package/dist/commands/resume.js +18 -55
  8. package/dist/commands/run-account-picker.d.ts +11 -0
  9. package/dist/commands/run-account-picker.js +11 -1
  10. package/dist/commands/sessions-resume.d.ts +4 -0
  11. package/dist/commands/sessions-resume.js +132 -49
  12. package/dist/commands/sessions.js +32 -5
  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/hosts/dispatch.d.ts +1 -1
  28. package/dist/lib/hosts/dispatch.js +1 -1
  29. package/dist/lib/models.d.ts +1 -1
  30. package/dist/lib/models.js +4 -4
  31. package/dist/lib/session/actor-sidecar.d.ts +3 -11
  32. package/dist/lib/session/actor-sidecar.js +3 -0
  33. package/dist/lib/session/claude-accounts.d.ts +12 -73
  34. package/dist/lib/session/claude-accounts.js +32 -70
  35. package/dist/lib/session/db.d.ts +1 -1
  36. package/dist/lib/session/db.js +18 -5
  37. package/dist/lib/session/discover.d.ts +4 -0
  38. package/dist/lib/session/discover.js +116 -15
  39. package/dist/lib/session/recovery.d.ts +30 -34
  40. package/dist/lib/session/recovery.js +212 -76
  41. package/dist/lib/session/types.d.ts +2 -0
  42. package/dist/lib/teams/placement-probe.js +1 -1
  43. package/dist/session-tracker/dist/adapters/claude.d.ts +10 -0
  44. package/dist/session-tracker/dist/adapters/claude.js +45 -0
  45. package/dist/session-tracker/dist/hook.sh +191 -0
  46. package/dist/session-tracker/dist/index.d.ts +19 -0
  47. package/dist/session-tracker/dist/index.js +67 -0
  48. package/dist/session-tracker/dist/install-hook.d.ts +19 -0
  49. package/dist/session-tracker/dist/install-hook.js +245 -0
  50. package/dist/session-tracker/dist/prune-state.d.ts +2 -0
  51. package/dist/session-tracker/dist/prune-state.js +7 -0
  52. package/dist/session-tracker/dist/reader.d.ts +7 -0
  53. package/dist/session-tracker/dist/reader.js +151 -0
  54. package/dist/session-tracker/dist/state-file.d.ts +10 -0
  55. package/dist/session-tracker/dist/state-file.js +119 -0
  56. package/dist/session-tracker/dist/types.d.ts +32 -0
  57. package/dist/session-tracker/dist/types.js +1 -0
  58. package/dist/session-tracker/dist/writer.d.ts +12 -0
  59. package/dist/session-tracker/dist/writer.js +27 -0
  60. package/package.json +1 -1
@@ -1790,10 +1790,17 @@ export function writeClaudeUsageCache(usageKey, snapshot, cachePath = getClaudeU
1790
1790
  // refresh cannot drop another account's row (lost update).
1791
1791
  const cache = readClaudeUsageCacheFile(cachePath);
1792
1792
  const prior = cache[usageKey];
1793
- cache[usageKey] = serializeClaudeUsageSnapshot({
1794
- ...snapshot,
1795
- unavailable: carryForwardUnavailable(prior?.unavailable, snapshot.unavailable),
1796
- });
1793
+ cache[usageKey] = {
1794
+ ...serializeClaudeUsageSnapshot({
1795
+ ...snapshot,
1796
+ unavailable: carryForwardUnavailable(prior?.unavailable, snapshot.unavailable),
1797
+ }),
1798
+ // A per-model refusal has its own independent lifecycle (see
1799
+ // noteClaudeModelRefusal / clearClaudeModelRefusal) — a global usage
1800
+ // write must never drop it, the same reason `unavailable` is carried
1801
+ // forward above rather than overwritten.
1802
+ modelRefusals: prior?.modelRefusals,
1803
+ };
1797
1804
  atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
1798
1805
  });
1799
1806
  }
@@ -1814,12 +1821,16 @@ export function mergeClaudeUsageCacheWindows(usageKey, snapshot, cachePath = get
1814
1821
  const windows = new Map(priorSnapshot?.windows.map((window) => [window.key, window]) ?? []);
1815
1822
  for (const window of snapshot.windows)
1816
1823
  windows.set(window.key, window);
1817
- cache[usageKey] = serializeClaudeUsageSnapshot({
1818
- ...snapshot,
1819
- windows: [...windows.values()],
1820
- plan: snapshot.plan ?? priorSnapshot?.plan ?? null,
1821
- unavailable: carryForwardUnavailable(prior?.unavailable, snapshot.unavailable),
1822
- });
1824
+ cache[usageKey] = {
1825
+ ...serializeClaudeUsageSnapshot({
1826
+ ...snapshot,
1827
+ windows: [...windows.values()],
1828
+ plan: snapshot.plan ?? priorSnapshot?.plan ?? null,
1829
+ unavailable: carryForwardUnavailable(prior?.unavailable, snapshot.unavailable),
1830
+ }),
1831
+ // See writeClaudeUsageCache: a per-model refusal survives a windows-only merge too.
1832
+ modelRefusals: prior?.modelRefusals,
1833
+ };
1823
1834
  atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
1824
1835
  });
1825
1836
  }
@@ -2004,7 +2015,8 @@ function deserializeClaudeUsageSnapshot(snapshot, now) {
2004
2015
  staleWindows.length === 0 &&
2005
2016
  !unavailable &&
2006
2017
  !snapshot.plan &&
2007
- !snapshot.refreshHint) {
2018
+ !snapshot.refreshHint &&
2019
+ !hasLiveModelRefusal(snapshot.modelRefusals, now)) {
2008
2020
  return null;
2009
2021
  }
2010
2022
  return {
@@ -2051,6 +2063,17 @@ function deserializeUnavailable(cached, now) {
2051
2063
  ? { reason: 'session_limit', resetsAt: reset }
2052
2064
  : undefined;
2053
2065
  }
2066
+ /** Whether any per-model refusal in the row is still live (not clock-expired). */
2067
+ function hasLiveModelRefusal(modelRefusals, now) {
2068
+ if (!modelRefusals)
2069
+ return false;
2070
+ return Object.values(modelRefusals).some((entry) => {
2071
+ if (!entry.resetsAt)
2072
+ return true; // no clock given — sticky until cleared
2073
+ const reset = parseDateValue(entry.resetsAt);
2074
+ return !reset || reset.getTime() > now.getTime();
2075
+ });
2076
+ }
2054
2077
  /**
2055
2078
  * Persist a Claude tokens/credits exhaustion (`out of usage credits` / `monthly
2056
2079
  * spend limit`) from a real run. Unlike a rate/session limit this does NOT reset
@@ -2115,6 +2138,132 @@ export function noteClaudeSessionLimit(usageKey, resetsAt, cachePath = getClaude
2115
2138
  /* best-effort cache write — lock busy or disk full */
2116
2139
  }
2117
2140
  }
2141
+ /**
2142
+ * Persist a Claude MODEL-specific refusal — "You've reached your Fable limit.
2143
+ * Run /usage-credits to continue or switch models with /model." — a distinct
2144
+ * class from {@link noteClaudeOutOfCredits} / {@link noteClaudeSessionLimit}:
2145
+ * those exclude the whole account, this excludes only ONE model on it (an
2146
+ * organization quota group can meter models separately). `accountKey` MUST be
2147
+ * the candidate's stable native-account key (see `candidateAccountKey` in
2148
+ * rotate.ts), never the org-shared `usageKey` — using the org key here would
2149
+ * poison every sibling account under that org for a limit that named one
2150
+ * model on one login. No invented reset: `resetsAt` is written only when the
2151
+ * refusal text carried one; otherwise the marker is sticky until a later
2152
+ * successful run on this exact (account, model) clears it via
2153
+ * {@link clearClaudeModelRefusal}.
2154
+ */
2155
+ export function claudeModelRefusalKey(accountId, home) {
2156
+ if (accountId)
2157
+ return `account:${accountId}`;
2158
+ if (!home)
2159
+ return undefined;
2160
+ try {
2161
+ return `context:${fs.realpathSync(home)}`;
2162
+ }
2163
+ catch {
2164
+ return undefined;
2165
+ }
2166
+ }
2167
+ function modelFamily(model) {
2168
+ const normalized = model.trim().toLowerCase();
2169
+ return /(?:^|[-\s])(fable|sonnet|opus|haiku)(?:$|[-\s\d])/.exec(normalized)?.[1] ?? normalized;
2170
+ }
2171
+ export function noteClaudeModelRefusal(accountKey, model, refusal, cachePath = getClaudeUsageCachePath()) {
2172
+ try {
2173
+ ensureLockTarget(cachePath, '{}');
2174
+ withFileLock(cachePath, () => {
2175
+ const cache = readClaudeUsageCacheFile(cachePath);
2176
+ const existing = cache[accountKey] ?? { capturedAt: null, windows: [] };
2177
+ const modelRefusals = { ...(existing.modelRefusals ?? {}) };
2178
+ modelRefusals[modelFamily(refusal.family ?? model)] = {
2179
+ family: refusal.family,
2180
+ resetsAt: refusal.resetsAt?.toISOString(),
2181
+ };
2182
+ cache[accountKey] = { ...existing, modelRefusals };
2183
+ atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
2184
+ });
2185
+ }
2186
+ catch {
2187
+ /* best-effort cache write — lock busy or disk full */
2188
+ }
2189
+ }
2190
+ /**
2191
+ * Clear a persisted model-refusal marker for exactly ONE (account, model)
2192
+ * pair after a run SUCCEEDS on that same account+model. Never clears a
2193
+ * sibling model on the same account, and never fires for an interactive
2194
+ * detach or an unknown outcome — the caller must have demonstrated an actual
2195
+ * completed success on this exact model before calling this.
2196
+ */
2197
+ export function clearClaudeModelRefusal(accountKey, model, cachePath = getClaudeUsageCachePath()) {
2198
+ model = modelFamily(model);
2199
+ try {
2200
+ if (!fs.existsSync(cachePath))
2201
+ return;
2202
+ withFileLock(cachePath, () => {
2203
+ const cache = readClaudeUsageCacheFile(cachePath);
2204
+ const existing = cache[accountKey];
2205
+ if (!existing?.modelRefusals?.[model])
2206
+ return;
2207
+ const modelRefusals = { ...existing.modelRefusals };
2208
+ delete modelRefusals[model];
2209
+ const rest = { ...existing, modelRefusals };
2210
+ if (Object.keys(modelRefusals).length === 0)
2211
+ delete rest.modelRefusals;
2212
+ cache[accountKey] = rest;
2213
+ atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
2214
+ });
2215
+ }
2216
+ catch {
2217
+ /* best-effort cache write */
2218
+ }
2219
+ }
2220
+ /**
2221
+ * Read a live (non-expired) model-refusal marker for (accountKey, model), or
2222
+ * null when none is recorded or the recorded one has passed its clock. A
2223
+ * marker with no `resetsAt` never expires here — it is sticky until
2224
+ * {@link clearClaudeModelRefusal} observes a real success.
2225
+ */
2226
+ export function getClaudeModelRefusal(accountKey, model, nowMs = Date.now(), cachePath = getClaudeUsageCachePath()) {
2227
+ const cache = readClaudeUsageCacheFile(cachePath);
2228
+ const entry = cache[accountKey]?.modelRefusals?.[modelFamily(model)];
2229
+ if (!entry)
2230
+ return null;
2231
+ if (entry.resetsAt) {
2232
+ const reset = parseDateValue(entry.resetsAt);
2233
+ if (reset && reset.getTime() <= nowMs)
2234
+ return null;
2235
+ return { family: entry.family, resetsAt: reset };
2236
+ }
2237
+ return { family: entry.family, resetsAt: null };
2238
+ }
2239
+ /**
2240
+ * Parse Claude's model-specific refusal — the CLI's own phrasing when ONE
2241
+ * model's quota is exhausted while the account otherwise keeps serving:
2242
+ * "You've reached your Fable limit. Run /usage-credits to continue or switch
2243
+ * models with /model." Deliberately narrow (unlike the broad RATE_LIMIT_PATTERNS
2244
+ * scan) so a session that merely discusses `/usage-credits` cannot false-positive.
2245
+ * Tolerates both a straight and curly apostrophe.
2246
+ */
2247
+ export function parseClaudeModelRefusal(text) {
2248
+ const candidates = [text];
2249
+ for (const line of text.split('\n')) {
2250
+ try {
2251
+ const row = JSON.parse(line);
2252
+ if (row.type === 'result' && row.is_error && typeof row.result === 'string')
2253
+ candidates.push(row.result);
2254
+ if (row.type === 'assistant' && row.isApiErrorMessage && Array.isArray(row.message?.content)) {
2255
+ candidates.push(row.message.content.filter((block) => block.type === 'text').map((block) => block.text ?? '').join('\n'));
2256
+ }
2257
+ }
2258
+ catch { /* Plain terminal output is checked directly. */ }
2259
+ }
2260
+ for (const candidate of candidates) {
2261
+ const match = /(?:^|\n)\s*You['’]ve reached your ([^\n.]+) limit\.\s*Run \/usage-credits to continue or switch models with \/model\./i.exec(candidate);
2262
+ if (match)
2263
+ return { family: match[1].trim() };
2264
+ }
2265
+ return null;
2266
+ }
2118
2267
  /** Parse Claude's `hit your session limit · resets …` refusal. */
2119
2268
  export function parseClaudeSessionLimitReset(text, nowMs = Date.now()) {
2120
2269
  if (!/hit your session limit/i.test(text))
@@ -54,4 +54,6 @@ export declare function resolveNativeSpawnHome(agent: AgentId, account: {
54
54
  id: string;
55
55
  name: string;
56
56
  agent: AgentId;
57
- }, meta?: Pick<Meta, 'accounts' | 'deviceAccounts'>): Promise<NativeSpawnHome>;
57
+ }, meta?: Pick<Meta, 'accounts' | 'deviceAccounts'>, options?: {
58
+ readOnly?: boolean;
59
+ }): Promise<NativeSpawnHome>;
@@ -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];
@@ -182,7 +182,7 @@ export interface InteractiveDispatchOptions {
182
182
  agent: string;
183
183
  /** Explicit agent version pin (e.g. "2.1.207") to forward as `agent@version`. */
184
184
  version?: string;
185
- /** Preserve the trailing-@ account picker so selection happens on the execution host. */
185
+ /** Preserve the trailing-# account picker so selection happens on the execution host. */
186
186
  accountPicker?: boolean;
187
187
  /** Explicit run strategy (e.g. "balanced") to forward as `--strategy <strategy>`. */
188
188
  strategy?: string;
@@ -362,7 +362,7 @@ async function launchDetached(host, target, opts) {
362
362
  /** Compose `agent[@version][#account]` so the peer resolves ITS slot (PHNX-3940 T5). */
363
363
  export function runAgentSpecArg(opts) {
364
364
  if (opts.accountPicker)
365
- return `${opts.agent}@`;
365
+ return `${opts.agent}#`;
366
366
  let spec = opts.agent;
367
367
  if (opts.version)
368
368
  spec += `@${opts.version}`;
@@ -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
  }