@phnx-labs/agents-cli 1.22.21 → 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.
- package/CHANGELOG.md +47 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/insights.d.ts +32 -0
- package/dist/commands/insights.js +478 -0
- package/dist/commands/sessions-picker.d.ts +2 -0
- package/dist/commands/sessions-picker.js +1 -0
- package/dist/commands/sessions.js +6 -0
- package/dist/index.js +3 -1
- package/dist/lib/hosts/passthrough.js +1 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/picker.d.ts +45 -0
- package/dist/lib/picker.js +75 -6
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/session/db.d.ts +34 -0
- package/dist/lib/session/db.js +100 -0
- package/dist/lib/session/digest.d.ts +3 -0
- package/dist/lib/session/digest.js +3 -3
- package/dist/lib/session/insights.d.ts +126 -0
- package/dist/lib/session/insights.js +330 -0
- package/dist/lib/session/parse.d.ts +19 -2
- package/dist/lib/session/parse.js +13 -5
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/usage.js +39 -3
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
/**
|
|
1103
|
-
|
|
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.
|
|
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",
|