@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 CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.22
4
+
5
+ - **New: `agents insights` — how you work, split by the Claude account that did the
6
+ work.** Tool and language mix, friction (interruptions, tool-error classes, your own
7
+ reply latency), what you changed (line deltas, files, commits), an hour-of-day
8
+ rhythm, and how often two accounts ran at once. Modelled on Claude Code's
9
+ `/insights`, with the difference that motivated it: that command reads one account's
10
+ directory, while `balanced` rotation sprays sessions across every signed-in account,
11
+ so it describes a fraction of the work and credits all of it to one org. Source:
12
+ `apps/cli/src/commands/insights.ts`, `apps/cli/src/lib/session/insights.ts`.
13
+
14
+ Deterministic and offline by default. `--narrative` is opt-in and adds a written read
15
+ by piping the *aggregate* — never raw transcripts, unlike `/insights` — through a
16
+ headless `claude -p`. Facets are cached per session in a new `session_insights` table
17
+ keyed on `(file_mtime_ms, file_size)`, so the first run parses every transcript once
18
+ and later runs re-read only what changed.
19
+
20
+ - **The Claude parser can surface interruption markers on request.** `[Request
21
+ interrupted` text was dropped outright, so the signal was unrecoverable downstream.
22
+ `parseSession(..., { includeInterrupts: true })` now emits a dedicated `interrupt`
23
+ event. It stays OFF by default because the event array is a versioned consumer
24
+ contract: `agents sessions <id> --json` serializes it verbatim, `computeSummaryStats`
25
+ folds every timestamp into the session duration, and the live-state reader inspects a
26
+ fixed window of trailing events. `agents insights` is the only caller that opts in.
27
+ Source: `apps/cli/src/lib/session/parse.ts`.
28
+
29
+ - **`digest.ts` now classifies droid's `Create` as a file write.** Its tool-vocabulary
30
+ set claimed cross-harness coverage but omitted it, so droid file creations classified
31
+ as nothing. Source: `apps/cli/src/lib/session/digest.ts`.
32
+
33
+ - **Codex usage bars no longer show a previous account's numbers after you switch accounts.** `agents view` derives a Codex version's usage from that home's session transcripts, which carry no account identity and are not removed on logout — so after logging a version out and into a different ChatGPT account, the bar kept showing the old account's last-seen percentage (e.g. "S: 99%") until the new account ran a session. Usage is now scoped to the current login: only sessions written at/after the id_token's `auth_time` (the OIDC authentication time) count, and a signed-out home reports no usage. `auth_time` is used rather than the auth.json file mtime because Codex rewrites auth.json on every token refresh, but a refresh does not re-authenticate, so `auth_time` stays at the real login — an actively-refreshing account keeps its bar, only a real re-login or account switch moves the floor. Source: `apps/cli/src/lib/usage.ts`.
34
+
35
+ - **The interactive session picker's detailed preview no longer collapses to empty.**
36
+ `agents sessions`, `agents sessions <query>`, and `agents sessions --active` open the
37
+ picker with the rich preview pane (prompt, files, hooks, errors, tests, last response)
38
+ on by default, but the preview had no guaranteed height: a 15-row list
39
+ (`PICKER_RECENT_COUNT`) on a short terminal consumed the whole viewport, the computed
40
+ `availablePreviewRows` went to zero, and the pane silently vanished — worse when
41
+ fleet-unreachable warnings and the hidden-session footer had scrolled lines above the
42
+ prompt. The picker now caps the visible list page so the preview keeps a floor of
43
+ `PREVIEW_MIN_ROWS` (6) rows, and accounts for the lines printed above the prompt so
44
+ those notices and the preview stay on screen together. Applied consistently across
45
+ `itemPicker`, `dynamicPicker`, and `multiItemPicker`, so the bare browser, the query
46
+ picker, and the `--active` browser all behave the same; the space/tab preview toggle
47
+ is unchanged. Source: `apps/cli/src/lib/picker.ts`,
48
+ `apps/cli/src/commands/sessions-picker.ts`, `apps/cli/src/commands/sessions.ts`.
49
+
3
50
  ## 1.22.21
4
51
 
5
52
  - **`agents secrets exec <bundle> -- <cmd>` now resolves a locked keychain bundle
