@phnx-labs/agents-cli 1.22.20 → 1.22.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Behavioural facets of a coding session, and the cross-session rollup built from them.
3
+ *
4
+ * This is the engine behind `agents insights`. It answers "how do you work" rather than
5
+ * "what did you spend" (`agents cost`) or "what shipped" (`agents output`), and it is
6
+ * the only surface that splits any of it by the account that produced the work.
7
+ *
8
+ * Everything here is a pure function of a parsed `SessionEvent[]`, so it is testable
9
+ * without a database and cheap to re-run. The expensive part is parsing the transcript,
10
+ * which is why results are cached per session in `session_insights` and recomputed only
11
+ * when the file's (mtime, size) changes — the same staleness contract as `scan_ledger`.
12
+ *
13
+ * Prior art: Claude Code's own `/insights`, which computes a comparable set from
14
+ * `~/.claude/projects` alone. Two deliberate differences:
15
+ *
16
+ * - It sees ONE account's directory. This reads every indexed session, across every
17
+ * Claude account and every other harness, and reports them apart.
18
+ * - It collapses conversation branches before counting. agents-cli is file-per-session
19
+ * throughout (`discover.ts` keys on the transcript's basename), so session counts
20
+ * here will read slightly higher than `/insights` on the same machine. Reported as
21
+ * the raw file count rather than quietly differing.
22
+ */
23
+ import { computeSummaryStats, shortenModel } from './render.js';
24
+ import { classifyFileChanges, EDIT_TOOLS, WRITE_TOOLS } from './digest.js';
25
+ /** File extension → language label. Mirrors the set `/insights` attributes by. */
26
+ const LANGUAGE_BY_EXT = {
27
+ '.ts': 'TypeScript', '.tsx': 'TypeScript', '.mts': 'TypeScript', '.cts': 'TypeScript',
28
+ '.js': 'JavaScript', '.jsx': 'JavaScript', '.mjs': 'JavaScript', '.cjs': 'JavaScript',
29
+ '.py': 'Python', '.go': 'Go', '.rs': 'Rust', '.rb': 'Ruby', '.java': 'Java',
30
+ '.kt': 'Kotlin', '.swift': 'Swift', '.c': 'C', '.h': 'C', '.cc': 'C++', '.cpp': 'C++',
31
+ '.hpp': 'C++', '.cs': 'C#', '.php': 'PHP', '.sh': 'Shell', '.bash': 'Shell',
32
+ '.zsh': 'Shell', '.fish': 'Shell', '.sql': 'SQL', '.css': 'CSS', '.scss': 'CSS',
33
+ '.html': 'HTML', '.vue': 'Vue', '.svelte': 'Svelte', '.md': 'Markdown',
34
+ '.json': 'JSON', '.yaml': 'YAML', '.yml': 'YAML', '.toml': 'TOML',
35
+ };
36
+ /**
37
+ * Tool-failure categories, matched in order against the result text (lowercased).
38
+ * Substring rules, not judgement calls — the same shape `/insights` uses, so the
39
+ * buckets stay comparable. First match wins; anything unmatched is "Other".
40
+ */
41
+ const ERROR_CATEGORIES = [
42
+ [['string to replace not found', 'no changes to make'], 'Edit Failed'],
43
+ [['has been modified since', 'modified since read'], 'File Changed'],
44
+ [['exceeds maximum', 'too large', 'too long'], 'File Too Large'],
45
+ [['file not found', 'does not exist', 'no such file'], 'File Not Found'],
46
+ [['rejected', "doesn't want to proceed", 'user doesn’t want'], 'User Rejected'],
47
+ [['exit code', 'command failed', 'error:'], 'Command Failed'],
48
+ ];
49
+ /**
50
+ * Gaps longer than this are someone leaving and coming back, not a reply latency.
51
+ * Counted separately rather than silently dropped.
52
+ */
53
+ const GAP_CEILING_SECONDS = 3600;
54
+ /** Response-gap buckets, in ascending order. Upper bound is exclusive. */
55
+ const GAP_BUCKETS = [
56
+ ['<10s', 10], ['10-30s', 30], ['30s-1m', 60], ['1-2m', 120],
57
+ ['2-5m', 300], ['5-15m', 900], ['15-60m', Infinity],
58
+ ];
59
+ function emptyFacets() {
60
+ return {
61
+ toolCounts: {}, models: {}, languages: {}, slashCommands: {}, errorCategories: {},
62
+ interruptions: 0, responseGaps: [], gapsOverCeiling: 0,
63
+ linesTouchedBefore: 0, linesTouchedAfter: 0, editingToolCalls: 0,
64
+ filesCreated: 0, filesModified: 0, filesDeleted: 0, gitCommits: 0, gitPushes: 0,
65
+ shellCommandsSeen: 0,
66
+ messageHours: new Array(24).fill(0), userTurns: 0, assistantTurns: 0,
67
+ toolCount: 0, errorCount: 0,
68
+ };
69
+ }
70
+ function bump(map, key, by = 1) {
71
+ map[key] = (map[key] ?? 0) + by;
72
+ }
73
+ /**
74
+ * Lines in a string, 0 for empty. A trailing newline terminates the last line rather
75
+ * than starting a new one, so `"a\nb\n"` is 2 — `split('\n').length` would say 3 and
76
+ * over-count every newline-terminated Write by one.
77
+ */
78
+ function lineCount(text) {
79
+ if (typeof text !== 'string' || text === '')
80
+ return 0;
81
+ const trimmed = text.endsWith('\n') ? text.slice(0, -1) : text;
82
+ return trimmed.split('\n').length;
83
+ }
84
+ function categorizeError(text) {
85
+ const lower = text.toLowerCase();
86
+ for (const [needles, label] of ERROR_CATEGORIES) {
87
+ if (needles.some((n) => lower.includes(n)))
88
+ return label;
89
+ }
90
+ return 'Other';
91
+ }
92
+ /**
93
+ * Count invocations of a git subcommand in a shell command line.
94
+ *
95
+ * Matches `git commit`, `git -C /repo commit`, and the same again after a `&&`, `||`
96
+ * or `;`, without trying to parse shell. The subcommand must be its own whitespace-
97
+ * separated token, so `git-commit` (a different binary) does not count.
98
+ *
99
+ * Known limitation, shared with the `/insights` implementation this mirrors: a git
100
+ * command quoted inside another command (`echo "run git commit later"`) still counts.
101
+ * Distinguishing that needs a real shell parse, which is not worth it for a rollup.
102
+ */
103
+ function countGitOp(command, op) {
104
+ const re = new RegExp(`(?:^|[\\s&|;(])git(?:\\s+[^\\s&|;]+)*?\\s+${op}\\b`, 'g');
105
+ return (command.match(re) ?? []).length;
106
+ }
107
+ /**
108
+ * Compute every behavioural facet of one session from its parsed events.
109
+ *
110
+ * Pure: no I/O, no clock, no filesystem. `timezoneOffsetMinutes` is injected rather
111
+ * than read from the environment so the hour histogram is deterministic in tests and
112
+ * can be re-bucketed for a different display timezone without re-parsing.
113
+ */
114
+ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().getTimezoneOffset()) {
115
+ const f = emptyFacets();
116
+ const stats = computeSummaryStats(events);
117
+ f.toolCounts = stats.toolCounts;
118
+ f.userTurns = stats.userTurns;
119
+ f.assistantTurns = stats.assistantTurns;
120
+ f.toolCount = stats.toolCount;
121
+ f.errorCount = stats.errorCount;
122
+ const changes = classifyFileChanges(events);
123
+ for (const c of changes) {
124
+ if (c.op === 'created')
125
+ f.filesCreated++;
126
+ else if (c.op === 'modified')
127
+ f.filesModified++;
128
+ else
129
+ f.filesDeleted++;
130
+ const dot = c.path.lastIndexOf('.');
131
+ if (dot > 0) {
132
+ const lang = LANGUAGE_BY_EXT[c.path.slice(dot).toLowerCase()];
133
+ if (lang)
134
+ bump(f.languages, lang);
135
+ }
136
+ }
137
+ // Response gap: assistant goes quiet, user speaks again.
138
+ //
139
+ // No lower bound. /insights drops gaps under 2s, which sounds harmless and is not:
140
+ // measured over 782 real transcripts it censors 28.4% of the sample and inflates the
141
+ // reported p50 by 63% (143s against a true 88s), because fast replies are common and
142
+ // dropping them all shifts the median right. A 0-second reply is a real reply.
143
+ //
144
+ // The upper bound stays: past an hour the user went away and came back, which is not
145
+ // a reply latency. It censors 5.5% of gaps, and `gapsOverCeiling` reports how many so
146
+ // the number is never quietly truncated.
147
+ let lastAssistantTs = null;
148
+ for (const e of events) {
149
+ const ts = new Date(e.timestamp).getTime();
150
+ const hasTs = !Number.isNaN(ts);
151
+ switch (e.type) {
152
+ case 'interrupt':
153
+ f.interruptions++;
154
+ break;
155
+ case 'usage':
156
+ // shortenModel so the label matches `agents sessions <id>` and `trends`
157
+ // rather than printing the raw id beside their shortened one.
158
+ if (e.model)
159
+ bump(f.models, shortenModel(e.model));
160
+ break;
161
+ case 'error':
162
+ bump(f.errorCategories, categorizeError(e.content ?? e.output ?? ''));
163
+ break;
164
+ case 'message':
165
+ if (e.role === 'assistant') {
166
+ if (hasTs)
167
+ lastAssistantTs = ts;
168
+ break;
169
+ }
170
+ if (e.role !== 'user')
171
+ break;
172
+ if (hasTs) {
173
+ // Local-time hour. parse.ts falls back to `new Date()` for a record with no
174
+ // timestamp; those are indistinguishable here, but they are rare and would
175
+ // only smear the histogram toward the scan time, never invent a session.
176
+ const local = new Date(ts - timezoneOffsetMinutes * 60_000);
177
+ f.messageHours[local.getUTCHours()]++;
178
+ if (lastAssistantTs !== null) {
179
+ const gap = (ts - lastAssistantTs) / 1000;
180
+ // >= 0 because clock skew between records can produce a negative gap
181
+ // (one of -8.662s in a real corpus). Removing the old 2s floor removed
182
+ // this guard with it; a negative reply latency is not a data point.
183
+ if (gap >= 0 && gap < GAP_CEILING_SECONDS)
184
+ f.responseGaps.push(gap);
185
+ else if (gap >= GAP_CEILING_SECONDS)
186
+ f.gapsOverCeiling++;
187
+ }
188
+ }
189
+ lastAssistantTs = null;
190
+ if (e.slashCommand)
191
+ bump(f.slashCommands, e.slashCommand);
192
+ break;
193
+ case 'tool_use': {
194
+ if (e._local)
195
+ break;
196
+ if (hasTs)
197
+ lastAssistantTs = ts;
198
+ const args = e.args ?? {};
199
+ const toolName = e.tool ?? '';
200
+ // Keyed on the SHARED cross-harness vocabulary, not Claude's literals. Keying
201
+ // on 'Edit'|'MultiEdit'|'Write' meant codex (whose vocabulary is exec /
202
+ // exec_command / write_stdin) reported 5,197 tool calls and exactly zero lines
203
+ // touched, rendered under the same column heading as a real number.
204
+ if (EDIT_TOOLS.has(toolName)) {
205
+ f.linesTouchedBefore += lineCount(args.old_string);
206
+ f.linesTouchedAfter += lineCount(args.new_string);
207
+ for (const edit of Array.isArray(args.edits) ? args.edits : []) {
208
+ f.linesTouchedBefore += lineCount(edit?.old_string);
209
+ f.linesTouchedAfter += lineCount(edit?.new_string);
210
+ }
211
+ f.editingToolCalls++;
212
+ }
213
+ else if (WRITE_TOOLS.has(toolName)) {
214
+ f.linesTouchedAfter += lineCount(args.content);
215
+ f.editingToolCalls++;
216
+ }
217
+ if (e.command) {
218
+ f.shellCommandsSeen++;
219
+ f.gitCommits += countGitOp(e.command, 'commit');
220
+ f.gitPushes += countGitOp(e.command, 'push');
221
+ }
222
+ break;
223
+ }
224
+ default:
225
+ if (hasTs && e.role === 'assistant')
226
+ lastAssistantTs = ts;
227
+ }
228
+ }
229
+ return f;
230
+ }
231
+ /** Percentile of a numeric sample, nearest-rank. Returns 0 for an empty sample. */
232
+ export function percentile(values, p) {
233
+ if (values.length === 0)
234
+ return 0;
235
+ const sorted = [...values].sort((a, b) => a - b);
236
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
237
+ return sorted[idx];
238
+ }
239
+ /** Bucket response gaps for display. Returns every bucket, including empty ones. */
240
+ export function bucketGaps(gaps) {
241
+ const out = GAP_BUCKETS.map(([bucket]) => ({ bucket, count: 0 }));
242
+ for (const g of gaps) {
243
+ for (let i = 0; i < GAP_BUCKETS.length; i++) {
244
+ if (g < GAP_BUCKETS[i][1]) {
245
+ out[i].count++;
246
+ break;
247
+ }
248
+ }
249
+ }
250
+ return out;
251
+ }
252
+ /**
253
+ * Detect concurrent sessions by interval intersection.
254
+ *
255
+ * This is the metric that makes the account split legible rather than academic: a
256
+ * cross-account overlap is `balanced` rotation actively running two orgs' quota at the
257
+ * same moment. `/insights` has a comparable "multi-clauding" count, but with one
258
+ * account it can only ever report the same-account case.
259
+ *
260
+ * Sweep in start order, keeping only spans that could still intersect, so this is
261
+ * O(n log n + pairs) rather than O(n^2) over ~3k sessions.
262
+ */
263
+ export function detectOverlap(spans) {
264
+ const usable = spans
265
+ .filter((s) => Number.isFinite(s.startMs) && Number.isFinite(s.endMs) && s.endMs > s.startMs)
266
+ .sort((a, b) => a.startMs - b.startMs);
267
+ let overlappingPairs = 0;
268
+ let crossAccountPairs = 0;
269
+ const involved = new Set();
270
+ const active = [];
271
+ for (const span of usable) {
272
+ // Drop spans that ended before this one started; they cannot intersect it or
273
+ // anything after it.
274
+ for (let i = active.length - 1; i >= 0; i--) {
275
+ if (active[i].endMs <= span.startMs)
276
+ active.splice(i, 1);
277
+ }
278
+ for (const other of active) {
279
+ overlappingPairs++;
280
+ if (other.accountKey !== span.accountKey)
281
+ crossAccountPairs++;
282
+ involved.add(other.id);
283
+ involved.add(span.id);
284
+ }
285
+ active.push(span);
286
+ }
287
+ return { overlappingPairs, crossAccountPairs, sessionsInvolved: involved.size };
288
+ }
289
+ /** Merge a session's facets into a running total. */
290
+ export function mergeFacets(into, add) {
291
+ for (const [k, v] of Object.entries(add.toolCounts))
292
+ bump(into.toolCounts, k, v);
293
+ for (const [k, v] of Object.entries(add.models))
294
+ bump(into.models, k, v);
295
+ for (const [k, v] of Object.entries(add.languages))
296
+ bump(into.languages, k, v);
297
+ for (const [k, v] of Object.entries(add.slashCommands))
298
+ bump(into.slashCommands, k, v);
299
+ for (const [k, v] of Object.entries(add.errorCategories))
300
+ bump(into.errorCategories, k, v);
301
+ into.interruptions += add.interruptions;
302
+ into.responseGaps.push(...add.responseGaps);
303
+ into.gapsOverCeiling += add.gapsOverCeiling;
304
+ into.linesTouchedBefore += add.linesTouchedBefore;
305
+ into.linesTouchedAfter += add.linesTouchedAfter;
306
+ into.editingToolCalls += add.editingToolCalls;
307
+ into.filesCreated += add.filesCreated;
308
+ into.filesModified += add.filesModified;
309
+ into.filesDeleted += add.filesDeleted;
310
+ into.gitCommits += add.gitCommits;
311
+ into.gitPushes += add.gitPushes;
312
+ into.shellCommandsSeen += add.shellCommandsSeen;
313
+ into.userTurns += add.userTurns;
314
+ into.assistantTurns += add.assistantTurns;
315
+ into.toolCount += add.toolCount;
316
+ into.errorCount += add.errorCount;
317
+ for (let i = 0; i < 24; i++)
318
+ into.messageHours[i] += add.messageHours[i];
319
+ }
320
+ /** A fresh zeroed accumulator, for callers folding many sessions together. */
321
+ export function newFacetAccumulator() {
322
+ return emptyFacets();
323
+ }
324
+ /** Top-N entries of a count map, highest first, ties broken by name for determinism. */
325
+ export function topEntries(counts, limit) {
326
+ return Object.entries(counts)
327
+ .map(([name, count]) => ({ name, count }))
328
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
329
+ .slice(0, limit);
330
+ }
@@ -34,6 +34,23 @@ export declare function safeReadSessionFile(filePath: string, maxBytes?: number)
34
34
  export interface ParseSessionOptions {
35
35
  /** Keep normalized tool results compact by default; renderers can request full output. */
36
36
  maxToolOutputChars?: number;
37
+ /**
38
+ * Emit an `interrupt` event where the transcript records `[Request interrupted`.
39
+ *
40
+ * OFF by default, deliberately. That marker is not a user message, and the default
41
+ * event array is a versioned consumer contract: `agents sessions <id> --json`
42
+ * serializes it verbatim (see render.ts, issue #743), `computeSummaryStats` folds
43
+ * every event's timestamp into the session duration, and the live-state reader and
44
+ * tail renderer inspect fixed-size windows of the last N events. Emitting it
45
+ * unconditionally changed all four — a measured 12x duration swing on one real
46
+ * transcript, a new object in a published payload, and an eviction from the
47
+ * 12-event rate-limit window whose trigger shape (a trailing interrupt) is exactly
48
+ * a session the user just cancelled.
49
+ *
50
+ * `agents insights` opts in: an interruption is a real friction signal, and dropping
51
+ * it outright is what made it unrecoverable.
52
+ */
53
+ includeInterrupts?: boolean;
37
54
  }
