@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.
- package/CHANGELOG.md +18 -0
- package/README.md +19 -1
- package/dist/browser.js +0 -0
- package/dist/commands/exec.js +135 -233
- package/dist/commands/resume.d.ts +6 -21
- package/dist/commands/resume.js +18 -55
- package/dist/commands/run-device-picker.d.ts +58 -0
- package/dist/commands/run-device-picker.js +222 -0
- package/dist/commands/sessions-resume.d.ts +4 -0
- package/dist/commands/sessions-resume.js +132 -49
- package/dist/commands/sessions.js +32 -5
- package/dist/commands/setup-computer.js +2 -2
- package/dist/index.js +0 -0
- package/dist/lib/accounting/account-launch.d.ts +54 -0
- package/dist/lib/accounting/account-launch.js +117 -0
- package/dist/lib/accounting/account-pool-collect.js +2 -1
- package/dist/lib/accounting/account-pool.d.ts +2 -0
- package/dist/lib/accounting/account-pool.js +1 -0
- package/dist/lib/accounting/rotate.d.ts +32 -6
- package/dist/lib/accounting/rotate.js +70 -42
- package/dist/lib/accounting/usage.d.ts +71 -0
- package/dist/lib/accounting/usage.js +160 -11
- package/dist/lib/exec-account-home.d.ts +3 -1
- package/dist/lib/exec-account-home.js +2 -2
- package/dist/lib/exec.d.ts +27 -1
- package/dist/lib/exec.js +150 -27
- package/dist/lib/models.d.ts +1 -1
- package/dist/lib/models.js +4 -4
- package/dist/lib/session/actor-sidecar.d.ts +3 -11
- package/dist/lib/session/actor-sidecar.js +3 -0
- package/dist/lib/session/claude-accounts.d.ts +12 -73
- package/dist/lib/session/claude-accounts.js +32 -70
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +18 -5
- package/dist/lib/session/discover.d.ts +4 -0
- package/dist/lib/session/discover.js +116 -15
- package/dist/lib/session/recovery.d.ts +30 -34
- package/dist/lib/session/recovery.js +212 -76
- package/dist/lib/session/types.d.ts +2 -0
- package/dist/lib/teams/placement-probe.js +1 -1
- package/dist/session-tracker/dist/adapters/claude.d.ts +10 -0
- package/dist/session-tracker/dist/adapters/claude.js +45 -0
- package/dist/session-tracker/dist/hook.sh +191 -0
- package/dist/session-tracker/dist/index.d.ts +19 -0
- package/dist/session-tracker/dist/index.js +67 -0
- package/dist/session-tracker/dist/install-hook.d.ts +19 -0
- package/dist/session-tracker/dist/install-hook.js +245 -0
- package/dist/session-tracker/dist/prune-state.d.ts +2 -0
- package/dist/session-tracker/dist/prune-state.js +7 -0
- package/dist/session-tracker/dist/reader.d.ts +7 -0
- package/dist/session-tracker/dist/reader.js +151 -0
- package/dist/session-tracker/dist/state-file.d.ts +10 -0
- package/dist/session-tracker/dist/state-file.js +119 -0
- package/dist/session-tracker/dist/types.d.ts +32 -0
- package/dist/session-tracker/dist/types.js +1 -0
- package/dist/session-tracker/dist/writer.d.ts +12 -0
- package/dist/session-tracker/dist/writer.js +27 -0
- 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
|
}
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
1437
|
-
|
|
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
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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];
|
package/dist/lib/models.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/models.js
CHANGED
|
@@ -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
|
-
|
|
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 {};
|