package/dist/bin/agents CHANGED
Binary file
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Insights command — how you actually work, split by the account that did the work.
3
+ *
4
+ * The behavioural sibling of the existing rollups, and deliberately not a duplicate of
5
+ * any of them:
6
+ *
7
+ * agents cost what you spent ($ and duration)
8
+ * agents output what shipped (burn vs PRs and commits)
9
+ * agents usage live quota headroom (rate-limit windows, right now)
10
+ * agents trends aggregate distributions (harness mix, tools-per-session, token ratios)
11
+ * agents sessions browse individual work (search, resume, render)
12
+ * agents insights HOW you work (tools, friction, rhythm, per account)
13
+ *
14
+ * The closest neighbour is `agents trends`, and the boundary is the data path: trends
15
+ * reads counters — `tool_scan_ledger` call counts and the analytics warehouse — to
16
+ * produce distributions ("how many tool calls per session, by harness"). This reads
17
+ * transcript CONTENT through `parseSession` to produce behaviour ("which tools, which
18
+ * languages, where it went wrong, when you were working"), and splits all of it by
19
+ * account, a dimension trends does not have. They overlap in spirit on tool and model
20
+ * mix; they do not read the same store or answer the same question.
21
+ *
22
+ * Modelled on Claude Code's `/insights`, with the difference that motivated it: that
23
+ * command reads one account's directory, while `balanced` rotation sprays sessions
24
+ * across every signed-in account. This reads the whole index and reports the accounts
25
+ * apart — see lib/session/claude-accounts.ts for how a transcript is attributed.
26
+ *
27
+ * The deterministic report makes zero network calls. `--narrative` is opt-in and adds
28
+ * the coaching prose by piping the AGGREGATE (never raw transcripts) through a headless
29
+ * `claude -p`.
30
+ */
31
+ import type { Command } from 'commander';
32
+ export declare function registerInsightsCommand(program: Command): void;
@@ -0,0 +1,478 @@
1
+ /**
2
+ * Insights command — how you actually work, split by the account that did the work.
3
+ *
4
+ * The behavioural sibling of the existing rollups, and deliberately not a duplicate of
5
+ * any of them:
6
+ *
7
+ * agents cost what you spent ($ and duration)
8
+ * agents output what shipped (burn vs PRs and commits)
9
+ * agents usage live quota headroom (rate-limit windows, right now)
10
+ * agents trends aggregate distributions (harness mix, tools-per-session, token ratios)
11
+ * agents sessions browse individual work (search, resume, render)
12
+ * agents insights HOW you work (tools, friction, rhythm, per account)
13
+ *
14
+ * The closest neighbour is `agents trends`, and the boundary is the data path: trends
15
+ * reads counters — `tool_scan_ledger` call counts and the analytics warehouse — to
16
+ * produce distributions ("how many tool calls per session, by harness"). This reads
17
+ * transcript CONTENT through `parseSession` to produce behaviour ("which tools, which
18
+ * languages, where it went wrong, when you were working"), and splits all of it by
19
+ * account, a dimension trends does not have. They overlap in spirit on tool and model
20
+ * mix; they do not read the same store or answer the same question.
21
+ *
22
+ * Modelled on Claude Code's `/insights`, with the difference that motivated it: that
23
+ * command reads one account's directory, while `balanced` rotation sprays sessions
24
+ * across every signed-in account. This reads the whole index and reports the accounts
25
+ * apart — see lib/session/claude-accounts.ts for how a transcript is attributed.
26
+ *
27
+ * The deterministic report makes zero network calls. `--narrative` is opt-in and adds
28
+ * the coaching prose by piping the AGGREGATE (never raw transcripts) through a headless
29
+ * `claude -p`.
30
+ */
31
+ import * as fs from 'fs';
32
+ import chalk from 'chalk';
33
+ import { execFile } from 'child_process';
34
+ import { promisify } from 'util';
35
+ import { addHostOption } from '../lib/hosts/option.js';
36
+ import { setHelpSections } from '../lib/help.js';
37
+ import { discoverSessions, parseTimeFilter } from '../lib/session/discover.js';
38
+ import { querySessions, readSessionInsights, writeSessionInsights, clearSessionInsights, } from '../lib/session/db.js';
39
+ import { parseSession } from '../lib/session/parse.js';
40
+ import { computeInsightFacets, mergeFacets, newFacetAccumulator, detectOverlap, percentile, bucketGaps, topEntries, } from '../lib/session/insights.js';
41
+ import { formatUsd } from '../lib/pricing/index.js';
42
+ import { formatDuration } from '../lib/session/render.js';
43
+ import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
44
+ const execFileAsync = promisify(execFile);
45
+ function resolveGroup(by) {
46
+ if (by === undefined)
47
+ return 'account';
48
+ if (by === 'account' || by === 'agent' || by === 'project' || by === 'day')
49
+ return by;
50
+ console.error(chalk.red('error: --by must be one of: account, agent, project, day'));
51
+ process.exit(1);
52
+ }
53
+ /**
54
+ * Sessions too short to say anything about how you work.
55
+ *
56
+ * Inspired by the filter `/insights` applies, but NOT identical and deliberately not
57
+ * claimed to be: `/insights` counts USER messages, while `messageCount` on the index
58
+ * counts both roles, so the same threshold is a weaker bar here. Matching it exactly
59
+ * would mean parsing every session just to decide whether to parse it. The dropped
60
+ * count is always reported, never silent.
61
+ */
62
+ function isSubstantive(m, minMessages) {
63
+ if ((m.messageCount ?? 0) < minMessages)
64
+ return false;
65
+ if ((m.durationMs ?? 0) < 60_000)
66
+ return false;
67
+ return true;
68
+ }
69
+ function groupKeyFor(m, dim) {
70
+ switch (dim) {
71
+ case 'account': return m.accountKey ?? `unattributed:${m.agent}`;
72
+ case 'agent': return m.agent;
73
+ case 'project': return m.project || '(no project)';
74
+ case 'day': return m.timestamp.slice(0, 10);
75
+ }
76
+ }
77
+ function groupLabelFor(m, dim, key) {
78
+ if (dim !== 'account')
79
+ return key;
80
+ if (m.accountOrg && m.account)
81
+ return `${m.accountOrg} <${m.account}>`;
82
+ return key;
83
+ }
84
+ /**
85
+ * Load facets for every in-scope session, parsing only what the cache does not
86
+ * already hold. A cold first run parses every transcript once; after that only files
87
+ * whose (mtime, size) changed are re-read.
88
+ */
89
+ async function collectFacets(rows, onProgress) {
90
+ let unreadable = 0;
91
+ const cached = readSessionInsights(rows.map((r) => r.id));
92
+ const stale = rows.filter((r) => !cached.has(r.id) && r.filePath);
93
+ if (stale.length === 0)
94
+ return { facets: cached, unreadable };
95
+ const fresh = [];
96
+ let done = 0;
97
+ for (const row of stale) {
98
+ try {
99
+ // Stat BEFORE reading, so the stamp we persist describes bytes no newer than the
100
+ // ones parsed: a rescan landing mid-read then reads as stale, not as a hit.
101
+ const st = fs.statSync(row.filePath);
102
+ // includeInterrupts: the default event array is a versioned contract, so the
103
+ // marker is opt-in and this is the reader that opts in.
104
+ const events = parseSession(row.filePath, row.agent, { includeInterrupts: true });
105
+ const facets = computeInsightFacets(events);
106
+ cached.set(row.id, facets);
107
+ fresh.push({ id: row.id, fileMtimeMs: Math.floor(st.mtimeMs), fileSize: st.size, facets });
108
+ }
109
+ catch {
110
+ // Deleted or corrupt since it was indexed. Counted and reported below, never
111
+ // silently contributing zero.
112
+ unreadable++;
113
+ }
114
+ done++;
115
+ if (done % 25 === 0)
116
+ onProgress(done, stale.length);
117
+ // Persist in batches so an interrupted cold run does not lose everything.
118
+ if (fresh.length >= 200) {
119
+ writeSessionInsights(fresh.splice(0, fresh.length));
120
+ }
121
+ }
122
+ if (fresh.length > 0)
123
+ writeSessionInsights(fresh);
124
+ onProgress(stale.length, stale.length);
125
+ return { facets: cached, unreadable };
126
+ }
127
+ function buildGroups(rows, facetsById, dim) {
128
+ const byKey = new Map();
129
+ for (const m of rows) {
130
+ const key = groupKeyFor(m, dim);
131
+ let g = byKey.get(key);
132
+ if (!g) {
133
+ g = {
134
+ key,
135
+ label: groupLabelFor(m, dim, key),
136
+ plan: null,
137
+ sessions: 0,
138
+ costUsd: 0,
139
+ durationMs: 0,
140
+ outputTokens: 0,
141
+ facets: newFacetAccumulator(),
142
+ };
143
+ byKey.set(key, g);
144
+ }
145
+ g.sessions++;
146
+ g.costUsd += m.costUsd ?? 0;
147
+ g.durationMs += m.durationMs ?? 0;
148
+ g.outputTokens += m.outputTokens ?? 0;
149
+ const f = facetsById.get(m.id);
150
+ if (f)
151
+ mergeFacets(g.facets, f);
152
+ }
153
+ return [...byKey.values()].sort((a, b) => b.sessions - a.sessions || a.key.localeCompare(b.key));
154
+ }
155
+ /** A compact bar for a count relative to the row maximum. */
156
+ function bar(count, max, width) {
157
+ if (max <= 0)
158
+ return '';
159
+ const filled = Math.max(1, Math.round((count / max) * width));
160
+ return '█'.repeat(filled);
161
+ }
162
+ function renderCounts(title, entries, out) {
163
+ if (entries.length === 0)
164
+ return;
165
+ out.push('');
166
+ out.push(chalk.bold(title));
167
+ const nameW = Math.max(...entries.map((e) => stringWidth(e.name)));
168
+ const countW = Math.max(...entries.map((e) => String(e.count).length));
169
+ const max = Math.max(...entries.map((e) => e.count));
170
+ const barW = Math.max(6, Math.min(28, terminalWidth() - nameW - countW - 8));
171
+ for (const e of entries) {
172
+ out.push(` ${padToWidth(e.name, nameW)} ${chalk.cyan(String(e.count).padStart(countW))} ` +
173
+ chalk.gray(bar(e.count, max, barW)));
174
+ }
175
+ }
176
+ function renderHours(hours, out) {
177
+ const total = hours.reduce((a, b) => a + b, 0);
178
+ if (total === 0)
179
+ return;
180
+ const blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
181
+ const max = Math.max(...hours);
182
+ const spark = hours
183
+ .map((h) => (h === 0 ? ' ' : blocks[Math.min(blocks.length - 1, Math.floor((h / max) * (blocks.length - 1)))]))
184
+ .join('');
185
+ out.push('');
186
+ out.push(chalk.bold('When you work') + chalk.gray(' (local time)'));
187
+ out.push(` ${chalk.cyan(spark)}`);
188
+ out.push(` ${chalk.gray('0h'.padEnd(6))}${chalk.gray('6h'.padEnd(6))}${chalk.gray('12h'.padEnd(6))}${chalk.gray('18h'.padEnd(5))}${chalk.gray('23h')}`);
189
+ }
190
+ function renderReport(groups, dim, meta) {
191
+ const out = [];
192
+ const scope = meta.since ? `last ${meta.since}` : 'all time';
193
+ out.push(chalk.bold('Insights') + chalk.gray(` ${scope} · ${meta.analyzed} of ${meta.scanned} sessions`));
194
+ if (groups.length === 0) {
195
+ out.push('');
196
+ out.push(chalk.gray(' No sessions in scope. Try a wider --since, or run `agents sessions --all` to index.'));
197
+ console.log(out.join('\n'));
198
+ return;
199
+ }
200
+ // Per-group table — the headline, and the thing no sibling command produces.
201
+ out.push('');
202
+ out.push(chalk.bold(`By ${dim}`));
203
+ const labelW = Math.min(Math.max(...groups.map((g) => stringWidth(g.label)), 5), Math.max(20, terminalWidth() - 46));
204
+ const sessW = Math.max(...groups.map((g) => String(g.sessions).length), 3);
205
+ for (const g of groups) {
206
+ const cost = g.costUsd > 0 ? formatUsd(g.costUsd) : '—';
207
+ const dur = g.durationMs > 0 ? formatDuration(g.durationMs) : '—';
208
+ out.push(` ${padToWidth(truncateToWidth(g.label, labelW), labelW)} ` +
209
+ `${chalk.gray(String(g.sessions).padStart(sessW))} ${chalk.gray('sess')} ` +
210
+ `${chalk.green(padToWidth(cost, 9))} ${chalk.gray(dur)}`);
211
+ }
212
+ // Everything below is the whole scope folded together; per-group detail is in --json.
213
+ const all = newFacetAccumulator();
214
+ for (const g of groups)
215
+ mergeFacets(all, g.facets);
216
+ renderCounts('Top tools', topEntries(all.toolCounts, 8), out);
217
+ renderCounts('Languages', topEntries(all.languages, 6), out);
218
+ renderCounts('Models', topEntries(all.models, 6), out);
219
+ // Friction — the section that earns the command.
220
+ const gaps = all.responseGaps;
221
+ out.push('');
222
+ out.push(chalk.bold('Friction'));
223
+ out.push(` ${padToWidth('interruptions', 18)} ${chalk.cyan(String(all.interruptions))}` +
224
+ chalk.gray(' turns you cut short'));
225
+ out.push(` ${padToWidth('tool errors', 18)} ${chalk.cyan(String(all.errorCount))}`);
226
+ if (gaps.length > 0) {
227
+ out.push(` ${padToWidth('your reply time', 18)} ` +
228
+ chalk.cyan(`p50 ${Math.round(percentile(gaps, 50))}s`) + chalk.gray(` · p90 ${Math.round(percentile(gaps, 90))}s`));
229
+ }
230
+ const errs = topEntries(all.errorCategories, 6);
231
+ if (errs.length > 0) {
232
+ for (const e of errs)
233
+ out.push(` ${chalk.gray('·')} ${padToWidth(e.name, 16)} ${chalk.gray(String(e.count))}`);
234
+ }
235
+ // Output
236
+ out.push('');
237
+ out.push(chalk.bold('What you changed'));
238
+ // Gate on whether anything was actually measured, not on whether an edit-shaped call
239
+ // was seen. Codex patches through `exec`, so it can log edit-class calls and still
240
+ // expose no line arguments to count — rendering that as "0 lines" would read as "wrote
241
+ // nothing" for a harness that wrote plenty.
242
+ if (all.linesTouchedAfter > 0 || all.linesTouchedBefore > 0) {
243
+ // "touched", not "+/-": these are the before/after line counts of each edit, so an
244
+ // Edit with unchanged context lines counts them on both sides. Not a diffstat, and
245
+ // labelled so nobody reads it as one.
246
+ out.push(` ${chalk.cyan(String(all.linesTouchedAfter))} ${chalk.gray('lines written,')} ` +
247
+ `${chalk.cyan(String(all.linesTouchedBefore))} ${chalk.gray('replaced')} ` +
248
+ chalk.gray('(lines touched, not a diff)'));
249
+ }
250
+ else {
251
+ out.push(` ${chalk.gray('lines touched — not measurable for this harness (edits go through the shell)')}`);
252
+ }
253
+ out.push(` ${chalk.gray(`${all.filesCreated} created, ${all.filesModified} modified, ${all.filesDeleted} deleted`)}`);
254
+ // Same not-measurable rule as the lines above. These are substring-matched from
255
+ // shell command TEXT, and not every harness exposes it — the codex parser populates
256
+ // `command` for `exec_command` but not plain `exec`, its dominant tool — so gate on
257
+ // whether we had anything to search rather than on seeing a shell-shaped tool call.
258
+ // When we did, the count is real, and still disagrees with `agents output`, which
259
+ // counts deduped SHAs from git log.
260
+ if (all.shellCommandsSeen > 0) {
261
+ out.push(` ${chalk.gray(`${all.gitCommits} commits · ${all.gitPushes} pushes (seen in shell commands)`)}`);
262
+ }
263
+ else {
264
+ out.push(` ${chalk.gray('commits — not measurable for this harness')}`);
265
+ }
266
+ renderHours(all.messageHours, out);
267
+ // Concurrency — direct evidence that a single-account view would be wrong.
268
+ if (meta.overlap.overlappingPairs > 0) {
269
+ out.push('');
270
+ out.push(chalk.bold('Parallel sessions'));
271
+ out.push(` ${chalk.cyan(String(meta.overlap.sessionsInvolved))} ${chalk.gray('sessions ran alongside another')}`);
272
+ // Pairs, not sessions — stated as pairs so the two numbers are not read as a
273
+ // subset of each other.
274
+ const crossNote = meta.overlap.crossAccountPairs > 0
275
+ ? `, ${meta.overlap.crossAccountPairs} of them across two different accounts`
276
+ : '';
277
+ out.push(chalk.gray(` ${meta.overlap.overlappingPairs} overlapping pairs${crossNote}`));
278
+ }
279
+ if (meta.filteredOut > 0) {
280
+ out.push('');
281
+ out.push(chalk.gray(` ${meta.filteredOut} sessions excluded as too short (under ${meta.minMessages} messages or 1 minute).`));
282
+ }
283
+ if (meta.unreadable > 0) {
284
+ if (meta.filteredOut === 0)
285
+ out.push('');
286
+ out.push(chalk.yellow(` ${meta.unreadable} transcripts could not be read; their behaviour is missing from these totals.`));
287
+ }
288
+ if (all.gapsOverCeiling > 0) {
289
+ out.push(chalk.gray(` ${all.gapsOverCeiling} reply gaps over an hour excluded from the percentiles.`));
290
+ }
291
+ out.push('');
292
+ out.push(chalk.gray(' `agents insights --by project` to see it per repo'));
293
+ out.push(chalk.gray(' `agents insights --narrative` for a written read on what to change'));
294
+ console.log(out.join('\n'));
295
+ }
296
+ /**
297
+ * The opt-in coaching layer. Pipes the AGGREGATE through a headless `claude -p` — never
298
+ * raw transcripts, unlike `/insights`, which ships session text to the API. Reuses
299
+ * whatever account the shim resolves, so there is no API key handling here.
300
+ */
301
+ async function renderNarrative(payload) {
302
+ const prompt = [
303
+ 'You are reading a developer\'s own coding-session telemetry, already aggregated.',
304
+ 'Write a short, direct read for them. Four sections, 2-3 sentences each:',
305
+ '1. What is working — the patterns worth keeping.',
306
+ '2. What is costing you — split into the assistant\'s fault vs your own workflow.',
307
+ '3. Quick wins — concrete, tied to a number in the data.',
308
+ '4. Worth trying — one more ambitious workflow change.',
309
+ 'Be specific and cite the numbers. No preamble, no flattery, no bullet padding.',
310
+ '',
311
+ JSON.stringify(payload),
312
+ ].join('\n');
313
+ try {
314
+ const { stdout } = await execFileAsync('claude', ['-p', prompt], {
315
+ timeout: 180_000,
316
+ maxBuffer: 8 * 1024 * 1024,
317
+ });
318
+ // stderr, always. Under --json stdout is a machine contract, and prose appended
319
+ // after the closing brace makes the payload unparseable; on a TTY stderr renders
320
+ // identically, so there is nothing to special-case.
321
+ process.stderr.write('\n' + chalk.bold('Narrative') + '\n');
322
+ process.stderr.write(stdout.trim().split('\n').map((l) => ` ${l}`).join('\n') + '\n');
323
+ }
324
+ catch (err) {
325
+ const msg = err.code === 'ENOENT'
326
+ ? 'claude is not on PATH'
327
+ : (err.message ?? 'unknown error');
328
+ console.error('');
329
+ console.error(chalk.red(`✗ narrative unavailable: ${msg}`));
330
+ console.error(chalk.gray(' The report above is complete; only the written section was skipped.'));
331
+ // A scripted caller asked for this section and did not get it. Say so in the exit
332
+ // code rather than reporting success for a partial result.
333
+ process.exitCode = 1;
334
+ }
335
+ }
336
+ async function insightsAction(options) {
337
+ const dim = resolveGroup(options.by);
338
+ const minMessages = Number.parseInt(options.minMessages ?? '2', 10);
339
+ if (!Number.isFinite(minMessages) || minMessages < 0) {
340
+ console.error(chalk.red('error: --min-messages must be a non-negative integer'));
341
+ process.exit(1);
342
+ }
343
+ const since = options.since ?? '30d';
344
+ const sinceMs = since === 'all' ? undefined : parseTimeFilter(since);
345
+ // Refresh the index first, exactly as `agents cost` does, so a report never silently
346
+ // describes a stale picture of disk.
347
+ await discoverSessions({ all: true, since: since === 'all' ? undefined : since, limit: 1 });
348
+ if (options.refresh)
349
+ clearSessionInsights();
350
+ const filter = { sinceMs };
351
+ if (options.agent)
352
+ filter.agent = options.agent;
353
+ const scanned = querySessions(filter);
354
+ const wanted = options.account?.toLowerCase();
355
+ const inScope = scanned.filter((m) => {
356
+ if (!wanted)
357
+ return true;
358
+ return [m.accountKey, m.account, m.accountOrg]
359
+ .some((v) => v?.toLowerCase().includes(wanted));
360
+ });
361
+ const substantive = inScope.filter((m) => isSubstantive(m, minMessages));
362
+ const filteredOut = inScope.length - substantive.length;
363
+ const isTty = process.stdout.isTTY && !options.json;
364
+ const { facets: facetsById, unreadable } = await collectFacets(substantive, (done, total) => {
365
+ if (isTty && done < total)
366
+ process.stderr.write(`\rReading transcripts ${done}/${total}…`);
367
+ else if (isTty)
368
+ process.stderr.write('\r'.padEnd(40) + '\r');
369
+ });
370
+ const spans = substantive.map((m) => {
371
+ const start = new Date(m.timestamp).getTime();
372
+ return {
373
+ id: m.id,
374
+ accountKey: m.accountKey ?? `unattributed:${m.agent}`,
375
+ startMs: start,
376
+ endMs: start + (m.durationMs ?? 0),
377
+ };
378
+ });
379
+ const overlap = detectOverlap(spans);
380
+ const groups = buildGroups(substantive, facetsById, dim);
381
+ if (options.json) {
382
+ const payload = {
383
+ generatedAt: new Date().toISOString(),
384
+ window: { since: since === 'all' ? null : since },
385
+ scanned: inScope.length,
386
+ analyzed: substantive.length,
387
+ filteredOut,
388
+ unreadable,
389
+ minMessages,
390
+ by: dim,
391
+ overlap,
392
+ groups: groups.map((g) => ({
393
+ key: g.key,
394
+ label: g.label,
395
+ sessions: g.sessions,
396
+ costUsd: g.costUsd,
397
+ durationMs: g.durationMs,
398
+ outputTokens: g.outputTokens,
399
+ ...g.facets,
400
+ responseGapP50: Math.round(percentile(g.facets.responseGaps, 50)),
401
+ responseGapP90: Math.round(percentile(g.facets.responseGaps, 90)),
402
+ responseGapBuckets: bucketGaps(g.facets.responseGaps),
403
+ // The raw sample is large and uninteresting once bucketed.
404
+ responseGaps: undefined,
405
+ })),
406
+ };
407
+ console.log(JSON.stringify(payload, null, 2));
408
+ if (options.narrative)
409
+ await renderNarrative(payload);
410
+ return;
411
+ }
412
+ renderReport(groups, dim, {
413
+ since: since === 'all' ? undefined : since,
414
+ scanned: inScope.length,
415
+ analyzed: substantive.length,
416
+ filteredOut,
417
+ unreadable,
418
+ minMessages,
419
+ overlap,
420
+ });
421
+ if (options.narrative) {
422
+ await renderNarrative(groups.map((g) => ({
423
+ account: g.label, sessions: g.sessions, costUsd: g.costUsd,
424
+ topTools: topEntries(g.facets.toolCounts, 8),
425
+ languages: topEntries(g.facets.languages, 6),
426
+ errorCategories: topEntries(g.facets.errorCategories, 6),
427
+ interruptions: g.facets.interruptions,
428
+ linesTouchedAfter: g.facets.linesTouchedAfter, linesTouchedBefore: g.facets.linesTouchedBefore,
429
+ gitCommits: g.facets.gitCommits,
430
+ replyP50s: Math.round(percentile(g.facets.responseGaps, 50)),
431
+ })));
432
+ }
433
+ }
434
+ export function registerInsightsCommand(program) {
435
+ const cmd = addHostOption(program.command('insights'))
436
+ .description('How you work — tools, friction, and rhythm, split by the account that did the work')
437
+ .option('--json', 'Output the full report as JSON')
438
+ .option('--since <time>', 'Window: 7d, 4w, 3mo, an ISO date, or "all" (default 30d)')
439
+ .option('--by <dimension>', 'Group by: account (default), agent, project, or day')
440
+ .option('--account <match>', 'Only sessions whose account key, email, or org contains this')
441
+ .option('--agent <id>', 'Only one harness (claude, codex, droid, …)')
442
+ .option('--min-messages <n>', 'Skip sessions under this many messages, both roles counted (default 2)')
443
+ .option('--refresh', 'Discard cached facets and re-read every transcript')
444
+ .option('--narrative', 'Add a written read on the numbers via a headless `claude -p`')
445
+ .action(async (options) => {
446
+ await insightsAction(options);
447
+ });
448
+ setHelpSections(cmd, {
449
+ examples: `
450
+ # Last 30 days, split by Claude account — the default
451
+ agents insights
452
+
453
+ # Which repo is eating the time
454
+ agents insights --by project --since 90d
455
+
456
+ # One account only, all of its history
457
+ agents insights --account "Turing Labs" --since all
458
+
459
+ # Machine-readable, for a dashboard or a slash command
460
+ agents insights --json
461
+
462
+ # Add a written read on what to change
463
+ agents insights --narrative
464
+ `,
465
+ notes: `
466
+ Answers "how do you work". For "what did it cost" use \`agents cost\`, for "what
467
+ shipped" use \`agents output\`, for live quota use \`agents usage\`.
468
+
469
+ The first run parses every in-scope transcript and caches the result; later runs
470
+ re-read only files that changed. \`--refresh\` forces a full re-read.
471
+
472
+ Account attribution is Claude-only today. Sessions from other harnesses group
473
+ under \`unattributed:<agent>\`.
474
+
475
+ Everything except \`--narrative\` is local and makes no network calls.
476
+ `,
477
+ });
478
+ }
@@ -28,6 +28,8 @@ export interface SessionPickerConfig {
28
28
  initialSearch?: string;
29
29
  /** Verb shown on the Enter key in the footer (default 'resume'). */
30
30
  enterHint?: string;
31
+ /** Lines the caller printed above the prompt (hidden-session footer). */
32
+ linesAbovePrompt?: number;
31
33
  }
32
34
  /** Build a cached multi-line preview string for display in the session picker. */
33
35
  export declare function buildPreview(session: SessionMeta): string;
@@ -801,6 +801,7 @@ export async function sessionPicker(config) {
801
801
  initialSearch: config.initialSearch,
802
802
  emptyMessage: 'No sessions match.',
803
803
  enterHint: config.enterHint ?? 'resume',
804
+ linesAbovePrompt: config.linesAbovePrompt,
804
805
  });