38
55
  export declare function parseSession(filePath: string, agent?: SessionAgentId, opts?: ParseSessionOptions): SessionEvent[];
39
56
  /** Infer the agent type from a session file path using known directory conventions. */
@@ -54,14 +71,14 @@ export declare function isCompletedTodoStatus(status: unknown): boolean;
54
71
  */
55
72
  export declare function summarizeToolUse(tool: string, args?: Record<string, any>): string;
56
73
  /** Parse a Claude JSONL session file into normalized events. */
57
- export declare function parseClaude(filePath: string): SessionEvent[];
74
+ export declare function parseClaude(filePath: string, opts?: ParseSessionOptions): SessionEvent[];
58
75
  /**
59
76
  * Parse Claude JSONL *content* (already read into a string) into normalized
60
77
  * events. Split from `parseClaude` so the tail reader can parse just the last
61
78
  * chunk of a file without re-reading the whole thing. Malformed leading lines
62
79
  * (a tail that starts mid-line) are skipped by the per-line try/catch below.
63
80
  */
64
- export declare function parseClaudeContent(content: string): SessionEvent[];
81
+ export declare function parseClaudeContent(content: string, opts?: ParseSessionOptions): SessionEvent[];
65
82
  /** Parse a Codex JSONL session file into normalized events. */
