@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/computer-actions.d.ts +4 -0
  4. package/dist/commands/computer-actions.js +34 -0
  5. package/dist/commands/computer.js +4 -2
  6. package/dist/commands/perf.d.ts +16 -0
  7. package/dist/commands/perf.js +11 -1
  8. package/dist/commands/projects.d.ts +11 -1
  9. package/dist/commands/projects.js +16 -2
  10. package/dist/commands/sessions-picker.js +17 -2
  11. package/dist/commands/sessions.d.ts +5 -0
  12. package/dist/commands/sessions.js +14 -0
  13. package/dist/index.js +8 -0
  14. package/dist/lib/browser/service.js +13 -0
  15. package/dist/lib/computer/dispatch.d.ts +3 -1
  16. package/dist/lib/computer/dispatch.js +10 -2
  17. package/dist/lib/event-stream.d.ts +2 -0
  18. package/dist/lib/event-stream.js +3 -0
  19. package/dist/lib/events.d.ts +3 -1
  20. package/dist/lib/events.js +4 -2
  21. package/dist/lib/git.d.ts +14 -0
  22. package/dist/lib/git.js +36 -0
  23. package/dist/lib/hooks/profile.js +1 -14
  24. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  25. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  26. package/dist/lib/percentile.d.ts +12 -0
  27. package/dist/lib/percentile.js +24 -0
  28. package/dist/lib/perf/db.d.ts +1 -2
  29. package/dist/lib/perf/db.js +2 -14
  30. package/dist/lib/plugins.js +12 -1
  31. package/dist/lib/resources.d.ts +16 -0
  32. package/dist/lib/resources.js +25 -14
  33. package/dist/lib/routines.js +1 -14
  34. package/dist/lib/runner.js +8 -1
  35. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  36. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  37. package/dist/lib/session/db.d.ts +16 -1
  38. package/dist/lib/session/db.js +227 -4
  39. package/dist/lib/session/discover.d.ts +28 -0
  40. package/dist/lib/session/discover.js +42 -1
  41. package/dist/lib/session/highlights.d.ts +24 -4
  42. package/dist/lib/session/highlights.js +52 -7
  43. package/dist/lib/session/parse.js +19 -3
  44. package/dist/lib/session/prompt.d.ts +19 -0
  45. package/dist/lib/session/prompt.js +43 -0
  46. package/dist/lib/session/types.d.ts +42 -0
  47. package/dist/lib/types.d.ts +14 -0
  48. package/package.json +1 -1
@@ -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 = 22;
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
- * Skills invoked during the session, from `Skill` tool calls. Claude and Kimi
21
- * both name the tool `Skill` and carry the skill id in `args.skill` (plugin
22
- * skills surface here too — a plugin-provided skill is invoked through the
23
- * same tool). Sorted by count desc, then name.
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
- * Skills invoked during the session, from `Skill` tool calls. Claude and Kimi
19
- * both name the tool `Skill` and carry the skill id in `args.skill` (plugin
20
- * skills surface here too — a plugin-provided skill is invoked through the
21
- * same tool). Sorted by count desc, then name.
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.type !== 'tool_use' || e._local)
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