@phnx-labs/agents-cli 1.22.18 → 1.22.20

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.
@@ -17,6 +17,7 @@ import { machineForSessionFile } from './origin-machine.js';
17
17
  import { loadSessionActorIndex, readSessionActorRecord } from './actor-sidecar.js';
18
18
  import { toolCallsFromEvents } from './tool-calls.js';
19
19
  import { persistToolCalls, purgeToolCalls, toolEvidenceSourcePath } from './tool-store.js';
20
+ import { buildClaudeAccountIndex, resolveClaudeAccount } from './claude-accounts.js';
20
21
  import { extractSkills, extractSlashCommands } from './highlights.js';
21
22
  import { resolveResource } from '../resources.js';
22
23
  import { discoverPlugins } from '../plugins.js';
@@ -25,7 +26,7 @@ const DB_PATH = getSessionsDbPath();
25
26
  /** Current schema version; bumped when migrations are added. Exported so tests
26
27
  * assert against the constant instead of hardcoding a number that every bump
27
28
  * then has to chase (docs/05-sessions.md calls the constant the source of truth). */
28
- export const SCHEMA_VERSION = 32;
29
+ export const SCHEMA_VERSION = 33;
29
30
  /**
30
31
  * Bump to force `agents sessions backfill resources` to re-derive every
31
32
  * session's skill/slash-command tallies on its next run (resource_scan_ledger
@@ -67,6 +68,8 @@ CREATE TABLE IF NOT EXISTS sessions (
67
68
  routine_run_id TEXT,
68
69
  version TEXT,
69
70
  account TEXT,
71
+ account_key TEXT,
72
+ account_org TEXT,
70
73
  mode TEXT,
71
74
  timestamp TEXT NOT NULL,
72
75
  last_activity TEXT,
@@ -730,6 +733,56 @@ function migrateSchema(db, fromVersion) {
730
733
  if (!cols.has('mode'))
731
734
  db.exec(`ALTER TABLE sessions ADD COLUMN mode TEXT`);
732
735
  }
736
+ if (fromVersion < 33) {
737
+ // v32 → v33: attribute each Claude session to the account that produced it.
738
+ // Until now `account` held ONE email resolved process-globally and stamped on
739
+ // every row of a scan, so a machine with several signed-in accounts reported all
740
+ // of its history under whichever resolved first.
741
+ //
742
+ // Do NOT wipe scan_ledger. Attribution is a pure function of (file_path,
743
+ // version) — both already stored — so existing rows are repaired in place with
744
+ // no transcript re-parsed. Adding `DELETE FROM scan_ledger` here to match the
745
+ // other migrations would force a full re-parse of every indexed transcript to
746
+ // recompute something derivable from two columns. The v31 migration sets the
747
+ // same precedent.
748
+ const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((column) => column.name));
749
+ if (!cols.has('account_key'))
750
+ db.exec(`ALTER TABLE sessions ADD COLUMN account_key TEXT`);
751
+ if (!cols.has('account_org'))
752
+ db.exec(`ALTER TABLE sessions ADD COLUMN account_org TEXT`);
753
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_account_key ON sessions(account_key)`);
754
+ backfillClaudeAccounts(db);
755
+ }
756
+ }
757
+ /**
758
+ * Stamp `account_key` / `account_org` / `account` on every Claude row from its
759
+ * `file_path` and recorded `version`. Used by the v33 migration; idempotent, so it is
760
+ * safe to re-run.
761
+ */
762
+ function backfillClaudeAccounts(db, scope = 'all') {
763
+ // 'unresolved' exists so the getDB repair touches ONLY rows that are actually
764
+ // broken. Re-resolving every Claude row on an unrelated trigger would silently
765
+ // downgrade a correct row whose version home has since been uninstalled and its
766
+ // trash snapshot pruned — the row would go from attributed to dark for no reason
767
+ // the user caused. The migration wants 'all'; the repair does not.
768
+ const where = scope === 'all'
769
+ ? `agent = 'claude'`
770
+ : `agent = 'claude' AND (account_key IS NULL
771
+ OR (account_key LIKE 'unattributed:%' AND account IS NOT NULL))`;
772
+ const index = buildClaudeAccountIndex();
773
+ const rows = db.prepare(`SELECT id, file_path, version FROM sessions WHERE ${where}`).all();
774
+ if (rows.length === 0)
775
+ return;
776
+ // `account` is overwritten, not COALESCEd. Every pre-v33 row carries the wrong
777
+ // globally-resolved email; keeping it on a row we could not attribute would leave a
778
+ // known-false address on display (commands/sessions.ts prints it, and its fuzzy
779
+ // matcher scores on it) and would disagree with the scan path, which writes
780
+ // `account = excluded.account` unconditionally. A dark row reads NULL.
781
+ const update = db.prepare(`UPDATE sessions SET account_key = ?, account_org = ?, account = ? WHERE id = ?`);
782
+ for (const row of rows) {
783
+ const bucket = resolveClaudeAccount(index, row.file_path ?? '', row.version);
784
+ update.run(bucket.key, bucket.orgName, bucket.email, row.id);
785
+ }
733
786
  }