66
83
  export declare function parseCodex(filePath: string): SessionEvent[];
67
84
  /**
@@ -125,7 +125,7 @@ export function parseSession(filePath, agent, opts = {}) {
125
125
  let events;
126
126
  switch (detected) {
127
127
  case 'claude':
128
- events = parseClaude(filePath);
128
+ events = parseClaude(filePath, opts);
129
129
  break;
130
130
  case 'codex':
131
131
  events = parseCodex(filePath);
@@ -311,8 +311,8 @@ function shortenPath(p) {
311
311
  // Claude parser
312
312
  // ---------------------------------------------------------------------------
313
313
  /** Parse a Claude JSONL session file into normalized events. */
314
- export function parseClaude(filePath) {
315
- return parseClaudeContent(safeReadSessionFile(filePath));
314
+ export function parseClaude(filePath, opts = {}) {
315
+ return parseClaudeContent(safeReadSessionFile(filePath), opts);
316
316
  }
317
317
  /**
318
318
  * Parse Claude JSONL *content* (already read into a string) into normalized
@@ -320,7 +320,7 @@ export function parseClaude(filePath) {
320
320
  * chunk of a file without re-reading the whole thing. Malformed leading lines
321
321
  * (a tail that starts mid-line) are skipped by the per-line try/catch below.
322
322
  */
323
- export function parseClaudeContent(content) {
323
+ export function parseClaudeContent(content, opts = {}) {
324
324
  const lines = content.split('\n').filter(l => l.trim());
325
325
  const events = [];
326
326
  // Map tool_use id -> {tool, args} for correlating with tool_result
@@ -436,7 +436,15 @@ export function parseClaudeContent(content) {
436
436
  for (const block of contentBlocks) {
437
437
  if (block.type === 'text') {
438
438
  const text = (block.text || '').trim();
439
- if (text && !text.startsWith('[Request interrupted')) {
439
+ if (text.startsWith('[Request interrupted')) {
440
+ // The harness's marker for a turn the user cut short, not a user
441
+ // message. Surfaced only on request — see includeInterrupts for why
442
+ // the default stream must stay byte-identical.
443
+ if (opts.includeInterrupts) {
444
+ events.push({ type: 'interrupt', agent: 'claude', timestamp, content: text });
445
+ }
446
+ }
447
+ else if (text) {
440
448
  events.push({
441
449
  type: 'message',
442
450
  agent: 'claude',
@@ -20,7 +20,7 @@ export declare const SESSION_AGENTS: SessionAgentId[];
20
20
  export declare function isSessionTrackedAgent(agent: string): agent is SessionAgentId;
21
21
  /** A single normalized event within a session (message, tool call, thinking, etc.). */
22
22
  export interface SessionEvent {
23
- type: 'message' | 'tool_use' | 'tool_result' | 'thinking' | 'error' | 'init' | 'result' | 'usage' | 'attachment' | 'hook';
23
+ type: 'message' | 'tool_use' | 'tool_result' | 'thinking' | 'error' | 'init' | 'result' | 'usage' | 'attachment' | 'hook' | 'interrupt';
24
24
  agent: SessionAgentId;
25
25
  timestamp: string;
26
26
  role?: 'user' | 'assistant';
@@ -72,6 +72,7 @@ export declare const loadRefreshRules: ModuleLoader;
72
72
  export declare const loadFactory: ModuleLoader;
73
73
  export declare const loadUsage: ModuleLoader;
74
74
  export declare const loadCost: ModuleLoader;
75
+ export declare const loadInsights: ModuleLoader;
75
76
  export declare const loadPerf: ModuleLoader;
76
77
  export declare const loadTrends: ModuleLoader;
77
78
  export declare const loadOutput: ModuleLoader;
@@ -50,6 +50,7 @@ export const loadRefreshRules = async () => (await import('../../commands/refres
50
50
  export const loadFactory = async () => (await import('../../commands/factory.js')).registerFactoryCommands;
51
51
  export const loadUsage = async () => (await import('../../commands/usage.js')).registerUsageCommand;
52
52
  export const loadCost = async () => (await import('../../commands/cost.js')).registerCostCommand;
53
+ export const loadInsights = async () => (await import('../../commands/insights.js')).registerInsightsCommand;
53
54
  export const loadPerf = async () => (await import('../../commands/perf.js')).registerPerfCommand;
54
55
  export const loadTrends = async () => (await import('../../commands/trends.js')).registerTrendsCommand;
55
56
  export const loadOutput = async () => (await import('../../commands/output.js')).registerOutputCommand;
@@ -177,6 +178,7 @@ export const COMMAND_LOADERS = {
177
178
  factory: [loadFactory],
178
179
  usage: [loadUsage],
179
180
  cost: [loadCost],
181
+ insights: [loadInsights],
180
182
  perf: [loadPerf],
181
183
  trends: [loadTrends],
182
184
  output: [loadOutput],
package/dist/lib/usage.js CHANGED
@@ -605,7 +605,34 @@ export function formatUsageSection(usage) {
605
605
  /** Fetch Codex usage by scanning the most recent session files for rate-limit events. */
606
606
  async function getCodexUsageInfo(options) {
607
607
  try {
608
- const files = collectCodexSessionFiles(options?.home);
608
+ // Codex usage is read from on-disk session transcripts, which carry no
609
+ // account identity and are not removed on logout. To keep the bar scoped to
610
+ // the account signed in NOW, floor the scan at the current login time: the
611
+ // id_token's `auth_time` claim — the OIDC time-of-authentication. A session
612
+ // written before that login belongs to whoever was signed in before (e.g.
613
+ // after `codex logout` + login into a different account), and showing its
614
+ // rate_limits is the "wrong usage after switch" bug.
615
+ //
616
+ // `auth_time` — not the auth.json file mtime — is the correct floor: Codex
617
+ // rewrites auth.json on every token refresh (advancing its mtime), but a
618
+ // refresh_token grant does not re-authenticate the user, so `auth_time`
619
+ // stays at the real login. Flooring on mtime would blank the bar after each
620
+ // background refresh; flooring on `auth_time` does not. No readable
621
+ // credential means the version is signed out — report no usage. A credential
622
+ // that carries no `auth_time` falls back to no floor (prior behavior) rather
623
+ // than hide a signed-in account's usage.
624
+ const base = options?.home || os.homedir();
625
+ let sinceMs;
626
+ try {
627
+ const tokens = JSON.parse(fs.readFileSync(path.join(base, '.codex', 'auth.json'), 'utf-8')).tokens;
628
+ const authTime = decodeJwtPayload(tokens?.id_token || tokens?.access_token || '')?.auth_time;
629
+ if (typeof authTime === 'number' && authTime > 0)
630
+ sinceMs = authTime * 1000;
631
+ }
632
+ catch {
633
+ return { snapshot: null, error: null };
634
+ }
635
+ const files = collectCodexSessionFiles(options?.home, sinceMs);
609
636
  for (const filePath of files) {
610
637
  const match = await readLatestCodexRateLimits(filePath);
611
638
  if (!match)
@@ -1099,8 +1126,15 @@ function normalizeDroidWindow(window, key, label, shortLabel) {
1099
1126
  windowMinutes: inferWindowMinutes(key),
1100
1127
  };
1101
1128
  }
1102
- /** Collect Codex JSONL session files sorted newest-first. */
1103
- function collectCodexSessionFiles(home) {
1129
+ /**
1130
+ * Collect Codex JSONL session files sorted newest-first.
1131
+ *
1132
+ * `sinceMs` drops files modified before it. Codex session transcripts are not
1133
+ * tagged with the account that wrote them, so this mtime floor is how usage is
1134
+ * kept account-scoped: a session older than the current login belongs to a
1135
+ * prior account (see {@link getCodexUsageInfo}).
1136
+ */
1137
+ function collectCodexSessionFiles(home, sinceMs) {
1104
1138
  const base = home || os.homedir();
1105
1139
  const dir = path.join(base, '.codex', 'sessions');
1106
1140
  if (!fs.existsSync(dir))
@@ -1115,6 +1149,8 @@ function collectCodexSessionFiles(home) {
1115
1149
  const stat = safeStatSync(filePath);
1116
1150
  if (!stat)
1117
1151
  continue;
1152
+ if (sinceMs !== undefined && stat.mtimeMs < sinceMs)
1153
+ continue;
1118
1154
  files.push({ path: filePath, mtime: stat.mtimeMs });
1119
1155
  }
1120
1156
  files.sort((a, b) => b.mtime - a.mtime);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.20",
3
+ "version": "1.22.22",
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",