805
806
  if (!picked)
806
807
  return null;
@@ -2726,8 +2726,13 @@ export function formatPickerTip(sessions) {
2726
2726
  return chalk.gray(PICKER_TIPS[sessions.length % PICKER_TIPS.length]);
2727
2727
  }
2728
2728
  export async function pickSessionInteractive(sessions, message = 'Search sessions:', initialSearch, hiddenCount = 0, enterHint) {
2729
+ // The hidden-session footer is console.log'd above the Inquirer prompt, so it
2730
+ // scrolls the viewport the picker can't measure; tell the picker to reserve for
2731
+ // it (see pickerPageSize) so the preview and the footer stay on screen together.
2732
+ let linesAbovePrompt = 0;
2729
2733
  if (hiddenCount > 0) {
2730
2734
  console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
2735
+ linesAbovePrompt += 1;
2731
2736
  }
2732
2737
  const cols = pickerColumnsFor(sessions);
2733
2738
  try {
@@ -2746,6 +2751,7 @@ export async function pickSessionInteractive(sessions, message = 'Search session
2746
2751
  pageSize: PICKER_RECENT_COUNT,
2747
2752
  initialSearch,
2748
2753
  enterHint,
2754
+ linesAbovePrompt,
2749
2755
  });
2750
2756
  }
