@phnx-labs/agents-cli 1.21.2 → 1.21.3
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 +34 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/computer-actions.d.ts +4 -0
- package/dist/commands/computer-actions.js +34 -0
- package/dist/commands/computer.js +4 -2
- package/dist/commands/perf.d.ts +16 -0
- package/dist/commands/perf.js +11 -1
- package/dist/commands/projects.d.ts +11 -1
- package/dist/commands/projects.js +16 -2
- package/dist/commands/sessions-picker.js +17 -2
- package/dist/commands/sessions.d.ts +5 -0
- package/dist/commands/sessions.js +14 -0
- package/dist/index.js +8 -0
- package/dist/lib/browser/service.js +13 -0
- package/dist/lib/computer/dispatch.d.ts +3 -1
- package/dist/lib/computer/dispatch.js +10 -2
- package/dist/lib/event-stream.d.ts +2 -0
- package/dist/lib/event-stream.js +3 -0
- package/dist/lib/events.d.ts +3 -1
- package/dist/lib/events.js +4 -2
- package/dist/lib/git.d.ts +14 -0
- package/dist/lib/git.js +36 -0
- package/dist/lib/hooks/profile.js +1 -14
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/percentile.d.ts +12 -0
- package/dist/lib/percentile.js +24 -0
- package/dist/lib/perf/db.d.ts +1 -2
- package/dist/lib/perf/db.js +2 -14
- package/dist/lib/plugins.js +12 -1
- package/dist/lib/resources.d.ts +16 -0
- package/dist/lib/resources.js +25 -14
- package/dist/lib/routines.js +1 -14
- package/dist/lib/runner.js +8 -1
- 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 +16 -1
- package/dist/lib/session/db.js +227 -4
- package/dist/lib/session/discover.d.ts +28 -0
- package/dist/lib/session/discover.js +42 -1
- package/dist/lib/session/highlights.d.ts +24 -4
- package/dist/lib/session/highlights.js +52 -7
- package/dist/lib/session/parse.js +19 -3
- package/dist/lib/session/prompt.d.ts +19 -0
- package/dist/lib/session/prompt.js +43 -0
- package/dist/lib/session/types.d.ts +42 -0
- package/dist/lib/types.d.ts +14 -0
- package/package.json +1 -1
package/dist/lib/session/db.js
CHANGED
|
@@ -12,14 +12,18 @@ import Database from '../sqlite.js';
|
|
|
12
12
|
import { parseSession } from './parse.js';
|
|
13
13
|
import { extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
|
|
14
14
|
import { getSessionsDir, getSessionsDbPath } from '../state.js';
|
|
15
|
+
import { query as queryEvents } from '../events.js';
|
|
15
16
|
import { machineForSessionFile } from './origin-machine.js';
|
|
16
17
|
import { loadSessionActorIndex, readSessionActorRecord } from './actor-sidecar.js';
|
|
18
|
+
import { extractSkills, extractSlashCommands } from './highlights.js';
|
|
19
|
+
import { resolveResource } from '../resources.js';
|
|
20
|
+
import { discoverPlugins } from '../plugins.js';
|
|
17
21
|
const SESSIONS_DIR = getSessionsDir();
|
|
18
22
|
const DB_PATH = getSessionsDbPath();
|
|
19
23
|
/** Current schema version; bumped when migrations are added. Exported so tests
|
|
20
24
|
* assert against the constant instead of hardcoding a number that every bump
|
|
21
25
|
* then has to chase (docs/05-sessions.md calls the constant the source of truth). */
|
|
22
|
-
export const SCHEMA_VERSION =
|
|
26
|
+
export const SCHEMA_VERSION = 24;
|
|
23
27
|
/**
|
|
24
28
|
* Canonicalize a file path for use as a scan_ledger key. The same physical
|
|
25
29
|
* session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
|
|
@@ -85,7 +89,9 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
85
89
|
linear_project TEXT,
|
|
86
90
|
linear_project_url TEXT,
|
|
87
91
|
actor TEXT,
|
|
88
|
-
initiated_by TEXT
|
|
92
|
+
initiated_by TEXT,
|
|
93
|
+
used_browser INTEGER,
|
|
94
|
+
used_computer INTEGER
|
|
89
95
|
);
|
|
90
96
|
CREATE INDEX IF NOT EXISTS idx_sessions_timestamp ON sessions(timestamp DESC);
|
|
91
97
|
CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
|
|
@@ -140,6 +146,29 @@ CREATE TABLE IF NOT EXISTS dir_ledger (
|
|
|
140
146
|
entry_count INTEGER NOT NULL,
|
|
141
147
|
scanned_at INTEGER NOT NULL
|
|
142
148
|
);
|
|
149
|
+
|
|
150
|
+
-- Skill/slash-command usage per session (#12), computed from a session's
|
|
151
|
+
-- parsed transcript (extractSkills / extractSlashCommands, session/highlights.ts)
|
|
152
|
+
-- and joined at write time against the currently-installed resource/plugin
|
|
153
|
+
-- (resolveResource / discoverPlugins) for provenance — repo_root + snapshot_sha
|
|
154
|
+
-- answer "which repo, which commit installed this skill/command", plugin/source
|
|
155
|
+
-- answer "which plugin, which DotAgents layer". A resource renamed or uninstalled
|
|
156
|
+
-- since the session ran leaves plugin/source/repo_root/snapshot_sha NULL rather
|
|
157
|
+
-- than a stale guess. One row per (session, kind, name); count is how many
|
|
158
|
+
-- times that skill/command fired in the session.
|
|
159
|
+
CREATE TABLE IF NOT EXISTS session_resource_usage (
|
|
160
|
+
session_id TEXT NOT NULL,
|
|
161
|
+
kind TEXT NOT NULL,
|
|
162
|
+
name TEXT NOT NULL,
|
|
163
|
+
plugin TEXT,
|
|
164
|
+
source TEXT,
|
|
165
|
+
repo_root TEXT,
|
|
166
|
+
snapshot_sha TEXT,
|
|
167
|
+
count INTEGER NOT NULL DEFAULT 1,
|
|
168
|
+
PRIMARY KEY (session_id, kind, name)
|
|
169
|
+
);
|
|
170
|
+
CREATE INDEX IF NOT EXISTS idx_session_resource_usage_kind_name ON session_resource_usage(kind, name);
|
|
171
|
+
CREATE INDEX IF NOT EXISTS idx_session_resource_usage_plugin ON session_resource_usage(plugin);
|
|
143
172
|
`;
|
|
144
173
|
let dbInstance = null;
|
|
145
174
|
/**
|
|
@@ -401,11 +430,56 @@ function migrateSchema(db, fromVersion) {
|
|
|
401
430
|
db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
|
|
402
431
|
}
|
|
403
432
|
if (fromVersion < 22) {
|
|
433
|
+
// v21 → v22 (main): persist the transcript's tool-call count.
|
|
404
434
|
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
405
435
|
if (!cols.some(c => c.name === 'tool_call_count'))
|
|
406
436
|
db.exec(`ALTER TABLE sessions ADD COLUMN tool_call_count INTEGER`);
|
|
407
437
|
db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
|
|
408
438
|
}
|
|
439
|
+
if (fromVersion < 23) {
|
|
440
|
+
// v22 → v23: persist usedBrowser/usedComputer (#11) so the sessions picker
|
|
441
|
+
// preview can trust a positive detection instead of re-deriving it from a
|
|
442
|
+
// transcript regex on every render. Computed from a sessionId-scoped read
|
|
443
|
+
// of the events log (see detectToolUsage), not from the parsed transcript,
|
|
444
|
+
// so no ledger wipe is needed here — a rescan re-derives them regardless.
|
|
445
|
+
//
|
|
446
|
+
// Deliberately NO DEFAULT (NULL on ALTER, for every pre-existing row):
|
|
447
|
+
// NULL means "not yet computed by this scanner" (a legacy row), distinct
|
|
448
|
+
// from a real, computed 0/false. Collapsing that into a DEFAULT 0 would
|
|
449
|
+
// make every un-rescanned row look like a definite "never used
|
|
450
|
+
// browser/computer" and permanently blind the picker's fallback path for
|
|
451
|
+
// any row that isn't rescanned before it's next viewed.
|
|
452
|
+
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
453
|
+
if (!cols.some(c => c.name === 'used_browser'))
|
|
454
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN used_browser INTEGER`);
|
|
455
|
+
if (!cols.some(c => c.name === 'used_computer'))
|
|
456
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN used_computer INTEGER`);
|
|
457
|
+
}
|
|
458
|
+
if (fromVersion < 24) {
|
|
459
|
+
// v23 → v24: session_resource_usage (#12) — skill/slash-command usage per
|
|
460
|
+
// session, joined against the currently-installed resource/plugin for
|
|
461
|
+
// provenance (repo_root/snapshot_sha/plugin/source). A brand-new table
|
|
462
|
+
// needs only CREATE TABLE IF NOT EXISTS, already present in SCHEMA for a
|
|
463
|
+
// fresh DB — this repeats it for an existing DB whose SCHEMA exec ran
|
|
464
|
+
// before the table definition was added. No ledger wipe: this table is
|
|
465
|
+
// populated by writeResourceUsage() at upsert time, independent of the
|
|
466
|
+
// scan ledgers.
|
|
467
|
+
db.exec(`
|
|
468
|
+
CREATE TABLE IF NOT EXISTS session_resource_usage (
|
|
469
|
+
session_id TEXT NOT NULL,
|
|
470
|
+
kind TEXT NOT NULL,
|
|
471
|
+
name TEXT NOT NULL,
|
|
472
|
+
plugin TEXT,
|
|
473
|
+
source TEXT,
|
|
474
|
+
repo_root TEXT,
|
|
475
|
+
snapshot_sha TEXT,
|
|
476
|
+
count INTEGER NOT NULL DEFAULT 1,
|
|
477
|
+
PRIMARY KEY (session_id, kind, name)
|
|
478
|
+
);
|
|
479
|
+
CREATE INDEX IF NOT EXISTS idx_session_resource_usage_kind_name ON session_resource_usage(kind, name);
|
|
480
|
+
CREATE INDEX IF NOT EXISTS idx_session_resource_usage_plugin ON session_resource_usage(plugin);
|
|
481
|
+
`);
|
|
482
|
+
}
|
|
409
483
|
}
|
|
410
484
|
/** Open (or return the cached) sessions database, applying migrations as needed. */
|
|
411
485
|
export function getDB() {
|
|
@@ -769,7 +843,7 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
769
843
|
file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
|
|
770
844
|
pr_url, pr_number, worktree_slug, ticket_id, spawned_team, plan, todos,
|
|
771
845
|
recent_directories_touched, linear_project, linear_project_url, machine,
|
|
772
|
-
actor, initiated_by
|
|
846
|
+
actor, initiated_by, used_browser, used_computer
|
|
773
847
|
) VALUES (
|
|
774
848
|
@id, @short_id, @agent, @origin, @routine_name, @routine_run_id,
|
|
775
849
|
@version, @account, @timestamp, @last_activity,
|
|
@@ -778,7 +852,7 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
778
852
|
@file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
|
|
779
853
|
@pr_url, @pr_number, @worktree_slug, @ticket_id, @spawned_team, @plan, @todos,
|
|
780
854
|
@recent_directories_touched, @linear_project, @linear_project_url, @machine,
|
|
781
|
-
@actor, @initiated_by
|
|
855
|
+
@actor, @initiated_by, @used_browser, @used_computer
|
|
782
856
|
)
|
|
783
857
|
ON CONFLICT(id) DO UPDATE SET
|
|
784
858
|
short_id = excluded.short_id,
|
|
@@ -823,6 +897,8 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
823
897
|
plan = excluded.plan,
|
|
824
898
|
todos = excluded.todos,
|
|
825
899
|
recent_directories_touched = excluded.recent_directories_touched,
|
|
900
|
+
used_browser = excluded.used_browser,
|
|
901
|
+
used_computer = excluded.used_computer,
|
|
826
902
|
linear_project = CASE
|
|
827
903
|
WHEN excluded.ticket_id IS NOT sessions.ticket_id THEN excluded.linear_project
|
|
828
904
|
ELSE COALESCE(excluded.linear_project, sessions.linear_project)
|
|
@@ -841,11 +917,124 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
841
917
|
actor = COALESCE(sessions.actor, excluded.actor),
|
|
842
918
|
initiated_by = COALESCE(sessions.initiated_by, excluded.initiated_by)
|
|
843
919
|
`);
|
|
920
|
+
/**
|
|
921
|
+
* Did this session emit at least one browser/computer-automation event?
|
|
922
|
+
* A scoped, sessionId-filtered read of the events log — NOT a re-scan of the
|
|
923
|
+
* (potentially huge) transcript — since browser.navigate / browser.screenshot
|
|
924
|
+
* / computer.action are recorded there via events.ts's emit(), keyed by the
|
|
925
|
+
* same session id that is this row's `meta.id` (the provenance floor stamps
|
|
926
|
+
* AGENT_SESSION_ID/AGENTS_SESSION_ID on every such event at emit time).
|
|
927
|
+
*
|
|
928
|
+
* Called independently of {@link enrichCachedSessionMeta} (which SKIPS
|
|
929
|
+
* claude/codex entries in the batch path because their caller already parsed
|
|
930
|
+
* the transcript for todos/recentDirectoriesTouched) — this never touches the
|
|
931
|
+
* transcript, so it must run for every agent, every time.
|
|
932
|
+
*/
|
|
933
|
+
function detectToolUsage(sessionId) {
|
|
934
|
+
const usedBrowser = queryEvents({ sessionId, eventTypes: ['browser.navigate', 'browser.screenshot'], limit: 1 }).length > 0;
|
|
935
|
+
const usedComputer = queryEvents({ sessionId, eventTypes: ['computer.action'], limit: 1 }).length > 0;
|
|
936
|
+
return { usedBrowser, usedComputer };
|
|
937
|
+
}
|
|
938
|
+
const deleteResourceUsageStmt = (db) => db.prepare(`DELETE FROM session_resource_usage WHERE session_id = ?`);
|
|
939
|
+
const insertResourceUsageStmt = (db) => db.prepare(`
|
|
940
|
+
INSERT INTO session_resource_usage (session_id, kind, name, plugin, source, repo_root, snapshot_sha, count)
|
|
941
|
+
VALUES (@session_id, @kind, @name, @plugin, @source, @repo_root, @snapshot_sha, @count)
|
|
942
|
+
`);
|
|
943
|
+
/**
|
|
944
|
+
* Resolve a skill/slash-command's provenance for `session_resource_usage`
|
|
945
|
+
* (#12). A flat (non-namespaced) resource resolves via resolveResource()'s
|
|
946
|
+
* project/user/system/extra-repo scan. A namespaced one (`plugin:name`, e.g.
|
|
947
|
+
* `rush:design` — the shape both a plugin skill's `args.skill` and a plugin
|
|
948
|
+
* command's SessionEvent.slashCommand carry) is plugin-owned and lives under
|
|
949
|
+
* the plugin's own directory, invisible to resolveResource()'s flat scan —
|
|
950
|
+
* resolved instead against the already-discovered plugin list. Neither found
|
|
951
|
+
* (renamed or uninstalled since the session ran) returns all-undefined
|
|
952
|
+
* rather than a stale guess.
|
|
953
|
+
*/
|
|
954
|
+
function resolveResourceProvenance(kind, name, cwd, plugins) {
|
|
955
|
+
const listOf = (p) => (kind === 'skills' ? p.skills : p.commands);
|
|
956
|
+
const colonIdx = name.indexOf(':');
|
|
957
|
+
if (colonIdx > 0) {
|
|
958
|
+
const pluginName = name.slice(0, colonIdx);
|
|
959
|
+
const shortName = name.slice(colonIdx + 1);
|
|
960
|
+
const plugin = plugins.find((p) => p.name === pluginName && listOf(p).includes(shortName));
|
|
961
|
+
if (!plugin)
|
|
962
|
+
return {};
|
|
963
|
+
return { plugin: plugin.name, source: plugin.marketplace, repoRoot: plugin.repoRoot, snapshotSha: plugin.snapshotSha };
|
|
964
|
+
}
|
|
965
|
+
const resolved = resolveResource(kind, name, cwd);
|
|
966
|
+
if (resolved)
|
|
967
|
+
return { source: resolved.source, repoRoot: resolved.repoRoot, snapshotSha: resolved.snapshotSha };
|
|
968
|
+
const plugin = plugins.find((p) => listOf(p).includes(name));
|
|
969
|
+
if (!plugin)
|
|
970
|
+
return {};
|
|
971
|
+
return { plugin: plugin.name, source: plugin.marketplace, repoRoot: plugin.repoRoot, snapshotSha: plugin.snapshotSha };
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Persist already-computed skill/slash-command tallies for a session (#12)
|
|
975
|
+
* into `session_resource_usage`, replacing any prior rows for it (a rescan's
|
|
976
|
+
* usage supersedes the old — same replace-on-upsert shape as the FTS text
|
|
977
|
+
* below). `discoverPlugins()` (real I/O: manifest + directory reads) only
|
|
978
|
+
* runs when there is actually a skill/command to resolve, so a session with
|
|
979
|
+
* neither pays nothing beyond the DELETE.
|
|
980
|
+
*
|
|
981
|
+
* Split from {@link writeResourceUsage} so a caller that already has the
|
|
982
|
+
* tallies (claude/codex's incremental accumulator — see
|
|
983
|
+
* ClaudeParseState.skillEvents/slashCommandEvents, threaded onto
|
|
984
|
+
* SessionMeta.skillsUsed/slashCommandsUsed) can write without re-parsing the
|
|
985
|
+
* transcript to re-derive them.
|
|
986
|
+
*
|
|
987
|
+
* Deliberately NOT wrapped in its own `db.transaction()`: better-sqlite3
|
|
988
|
+
* (this repo's wrapper included) does not support nested transactions, and
|
|
989
|
+
* `upsertSessionsBatch` calls this from INSIDE its own outer transaction. A
|
|
990
|
+
* standalone caller (enrichCachedSessionMeta, outside any transaction) still
|
|
991
|
+
* gets each statement committed individually — a crash between the DELETE
|
|
992
|
+
* and an INSERT self-heals on the next rescan, the same risk profile as any
|
|
993
|
+
* other un-batched write in this file.
|
|
994
|
+
*/
|
|
995
|
+
function writeResourceUsageFromTallies(sessionId, skills, commands, cwd) {
|
|
996
|
+
const db = getDB();
|
|
997
|
+
const del = deleteResourceUsageStmt(db);
|
|
998
|
+
const ins = insertResourceUsageStmt(db);
|
|
999
|
+
del.run(sessionId);
|
|
1000
|
+
if (skills.length === 0 && commands.length === 0)
|
|
1001
|
+
return;
|
|
1002
|
+
const plugins = discoverPlugins({ cwd });
|
|
1003
|
+
for (const { name, count } of skills) {
|
|
1004
|
+
const prov = resolveResourceProvenance('skills', name, cwd, plugins);
|
|
1005
|
+
ins.run({
|
|
1006
|
+
session_id: sessionId, kind: 'skill', name, count,
|
|
1007
|
+
plugin: prov.plugin ?? null, source: prov.source ?? null,
|
|
1008
|
+
repo_root: prov.repoRoot ?? null, snapshot_sha: prov.snapshotSha ?? null,
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
for (const { name, count } of commands) {
|
|
1012
|
+
const bare = name.replace(/^\//, '');
|
|
1013
|
+
const prov = resolveResourceProvenance('commands', bare, cwd, plugins);
|
|
1014
|
+
ins.run({
|
|
1015
|
+
session_id: sessionId, kind: 'command', name: bare, count,
|
|
1016
|
+
plugin: prov.plugin ?? null, source: prov.source ?? null,
|
|
1017
|
+
repo_root: prov.repoRoot ?? null, snapshot_sha: prov.snapshotSha ?? null,
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Persist skill/slash-command usage for a session (#12) by deriving the
|
|
1023
|
+
* tallies from a full parsed transcript. Used by {@link enrichCachedSessionMeta}
|
|
1024
|
+
* (every `upsertSession()` call, and `upsertSessionsBatch` for every harness
|
|
1025
|
+
* EXCEPT claude/codex, which pre-compute skillsUsed/slashCommandsUsed via
|
|
1026
|
+
* their incremental accumulator instead — see writeResourceUsageFromTallies
|
|
1027
|
+
* and the call site in upsertSessionsBatch).
|
|
1028
|
+
*/
|
|
1029
|
+
function writeResourceUsage(sessionId, events, cwd) {
|
|
1030
|
+
writeResourceUsageFromTallies(sessionId, extractSkills(events), extractSlashCommands(events), cwd);
|
|
1031
|
+
}
|
|
844
1032
|
function enrichCachedSessionMeta(meta) {
|
|
845
1033
|
if (!meta.filePath)
|
|
846
1034
|
return meta;
|
|
847
1035
|
try {
|
|
848
1036
|
const events = parseSession(meta.filePath, meta.agent);
|
|
1037
|
+
writeResourceUsage(meta.id, events, meta.cwd);
|
|
849
1038
|
return {
|
|
850
1039
|
...meta,
|
|
851
1040
|
todos: extractTodoProgressFromEvents(events),
|
|
@@ -903,6 +1092,7 @@ export function upsertSession(meta, content, scan) {
|
|
|
903
1092
|
// row AND backfills one indexed null-first (before its sidecar existed), while a
|
|
904
1093
|
// rescan carrying no actor still keeps the stored owner.
|
|
905
1094
|
const actorRec = meta.actor ? undefined : readSessionActorRecord(meta.id);
|
|
1095
|
+
const toolUsage = detectToolUsage(meta.id);
|
|
906
1096
|
const db = getDB();
|
|
907
1097
|
const { upsert, delText, insText, readLabel } = stmts(db);
|
|
908
1098
|
const row = {
|
|
@@ -946,6 +1136,8 @@ export function upsertSession(meta, content, scan) {
|
|
|
946
1136
|
machine: resolveMachine(meta),
|
|
947
1137
|
actor: meta.actor ?? actorRec?.actor ?? null,
|
|
948
1138
|
initiated_by: meta.initiatedBy ?? actorRec?.initiatedBy ?? null,
|
|
1139
|
+
used_browser: toolUsage.usedBrowser ? 1 : 0,
|
|
1140
|
+
used_computer: toolUsage.usedComputer ? 1 : 0,
|
|
949
1141
|
};
|
|
950
1142
|
const txn = db.transaction(() => {
|
|
951
1143
|
upsert.run(row);
|
|
@@ -1026,6 +1218,15 @@ export function upsertSessionsBatch(entries) {
|
|
|
1026
1218
|
// back when the error escapes `fn`, so catching + skipping here leaves the txn valid
|
|
1027
1219
|
// and committable. We deliberately do NOT stamp the ledger for a skipped row, so the
|
|
1028
1220
|
// next scan re-tries it (self-healing once the underlying parser is fixed).
|
|
1221
|
+
const toolUsage = detectToolUsage(meta.id);
|
|
1222
|
+
// claude/codex skip enrichCachedSessionMeta above (preserving their
|
|
1223
|
+
// resumable-parse optimization) — write their pre-computed
|
|
1224
|
+
// skillsUsed/slashCommandsUsed (folded incrementally by discover.ts's
|
|
1225
|
+
// accumulator) here instead. Every other harness already got this
|
|
1226
|
+
// write from enrichCachedSessionMeta() in the .map() above.
|
|
1227
|
+
if (meta.agent === 'claude' || meta.agent === 'codex') {
|
|
1228
|
+
writeResourceUsageFromTallies(meta.id, meta.skillsUsed ?? [], meta.slashCommandsUsed ?? [], meta.cwd);
|
|
1229
|
+
}
|
|
1029
1230
|
try {
|
|
1030
1231
|
upsert.run({
|
|
1031
1232
|
id: meta.id,
|
|
@@ -1068,6 +1269,8 @@ export function upsertSessionsBatch(entries) {
|
|
|
1068
1269
|
machine: resolveMachine(meta),
|
|
1069
1270
|
actor: meta.actor ?? actorIndex.get(meta.id)?.actor ?? null,
|
|
1070
1271
|
initiated_by: meta.initiatedBy ?? actorIndex.get(meta.id)?.initiatedBy ?? null,
|
|
1272
|
+
used_browser: toolUsage.usedBrowser ? 1 : 0,
|
|
1273
|
+
used_computer: toolUsage.usedComputer ? 1 : 0,
|
|
1071
1274
|
});
|
|
1072
1275
|
delText.run(meta.id);
|
|
1073
1276
|
insText.run(meta.id,
|
|
@@ -1260,6 +1463,11 @@ function rowToMeta(row) {
|
|
|
1260
1463
|
// Narrow the free-text column to the known kinds; an unexpected value maps
|
|
1261
1464
|
// to undefined rather than being asserted as a valid kind.
|
|
1262
1465
|
initiatedBy: row.initiated_by === 'human' || row.initiated_by === 'agent' ? row.initiated_by : undefined,
|
|
1466
|
+
// NULL = never computed by this scanner (legacy row) — leave undefined so
|
|
1467
|
+
// the sessions picker knows to fall back to the transcript-regex detection
|
|
1468
|
+
// instead of trusting a false "never used browser/computer".
|
|
1469
|
+
usedBrowser: row.used_browser === null ? undefined : row.used_browser === 1,
|
|
1470
|
+
usedComputer: row.used_computer === null ? undefined : row.used_computer === 1,
|
|
1263
1471
|
};
|
|
1264
1472
|
}
|
|
1265
1473
|
function parseJsonColumn(value) {
|
|
@@ -1392,6 +1600,21 @@ function buildSessionWhere(options) {
|
|
|
1392
1600
|
if (options.onlyTeamOrigin) {
|
|
1393
1601
|
where.push('IFNULL(is_team_origin, 0) = 1');
|
|
1394
1602
|
}
|
|
1603
|
+
// #12: join against session_resource_usage. A subquery IN, not a real JOIN
|
|
1604
|
+
// on the base SELECT, keeps `SELECT * FROM sessions` untouched for every
|
|
1605
|
+
// other caller of buildSessionWhere() (countSessions, the usage rollup, …)
|
|
1606
|
+
// that never wants a skill/plugin filter.
|
|
1607
|
+
if (options.skill) {
|
|
1608
|
+
where.push(`id IN (
|
|
1609
|
+
SELECT session_id FROM session_resource_usage
|
|
1610
|
+
WHERE kind = 'skill' AND (name = ? COLLATE NOCASE OR name LIKE ? COLLATE NOCASE)
|
|
1611
|
+
)`);
|
|
1612
|
+
params.push(options.skill, `%:${options.skill}`);
|
|
1613
|
+
}
|
|
1614
|
+
if (options.plugin) {
|
|
1615
|
+
where.push(`id IN (SELECT session_id FROM session_resource_usage WHERE plugin = ? COLLATE NOCASE)`);
|
|
1616
|
+
params.push(options.plugin);
|
|
1617
|
+
}
|
|
1395
1618
|
const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
|
1396
1619
|
return { clause, params };
|
|
1397
1620
|
}
|
|
@@ -41,6 +41,10 @@ export interface DiscoverOptions {
|
|
|
41
41
|
sortBy?: 'timestamp' | 'cost' | 'duration';
|
|
42
42
|
/** Called as each agent makes parsing progress. Totals count only files that need re-parsing (cache misses). */
|
|
43
43
|
onProgress?: (progress: ScanProgress) => void;
|
|
44
|
+
/** Only sessions that invoked this skill (#12) — see QueryOptions.skill in session/db.ts. */
|
|
45
|
+
skill?: string;
|
|
46
|
+
/** Only sessions that used a skill/command owned by this plugin (#12) — see QueryOptions.plugin. */
|
|
47
|
+
plugin?: string;
|
|
44
48
|
}
|
|
45
49
|
/** Progress report emitted during incremental scanning. */
|
|
46
50
|
export interface ScanProgress {
|
|
@@ -87,6 +91,16 @@ interface ClaudeSessionScan {
|
|
|
87
91
|
plan?: string;
|
|
88
92
|
todos?: TodoProgress;
|
|
89
93
|
recentDirectoriesTouched?: string[];
|
|
94
|
+
/** Skills invoked (#12) — see SessionMeta.skillsUsed. */
|
|
95
|
+
skillsUsed?: Array<{
|
|
96
|
+
name: string;
|
|
97
|
+
count: number;
|
|
98
|
+
}>;
|
|
99
|
+
/** Slash commands invoked (#12) — see SessionMeta.slashCommandsUsed. */
|
|
100
|
+
slashCommandsUsed?: Array<{
|
|
101
|
+
name: string;
|
|
102
|
+
count: number;
|
|
103
|
+
}>;
|
|
90
104
|
}
|
|
91
105
|
/** Lightweight metadata extracted from a Codex JSONL file during incremental scan. */
|
|
92
106
|
interface CodexSessionScan {
|
|
@@ -394,6 +408,11 @@ export interface ClaudeParseState {
|
|
|
394
408
|
plan?: string;
|
|
395
409
|
checklistEvents: SessionEvent[];
|
|
396
410
|
recentDirectoriesTouched: string[];
|
|
411
|
+
/** Skill-invocation tool_use events, held for extractSkills() at finalize (#12). */
|
|
412
|
+
skillEvents: SessionEvent[];
|
|
413
|
+
/** Slash-command events (user-typed <command-name> wrapper OR a SlashCommand
|
|
414
|
+
* tool_use), held for extractSlashCommands() at finalize (#12). */
|
|
415
|
+
slashCommandEvents: SessionEvent[];
|
|
397
416
|
}
|
|
398
417
|
/** Zero-value accumulator for a fresh (from-byte-0) Claude parse. */
|
|
399
418
|
export declare function initClaudeParseState(): ClaudeParseState;
|
|
@@ -456,6 +475,9 @@ export interface ClaudeParserState {
|
|
|
456
475
|
contentText?: string;
|
|
457
476
|
checklistEvents: SessionEvent[];
|
|
458
477
|
recentDirectoriesTouched: string[];
|
|
478
|
+
/** #12: see ClaudeParseState.skillEvents/slashCommandEvents. */
|
|
479
|
+
skillEvents: SessionEvent[];
|
|
480
|
+
slashCommandEvents: SessionEvent[];
|
|
459
481
|
}
|
|
460
482
|
/**
|
|
461
483
|
* Snapshot a live {@link ClaudeParseState} into its serializable form at
|
|
@@ -531,6 +553,12 @@ export interface CodexParseState {
|
|
|
531
553
|
spawnedTeam?: string;
|
|
532
554
|
checklistEvents: SessionEvent[];
|
|
533
555
|
recentDirectoriesTouched: string[];
|
|
556
|
+
/** #12: see ClaudeParseState.skillEvents. Empty in practice today — no
|
|
557
|
+
* verified Codex skill-invocation tool name — but wired for parity so a
|
|
558
|
+
* future confirmed tool name (or a literal 'SlashCommand' function_call)
|
|
559
|
+
* is picked up with no further plumbing. */
|
|
560
|
+
skillEvents: SessionEvent[];
|
|
561
|
+
slashCommandEvents: SessionEvent[];
|
|
534
562
|
}
|
|
535
563
|
/** Zero-value accumulator for a fresh (from-byte-0) Codex parse. */
|
|
536
564
|
export declare function initCodexParseState(): CodexParseState;
|
|
@@ -23,7 +23,8 @@ 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 { extractSessionTopic } from './prompt.js';
|
|
26
|
+
import { extractSessionTopic, extractSlashCommandName, extractSlashCommandFromToolInput } from './prompt.js';
|
|
27
|
+
import { isSkillInvocation, extractSkills, extractSlashCommands } from './highlights.js';
|
|
27
28
|
import { parseAntigravity, parseCursor } from './parse.js';
|
|
28
29
|
import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket, extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
|
|
29
30
|
import { costOfUsage } from '../pricing/index.js';
|
|
@@ -310,6 +311,8 @@ function buildQueryOptions(options, agents, opts) {
|
|
|
310
311
|
onlyTeamOrigin: options?.onlyTeamOrigin,
|
|
311
312
|
origin: options?.origin,
|
|
312
313
|
sortBy: options?.sortBy,
|
|
314
|
+
skill: options?.skill,
|
|
315
|
+
plugin: options?.plugin,
|
|
313
316
|
};
|
|
314
317
|
}
|
|
315
318
|
/**
|
|
@@ -1030,6 +1033,8 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account,
|
|
|
1030
1033
|
plan: scan.plan,
|
|
1031
1034
|
todos: scan.todos,
|
|
1032
1035
|
recentDirectoriesTouched: scan.recentDirectoriesTouched,
|
|
1036
|
+
skillsUsed: scan.skillsUsed,
|
|
1037
|
+
slashCommandsUsed: scan.slashCommandsUsed,
|
|
1033
1038
|
};
|
|
1034
1039
|
}
|
|
1035
1040
|
else {
|
|
@@ -1061,6 +1066,8 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account,
|
|
|
1061
1066
|
plan: scan.plan,
|
|
1062
1067
|
todos: scan.todos,
|
|
1063
1068
|
recentDirectoriesTouched: scan.recentDirectoriesTouched,
|
|
1069
|
+
skillsUsed: scan.skillsUsed,
|
|
1070
|
+
slashCommandsUsed: scan.slashCommandsUsed,
|
|
1064
1071
|
};
|
|
1065
1072
|
}
|
|
1066
1073
|
return {
|
|
@@ -2446,6 +2453,8 @@ export function initClaudeParseState() {
|
|
|
2446
2453
|
plan: undefined,
|
|
2447
2454
|
checklistEvents: [],
|
|
2448
2455
|
recentDirectoriesTouched: [],
|
|
2456
|
+
skillEvents: [],
|
|
2457
|
+
slashCommandEvents: [],
|
|
2449
2458
|
};
|
|
2450
2459
|
}
|
|
2451
2460
|
const CHECKLIST_TOOLS = new Set(['TodoWrite', 'todo_write', 'update_plan', 'TaskCreate', 'TaskUpdate']);
|
|
@@ -2453,6 +2462,16 @@ const DIRECTORY_TOOLS = new Set(['Edit', 'Write', 'edit_file', 'write_file', 'cr
|
|
|
2453
2462
|
function foldDerivedToolState(state, event) {
|
|
2454
2463
|
if (CHECKLIST_TOOLS.has(event.tool ?? ''))
|
|
2455
2464
|
state.checklistEvents.push(event);
|
|
2465
|
+
// #12: skill/slash-command usage, held here instead of re-parsed later — the
|
|
2466
|
+
// same reason checklistEvents is folded incrementally rather than recomputed
|
|
2467
|
+
// from a full re-parse (see session/db.ts's writeResourceUsage doc comment).
|
|
2468
|
+
if (isSkillInvocation(event))
|
|
2469
|
+
state.skillEvents.push(event);
|
|
2470
|
+
if (event.tool === 'SlashCommand') {
|
|
2471
|
+
const slashCommand = extractSlashCommandFromToolInput(event.args);
|
|
2472
|
+
if (slashCommand)
|
|
2473
|
+
state.slashCommandEvents.push({ ...event, slashCommand });
|
|
2474
|
+
}
|
|
2456
2475
|
if (!DIRECTORY_TOOLS.has(event.tool ?? ''))
|
|
2457
2476
|
return;
|
|
2458
2477
|
const next = extractRecentDirectoriesTouched([event], state.cwd);
|
|
@@ -2582,6 +2601,15 @@ export function applyClaudeLine(state, parsed) {
|
|
|
2582
2601
|
state.userTexts.push(text);
|
|
2583
2602
|
if (!state.topic)
|
|
2584
2603
|
state.topic = extractSessionTopic(text);
|
|
2604
|
+
// #12: the USER typing a slash command — Claude injects a <command-name>
|
|
2605
|
+
// wrapper as the message content (extractClaudeUserText returns it
|
|
2606
|
+
// un-stripped; isLocalCommandMessage only filters bash-echo wrappers).
|
|
2607
|
+
const slashCommand = extractSlashCommandName(text);
|
|
2608
|
+
if (slashCommand) {
|
|
2609
|
+
state.slashCommandEvents.push({
|
|
2610
|
+
type: 'message', agent: 'claude', timestamp: parsed.timestamp || '', role: 'user', content: text, slashCommand,
|
|
2611
|
+
});
|
|
2612
|
+
}
|
|
2585
2613
|
}
|
|
2586
2614
|
return;
|
|
2587
2615
|
}
|
|
@@ -2662,6 +2690,8 @@ export function finalizeClaudeScan(state) {
|
|
|
2662
2690
|
plan: state.plan,
|
|
2663
2691
|
todos: extractTodoProgressFromEvents(state.checklistEvents),
|
|
2664
2692
|
recentDirectoriesTouched: state.recentDirectoriesTouched.length ? state.recentDirectoriesTouched : undefined,
|
|
2693
|
+
skillsUsed: state.skillEvents.length ? extractSkills(state.skillEvents) : undefined,
|
|
2694
|
+
slashCommandsUsed: state.slashCommandEvents.length ? extractSlashCommands(state.slashCommandEvents) : undefined,
|
|
2665
2695
|
};
|
|
2666
2696
|
}
|
|
2667
2697
|
/** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
|
|
@@ -2740,6 +2770,8 @@ export function serializeClaudeParserState(state, offset) {
|
|
|
2740
2770
|
contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
|
|
2741
2771
|
checklistEvents: state.checklistEvents,
|
|
2742
2772
|
recentDirectoriesTouched: state.recentDirectoriesTouched,
|
|
2773
|
+
skillEvents: state.skillEvents,
|
|
2774
|
+
slashCommandEvents: state.slashCommandEvents,
|
|
2743
2775
|
};
|
|
2744
2776
|
}
|
|
2745
2777
|
/**
|
|
@@ -2796,6 +2828,8 @@ export function hydrateClaudeParseState(prior) {
|
|
|
2796
2828
|
plan: prior.plan,
|
|
2797
2829
|
checklistEvents: prior.checklistEvents ?? [],
|
|
2798
2830
|
recentDirectoriesTouched: prior.recentDirectoriesTouched ?? [],
|
|
2831
|
+
skillEvents: prior.skillEvents ?? [],
|
|
2832
|
+
slashCommandEvents: prior.slashCommandEvents ?? [],
|
|
2799
2833
|
};
|
|
2800
2834
|
}
|
|
2801
2835
|
/**
|
|
@@ -2997,6 +3031,8 @@ export function initCodexParseState() {
|
|
|
2997
3031
|
spawnedTeam: undefined,
|
|
2998
3032
|
checklistEvents: [],
|
|
2999
3033
|
recentDirectoriesTouched: [],
|
|
3034
|
+
skillEvents: [],
|
|
3035
|
+
slashCommandEvents: [],
|
|
3000
3036
|
};
|
|
3001
3037
|
}
|
|
3002
3038
|
/**
|
|
@@ -3244,6 +3280,11 @@ export function hydrateCodexParseState(prior) {
|
|
|
3244
3280
|
spawnedTeam: prior.spawnedTeam,
|
|
3245
3281
|
checklistEvents: prior.checklistEvents ?? [],
|
|
3246
3282
|
recentDirectoriesTouched: prior.recentDirectoriesTouched ?? [],
|
|
3283
|
+
// Not persisted in CodexParserState (always empty for Codex today — see
|
|
3284
|
+
// CodexParseState.skillEvents) — a resume starts fresh rather than
|
|
3285
|
+
// round-tripping an always-empty array through the continuation blob.
|
|
3286
|
+
skillEvents: [],
|
|
3287
|
+
slashCommandEvents: [],
|
|
3247
3288
|
};
|
|
3248
3289
|
}
|
|
3249
3290
|
/**
|
|
@@ -17,12 +17,32 @@ export interface SkillUse {
|
|
|
17
17
|
count: number;
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* True for a tool_use event that is that harness's skill-invocation call (see
|
|
21
|
+
* {@link SKILL_TOOL_NAME_BY_AGENT}). Exported so an INCREMENTAL parser
|
|
22
|
+
* (discover.ts's ClaudeParseState/foldDerivedToolState) can select matching
|
|
23
|
+
* events into a small held array as it streams, then run {@link extractSkills}
|
|
24
|
+
* over just that subset at finalize — without re-parsing the whole transcript.
|
|
25
|
+
*/
|
|
26
|
+
export declare function isSkillInvocation(e: SessionEvent): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Skills invoked during the session, from that harness's skill-invocation
|
|
29
|
+
* tool calls (see {@link SKILL_TOOL_NAME_BY_AGENT}). Carries the skill id in
|
|
30
|
+
* `args.skill` (or `args.name`). Sorted by count desc, then name.
|
|
24
31
|
*/
|
|
25
32
|
export declare function extractSkills(events: SessionEvent[]): SkillUse[];
|
|
33
|
+
export interface SlashCommandUse {
|
|
34
|
+
/** WITH the leading slash, e.g. `/recap`, `/code:commit` — matches SessionEvent.slashCommand's shape. */
|
|
35
|
+
name: string;
|
|
36
|
+
count: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Slash commands invoked during the session — either the user typing one
|
|
40
|
+
* (Claude's `<command-name>` wrapper) or the model invoking one via the
|
|
41
|
+
* `SlashCommand` tool (`SessionEvent.slashCommand`, populated by
|
|
42
|
+
* `parseClaudeContent` for both sources — see session/prompt.ts). Sorted by
|
|
43
|
+
* count desc, then name.
|
|
44
|
+
*/
|
|
45
|
+
export declare function extractSlashCommands(events: SessionEvent[]): SlashCommandUse[];
|
|
26
46
|
export interface HookUse {
|
|
27
47
|
/** Hook name as configured, e.g. `SessionStart:startup`. */
|
|
28
48
|
name: string;
|
|
@@ -15,17 +15,44 @@ import os from 'node:os';
|
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { isNoisePath } from './digest.js';
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* The tool name a harness uses to invoke a skill, keyed by `SessionAgentId` —
|
|
19
|
+
* a registry, not a single hardcoded string, so a harness whose transcript
|
|
20
|
+
* names the tool differently is one table row away from support instead of a
|
|
21
|
+
* near-identical `if` arm (see CLAUDE.md's registry-over-if-chain convention).
|
|
22
|
+
*
|
|
23
|
+
* Verified entries only: Claude and Kimi both empirically name the tool
|
|
24
|
+
* `Skill` (a plugin-provided skill rides the same tool). No other harness has
|
|
25
|
+
* a confirmed skill-invocation tool name in this codebase's fixtures — an
|
|
26
|
+
* absent entry means "we don't know yet", not "this harness has no skills";
|
|
27
|
+
* extractSkills yields `[]` for it rather than guessing a name and silently
|
|
28
|
+
* mismatching (or worse, matching a coincidental tool with the same name).
|
|
29
|
+
*/
|
|
30
|
+
const SKILL_TOOL_NAME_BY_AGENT = {
|
|
31
|
+
claude: 'Skill',
|
|
32
|
+
kimi: 'Skill',
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* True for a tool_use event that is that harness's skill-invocation call (see
|
|
36
|
+
* {@link SKILL_TOOL_NAME_BY_AGENT}). Exported so an INCREMENTAL parser
|
|
37
|
+
* (discover.ts's ClaudeParseState/foldDerivedToolState) can select matching
|
|
38
|
+
* events into a small held array as it streams, then run {@link extractSkills}
|
|
39
|
+
* over just that subset at finalize — without re-parsing the whole transcript.
|
|
40
|
+
*/
|
|
41
|
+
export function isSkillInvocation(e) {
|
|
42
|
+
if (e.type !== 'tool_use' || e._local)
|
|
43
|
+
return false;
|
|
44
|
+
const skillTool = SKILL_TOOL_NAME_BY_AGENT[e.agent];
|
|
45
|
+
return !!skillTool && e.tool === skillTool;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Skills invoked during the session, from that harness's skill-invocation
|
|
49
|
+
* tool calls (see {@link SKILL_TOOL_NAME_BY_AGENT}). Carries the skill id in
|
|
50
|
+
* `args.skill` (or `args.name`). Sorted by count desc, then name.
|
|
22
51
|
*/
|
|
23
52
|
export function extractSkills(events) {
|
|
24
53
|
const counts = new Map();
|
|
25
54
|
for (const e of events) {
|
|
26
|
-
if (e
|
|
27
|
-
continue;
|
|
28
|
-
if (e.tool !== 'Skill')
|
|
55
|
+
if (!isSkillInvocation(e))
|
|
29
56
|
continue;
|
|
30
57
|
const name = e.args?.skill ?? e.args?.name;
|
|
31
58
|
if (typeof name !== 'string' || !name.trim())
|
|
@@ -37,6 +64,24 @@ export function extractSkills(events) {
|
|
|
37
64
|
.map(([name, count]) => ({ name, count }))
|
|
38
65
|
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
|
39
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Slash commands invoked during the session — either the user typing one
|
|
69
|
+
* (Claude's `<command-name>` wrapper) or the model invoking one via the
|
|
70
|
+
* `SlashCommand` tool (`SessionEvent.slashCommand`, populated by
|
|
71
|
+
* `parseClaudeContent` for both sources — see session/prompt.ts). Sorted by
|
|
72
|
+
* count desc, then name.
|
|
73
|
+
*/
|
|
74
|
+
export function extractSlashCommands(events) {
|
|
75
|
+
const counts = new Map();
|
|
76
|
+
for (const e of events) {
|
|
77
|
+
if (!e.slashCommand)
|
|
78
|
+
continue;
|
|
79
|
+
counts.set(e.slashCommand, (counts.get(e.slashCommand) ?? 0) + 1);
|
|
80
|
+
}
|
|
81
|
+
return [...counts.entries()]
|
|
82
|
+
.map(([name, count]) => ({ name, count }))
|
|
83
|
+
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
|
84
|
+
}
|
|
40
85
|
/**
|
|
41
86
|
* Hooks that fired during the session, folded from `hook` events (parsed from
|
|
42
87
|
* Claude's `hook_success`/`hook_error`/… attachment records; other harnesses
|