734
787
  /** Open (or return the cached) sessions database, applying migrations as needed. */
735
788
  export function getDB() {
@@ -776,6 +829,31 @@ export function getDB() {
776
829
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_last_activity ON sessions(last_activity DESC)`);
777
830
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_origin ON sessions(origin)`);
778
831
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_routine_run_id ON sessions(routine_run_id)`);
832
+ // Account attribution repair. Two ways a Claude row ends up wrong even at v33:
833
+ // an older CLI (whose INSERT does not name the column) writes NULL, and a DB
834
+ // migrated by a build that predates the "clear the stale email on a dark row" fix
835
+ // keeps a known-wrong address. The v33 migration cannot fix either — it never runs
836
+ // again. Cheap guard first so the common case is one indexed lookup, then repair.
837
+ // Same shape as the `machine` repair below, for the same reason.
838
+ {
839
+ // Column guard FIRST, like the `machine` repair below. schema_version can be
840
+ // stamped at SCHEMA_VERSION without the column existing — getDB writes the marker
841
+ // for any DB whose meta has no row (a hand-built or partially-created index), and
842
+ // migrateSchema never runs in that path. Querying account_key unguarded would
843
+ // then throw "no such column" and take down every command that opens the index.
844
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
845
+ if (cols.some((c) => c.name === 'account_key') && cols.some((c) => c.name === 'account_org')) {
846
+ const needsRepair = db.prepare(`
847
+ SELECT 1 FROM sessions
848
+ WHERE agent = 'claude'
849
+ AND (account_key IS NULL
850
+ OR (account_key LIKE 'unattributed:%' AND account IS NOT NULL))
851
+ LIMIT 1
852
+ `).get();
853
+ if (needsRepair)
854
+ db.transaction(() => backfillClaudeAccounts(db, 'unresolved'))();
855
+ }
856
+ }
779
857
  // machine column + indexes: only after the column is guaranteed present.
780
858
  // Fresh SCHEMA (v17) includes the column; older DBs get it from migrate v17.
781
859
  // If a partial upgrade left schema_version ahead of the column, repair here.
@@ -1124,7 +1202,7 @@ export function recordDirScans(entries) {
1124
1202
  const upsertSessionStmt = (db) => db.prepare(`
1125
1203
  INSERT INTO sessions (
1126
1204
  id, short_id, agent, origin, routine_name, routine_run_id,
1127
- version, account, mode, timestamp, last_activity,
1205
+ version, account, account_key, account_org, mode, timestamp, last_activity,
1128
1206
  project, cwd, git_branch, topic, label, message_count, token_count,
1129
1207
  output_tokens, cost_usd, duration_ms, model, tool_call_count,
1130
1208
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
@@ -1133,7 +1211,7 @@ const upsertSessionStmt = (db) => db.prepare(`
1133
1211
  actor, initiated_by, used_browser, used_computer
1134
1212
  ) VALUES (
1135
1213
  @id, @short_id, @agent, @origin, @routine_name, @routine_run_id,
1136
- @version, @account, @mode, @timestamp, @last_activity,
1214
+ @version, @account, @account_key, @account_org, @mode, @timestamp, @last_activity,
1137
1215
  @project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
1138
1216
  @output_tokens, @cost_usd, @duration_ms, @model, @tool_call_count,
1139
1217
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
@@ -1149,6 +1227,8 @@ const upsertSessionStmt = (db) => db.prepare(`
1149
1227
  routine_run_id = excluded.routine_run_id,
1150
1228
  version = excluded.version,
1151
1229
  account = excluded.account,
1230
+ account_key = excluded.account_key,
1231
+ account_org = excluded.account_org,
1152
1232
  mode = COALESCE(excluded.mode, sessions.mode),
1153
1233
  timestamp = excluded.timestamp,
1154
1234
  last_activity = excluded.last_activity,
@@ -1290,20 +1370,22 @@ function writeResourceUsageFromTallies(sessionId, skills, commands, cwd) {
1290
1370
  const plugins = discoverPlugins({ cwd });
1291
1371
  for (const { name, count } of skills) {
1292
1372
  const prov = resolveResourceProvenance('skills', name, cwd, plugins);
1293
- ins.run({
1373
+ const bind = {
1294
1374
  session_id: sessionId, kind: 'skill', name, count,
1295
1375
  plugin: prov.plugin ?? null, source: prov.source ?? null,
1296
1376
  repo_root: prov.repoRoot ?? null, snapshot_sha: prov.snapshotSha ?? null,
1297
- });
1377
+ };
1378
+ ins.run(bind);
1298
1379
  }
1299
1380
  for (const { name, count } of commands) {
1300
1381
  const bare = name.replace(/^\//, '');
1301
1382
  const prov = resolveResourceProvenance('commands', bare, cwd, plugins);
1302
- ins.run({
1383
+ const bind = {
1303
1384
  session_id: sessionId, kind: 'command', name: bare, count,
1304
1385
  plugin: prov.plugin ?? null, source: prov.source ?? null,
1305
1386
  repo_root: prov.repoRoot ?? null, snapshot_sha: prov.snapshotSha ?? null,
1306
- });
1387
+ };
1388
+ ins.run(bind);
1307
1389
  }
1308
1390
  }
1309
1391
  /**
@@ -1392,6 +1474,8 @@ export function upsertSession(meta, content, scan) {
1392
1474
  routine_run_id: meta.routineRunId ?? null,
1393
1475
  version: meta.version ?? null,
1394
1476
  account: meta.account ?? null,
1477
+ account_key: meta.accountKey ?? null,
1478
+ account_org: meta.accountOrg ?? null,
1395
1479
  mode: meta.mode ?? actorRec?.mode ?? null,
1396
1480
  timestamp: meta.timestamp,
1397
1481
  last_activity: resolveLastActivity(meta, scan),
@@ -1547,7 +1631,12 @@ export function upsertSessionsBatch(entries) {
1547
1631
  writeResourceUsageFromTallies(meta.id, meta.skillsUsed ?? [], meta.slashCommandsUsed ?? [], meta.cwd);
1548
1632
  }
1549
1633
  try {
1550
- upsert.run({
1634
+ // Typed, not a bare literal: bun binds named parameters in strict mode, where a
1635
+ // MISSING key throws (node binds NULL instead). A silently dropped key therefore
1636
+ // breaks only the shipped binary's runtime, and the per-row catch below swallows
1637
+ // it — the exact shape of the bug that shipped account_key unbound. Annotating
1638
+ // against SessionRow makes tsc reject the next omission.
1639
+ const row = {
1551
1640
  id: meta.id,
1552
1641
  short_id: meta.shortId,
1553
1642
  agent: meta.agent,
@@ -1556,6 +1645,8 @@ export function upsertSessionsBatch(entries) {
1556
1645
  routine_run_id: meta.routineRunId ?? null,
1557
1646
  version: meta.version ?? null,
1558
1647
  account: meta.account ?? null,
1648
+ account_key: meta.accountKey ?? null,
1649
+ account_org: meta.accountOrg ?? null,
1559
1650
  mode: meta.mode ?? actorIndex.get(meta.id)?.mode ?? null,
1560
1651
  timestamp: meta.timestamp,
1561
1652
  last_activity: resolveLastActivity(meta, scan),
@@ -1591,7 +1682,8 @@ export function upsertSessionsBatch(entries) {
1591
1682
  initiated_by: meta.initiatedBy ?? actorIndex.get(meta.id)?.initiatedBy ?? null,
1592
1683
  used_browser: toolUsage.usedBrowser ? 1 : 0,
1593
1684
  used_computer: toolUsage.usedComputer ? 1 : 0,
1594
- });
1685
+ };
1686
+ upsert.run(row);
1595
1687
  delText.run(meta.id);
1596
1688
  insText.run(meta.id,
1597
1689
  // Mirror upsertSession: index the label the upsert actually stored
@@ -1780,6 +1872,8 @@ function rowToMeta(row) {
1780
1872
  toolCallCount: row.tool_call_count ?? undefined,
1781
1873
  version: row.version ?? undefined,
1782
1874
  account: row.account ?? undefined,
1875
+ accountKey: row.account_key ?? undefined,
1876
+ accountOrg: row.account_org ?? undefined,
1783
1877
  mode: isSessionRunMode(row.mode) ? row.mode : undefined,
1784
1878
  topic: row.topic ?? undefined,
1785
1879
  label: row.label ?? undefined,
@@ -2075,12 +2169,23 @@ export function queryUsageRollup(options) {
2075
2169
  ? 'agent'
2076
2170
  : options.groupBy === 'project'
2077
2171
  ? `IFNULL(NULLIF(project, ''), '(no project)')`
2078
- // ISO timestamps are lexicographically date-sortable; the date is the
2079
- // first 10 chars (YYYY-MM-DD).
2080
- : `substr(timestamp, 1, 10)`;
2172
+ : options.groupBy === 'account'
2173
+ // A NULL account_key means this harness has no account attribution yet —
2174
+ // the mechanism is Claude-only today (see lib/session/claude-accounts.ts).
2175
+ // Bucket per agent so the rows are named honestly instead of being called
2176
+ // "not indexed", which they are not, and instead of joining a real account.
2177
+ ? `IFNULL(NULLIF(account_key, ''), 'unattributed:' || agent)`
2178
+ // ISO timestamps are lexicographically date-sortable; the date is the
2179
+ // first 10 chars (YYYY-MM-DD).
2180
+ : `substr(timestamp, 1, 10)`;
2081
2181
  const sql = `
2082
2182
  SELECT
2083
2183
  ${keyExpr} AS key,
2184
+ ${options.groupBy === 'account'
2185
+ // One label per account_key by construction, so MAX just picks it out.
2186
+ ? `MAX(CASE WHEN account_org IS NOT NULL AND account IS NOT NULL
2187
+ THEN account_org || ' <' || account || '>' END) AS label,`
2188
+ : ''}
2084
2189
  IFNULL(SUM(cost_usd), 0) AS costUsd,
2085
2190
  IFNULL(SUM(duration_ms), 0) AS durationMs,
2086
2191
  COUNT(*) AS sessionCount,
@@ -23,6 +23,7 @@ import { walkForFilesWithStat } from '../fs-walk.js';
23
23
  import { getConfigSymlinkVersion } from '../shims.js';
24
24
  import { SESSION_AGENTS } from './types.js';
25
25
  import { deriveShortId } from './short-id.js';
26
+ import { buildClaudeAccountIndex, resolveClaudeAccount } from './claude-accounts.js';
26
27
  import { extractSessionTopic, extractSlashCommandName, extractSlashCommandFromToolInput } from './prompt.js';
27
28
  import { isSkillInvocation, extractSkills, extractSlashCommands } from './highlights.js';
28
29
  import { parseAntigravity, parseCursor } from './parse.js';
@@ -891,45 +892,20 @@ async function scanRoutineArchivesIncremental(agent, onProgress) {
891
892
  // ---------------------------------------------------------------------------
892
893
  // Claude account info
893
894
  // ---------------------------------------------------------------------------
894
- let cachedClaudeAccount;
895
- /** Read the Claude OAuth account email from .claude.json across all version homes. */
896
- function getClaudeAccount() {
897
- if (cachedClaudeAccount !== undefined)
898
- return cachedClaudeAccount || undefined;
899
- // Claude's active config lives at $CLAUDE_CONFIG_DIR/.claude.json; for our shim
900
- // that's <version>/home/.claude/.claude.json. The home-level .claude.json is a
901
- // legacy path used when Claude runs without CLAUDE_CONFIG_DIR set.
902
- const candidates = [
903
- path.join(HOME, '.claude', '.claude.json'),
904
- path.join(HOME, '.claude.json'),
905
- ];
906
- for (const root of VERSIONS_ROOTS) {
907
- const versionsBase = path.join(root, 'versions', 'claude');
908
- if (!fs.existsSync(versionsBase))
909
- continue;
910
- try {
911
- for (const version of fs.readdirSync(versionsBase)) {
912
- candidates.push(path.join(versionsBase, version, 'home', '.claude', '.claude.json'));
913
- candidates.push(path.join(versionsBase, version, 'home', '.claude.json'));
914
- }
915
- }
916
- catch { /* versions dir unreadable */ }
917
- }
918
- for (const candidate of candidates) {
919
- try {
920
- if (!fs.existsSync(candidate))
921
- continue;
922
- const data = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
923
- const name = data.oauthAccount?.emailAddress || data.oauthAccount?.displayName;
924
- if (name) {
925
- cachedClaudeAccount = name;
926
- return name;
927
- }
928
- }
929
- catch { /* auth file unreadable or malformed */ }
930
- }
931
- cachedClaudeAccount = '';
932
- return undefined;
895
+ let cachedClaudeAccountIndex;
896
+ /**
897
+ * The account-attribution index, built at most once per scan pass.
898
+ *
899
+ * Rebuilt when `refresh` is set — `scanClaudeIncremental` does that at the start of
900
+ * each pass so a long-lived process (the daemon) picks up an `agents use` switch or a
901
+ * fresh login instead of attributing later sessions to a stale set of homes. Per-file
902
+ * resolution then reads the cached index, because rebuilding it per transcript would
903
+ * re-read every home's `.claude.json` thousands of times.
904
+ */
905
+ function claudeAccountIndex(refresh = false) {
906
+ if (refresh || !cachedClaudeAccountIndex)
907
+ cachedClaudeAccountIndex = buildClaudeAccountIndex();
908
+ return cachedClaudeAccountIndex;
933
909
  }
934
910
  // ---------------------------------------------------------------------------
935
911
  // Claude
@@ -974,7 +950,8 @@ export function buildClaudeLabelMap() {
974
950
  }
975
951
  /** Incrementally re-scan changed Claude session files and upsert into the DB. */
976
952
  async function scanClaudeIncremental(onProgress) {
977
- const account = getClaudeAccount();
953
+ // Rebuild once per pass; readClaudeMeta resolves each transcript against it.
954
+ claudeAccountIndex(true);
978
955
  const labelMap = buildClaudeLabelMap();
979
956
  // Enumerate every leaf project dir across all Claude roots. The FIRST root
980
957
  // returned by getAgentSessionDirs is the agent's live `~/.claude/projects` —
@@ -1042,7 +1019,7 @@ async function scanClaudeIncremental(onProgress) {
1042
1019
  const sessionId = path.basename(filePath).replace('.jsonl', '');
1043
1020
  const label = labelMap.get(sessionId) ?? undefined;
1044
1021
  const priorRow = priorStates.get(filePath);
1045
- const result = await readClaudeMeta(filePath, sessionId, scan, priorRow, account, label);
1022
+ const result = await readClaudeMeta(filePath, sessionId, scan, priorRow, label);
1046
1023
  if (result) {
1047
1024
  entries.push({
1048
1025
  meta: result.meta,
@@ -1079,7 +1056,7 @@ async function scanClaudeIncremental(onProgress) {
1079
1056
  * the serialized continuation (parser_state + content_text) to persist for the
1080
1057
  * next scan.
1081
1058
  */
1082
- async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account, label) {
1059
+ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, label) {
1083
1060
  const prior = parsePriorClaudeState(priorRow);
1084
1061
  const { scan, newState, toolCalls, mode } = await scanClaudeSessionResumable(filePath, prior, scanStamp.fileMtimeMs, scanStamp.fileSize, priorRow?.fileMtimeMs);
1085
1062
  if (mode === 'incremental')
@@ -1087,6 +1064,10 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account,
1087
1064
  else
1088
1065
  claudeFullScanCount++;
1089
1066
  const isTeamOrigin = scan.entrypoint === 'sdk-cli';
1067
+ // Which account produced this transcript. Resolved from the path plus the version
1068
+ // recorded inside the file, so rows under the mutable ~/.claude symlink are
1069
+ // attributed to the version that actually wrote them. See claude-accounts.ts.
1070
+ const acct = resolveClaudeAccount(claudeAccountIndex(), filePath, scan.version);
1090
1071
  let meta;
1091
1072
  if (scan.timestamp) {
1092
1073
  const cwd = normalizeCwd(scan.cwd || '');
@@ -1102,7 +1083,9 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account,
1102
1083
  gitBranch: scan.gitBranch,
1103
1084
  version: scan.version,
1104
1085
  model: scan.model,
1105
- account,
1086
+ account: acct.email ?? undefined,
1087
+ accountKey: acct.key,
1088
+ accountOrg: acct.orgName ?? undefined,
1106
1089
  topic: scan.topic,
1107
1090
  label,
1108
1091
  messageCount: scan.messageCount,
@@ -1134,7 +1117,9 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account,
1134
1117
  timestamp: stat ? stat.mtime.toISOString() : new Date().toISOString(),
1135
1118
  lastActivity: scan.lastActivity,
1136
1119
  filePath,
1137
- account,
1120
+ account: acct.email ?? undefined,
1121
+ accountKey: acct.key,
1122
+ accountOrg: acct.orgName ?? undefined,
1138
1123
  model: scan.model,
1139
1124
  label,
1140
1125
  messageCount: scan.messageCount,
@@ -152,7 +152,20 @@ export interface SessionMeta {
152
152
  model?: string;
153
153
  toolCallCount?: number;
154
154
  version?: string;
155
+ /**
156
+ * Email of the account that produced the session. Display-only: two orgs can share
157
+ * one email, so never group on this — group on `accountKey`.
158
+ */
155
159
  account?: string;
160
+ /**
161
+ * Org-scoped identity of the producing account (`claude:org=<uuid>`), or
162
+ * `unattributed:<reason>` when it cannot be established. The correct grouping key:
163
+ * a Team seat and a personal Max plan under one email are separate quota buckets.
164
+ * See lib/session/claude-accounts.ts for how a transcript is attributed.
165
+ */
166
+ accountKey?: string;
167
+ /** Organization display name of the producing account, when known. */
168
+ accountOrg?: string;
156
169
  /** Effective normalized launch mode captured by the SessionStart hook. */
157
170
  mode?: SessionRunMode;
158
171
  topic?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.18",
3
+ "version": "1.22.20",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",