2751
2757
  catch (err) {
package/dist/index.js CHANGED
@@ -94,7 +94,7 @@ if (IS_DEV_BUILD) {
94
94
  // module on each invocation (which loaded the whole ~50-module tree before the
95
95
  // first byte of output), the registry maps a command name to a thunk that
96
96
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
97
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadSnapshot, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadHumans, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
97
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadSnapshot, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadInsights, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadHumans, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
98
98
  import { applyGlobalHelpConventions } from './lib/help.js';
99
99
  import { renderWhatsNew } from './lib/whats-new.js';
100
100
  import { getCliLaunch } from './lib/cli-entry.js';
@@ -327,6 +327,7 @@ Credentials and profiles:
327
327
  Diagnostics:
328
328
  doctor [agent[@version]] Diagnose CLI availability, sync status, and resource divergence; --check for the CI drift gate
329
329
  usage [agent] Show rate-limit and quota usage per agent
330
+ insights How you work — tools, friction, rhythm, split by Claude account
330
331
  perf Latency rollups (hooks, commands, runs) from the disposable perf warehouse
331
332
 
332
333
  Config sync:
@@ -963,6 +964,7 @@ async function registerAllEagerCommands() {
963
964
  await reg(loadFactory);
964
965
  await reg(loadUsage);
965
966
  await reg(loadCost);
967
+ await reg(loadInsights);
966
968
  await reg(loadPerf);
967
969
  await reg(loadTrends);
968
970
  await reg(loadOutput);
@@ -46,6 +46,7 @@ const REMOTE_PASSTHROUGH = {
46
46
  list: {},
47
47
  usage: {},
48
48
  cost: {},
49
+ insights: {},
49
50
  output: {},
50
51
  budget: {},
51
52
  // config / resources