@phnx-labs/agents-cli 1.21.1 → 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 (111) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/README.md +1 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/computer-actions.d.ts +4 -0
  5. package/dist/commands/computer-actions.js +34 -0
  6. package/dist/commands/computer.js +4 -2
  7. package/dist/commands/doctor.js +5 -2
  8. package/dist/commands/feed.js +28 -19
  9. package/dist/commands/hooks.js +9 -45
  10. package/dist/commands/menubar.js +24 -24
  11. package/dist/commands/message.js +23 -3
  12. package/dist/commands/perf.d.ts +29 -0
  13. package/dist/commands/perf.js +90 -23
  14. package/dist/commands/projects.d.ts +21 -0
  15. package/dist/commands/projects.js +169 -23
  16. package/dist/commands/routines.js +46 -1
  17. package/dist/commands/sessions-picker.js +17 -2
  18. package/dist/commands/sessions.d.ts +5 -0
  19. package/dist/commands/sessions.js +14 -0
  20. package/dist/commands/ssh.js +69 -0
  21. package/dist/commands/trends.d.ts +2 -0
  22. package/dist/commands/trends.js +158 -0
  23. package/dist/commands/usage.d.ts +4 -4
  24. package/dist/commands/view.d.ts +6 -0
  25. package/dist/commands/view.js +90 -45
  26. package/dist/index.js +22 -1
  27. package/dist/lib/agents.js +2 -2
  28. package/dist/lib/analytics/dashboard.d.ts +11 -0
  29. package/dist/lib/analytics/dashboard.js +31 -0
  30. package/dist/lib/analytics/recipes.d.ts +32 -0
  31. package/dist/lib/analytics/recipes.js +316 -0
  32. package/dist/lib/analytics/usage-db.d.ts +84 -0
  33. package/dist/lib/analytics/usage-db.js +301 -0
  34. package/dist/lib/browser/service.js +31 -0
  35. package/dist/lib/cli-resources.d.ts +20 -0
  36. package/dist/lib/cli-resources.js +48 -1
  37. package/dist/lib/computer/dispatch.d.ts +3 -1
  38. package/dist/lib/computer/dispatch.js +10 -2
  39. package/dist/lib/daemon.js +51 -14
  40. package/dist/lib/devices/health-report.d.ts +5 -0
  41. package/dist/lib/devices/health-report.js +3 -0
  42. package/dist/lib/event-stream.d.ts +2 -0
  43. package/dist/lib/event-stream.js +3 -0
  44. package/dist/lib/events.d.ts +3 -1
  45. package/dist/lib/events.js +4 -2
  46. package/dist/lib/feed-broadcast.d.ts +52 -7
  47. package/dist/lib/feed-broadcast.js +125 -18
  48. package/dist/lib/fleet-cache.d.ts +37 -0
  49. package/dist/lib/fleet-cache.js +40 -0
  50. package/dist/lib/fleet-status.d.ts +53 -0
  51. package/dist/lib/fleet-status.js +120 -0
  52. package/dist/lib/friction-heuristics.d.ts +32 -0
  53. package/dist/lib/friction-heuristics.js +47 -0
  54. package/dist/lib/git.d.ts +14 -0
  55. package/dist/lib/git.js +36 -0
  56. package/dist/lib/hooks/cache.js +28 -6
  57. package/dist/lib/hooks/profile.d.ts +8 -0
  58. package/dist/lib/hooks/profile.js +15 -18
  59. package/dist/lib/hooks.js +72 -17
  60. package/dist/lib/linear-cache.d.ts +63 -0
  61. package/dist/lib/linear-cache.js +146 -0
  62. package/dist/lib/linear-project-counts.d.ts +35 -5
  63. package/dist/lib/linear-project-counts.js +61 -16
  64. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  65. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
  66. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  67. package/dist/lib/menubar/install-menubar.d.ts +7 -0
  68. package/dist/lib/menubar/install-menubar.js +36 -6
  69. package/dist/lib/percentile.d.ts +12 -0
  70. package/dist/lib/percentile.js +24 -0
  71. package/dist/lib/perf/db.d.ts +7 -3
  72. package/dist/lib/perf/db.js +37 -19
  73. package/dist/lib/perf/types.d.ts +10 -0
  74. package/dist/lib/plugins.js +12 -1
  75. package/dist/lib/project-doctor.d.ts +36 -0
  76. package/dist/lib/project-doctor.js +45 -0
  77. package/dist/lib/project-import.d.ts +11 -1
  78. package/dist/lib/project-import.js +17 -3
  79. package/dist/lib/project-status.d.ts +25 -5
  80. package/dist/lib/project-status.js +48 -6
  81. package/dist/lib/resources.d.ts +16 -0
  82. package/dist/lib/resources.js +25 -14
  83. package/dist/lib/rotate.d.ts +27 -0
  84. package/dist/lib/rotate.js +44 -17
  85. package/dist/lib/routines.d.ts +16 -0
  86. package/dist/lib/routines.js +26 -0
  87. package/dist/lib/runner.js +41 -0
  88. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  89. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  90. package/dist/lib/secrets/usage-db.d.ts +3 -63
  91. package/dist/lib/secrets/usage-db.js +46 -186
  92. package/dist/lib/session/db.d.ts +17 -1
  93. package/dist/lib/session/db.js +240 -6
  94. package/dist/lib/session/discover.d.ts +31 -0
  95. package/dist/lib/session/discover.js +50 -1
  96. package/dist/lib/session/highlights.d.ts +24 -4
  97. package/dist/lib/session/highlights.js +52 -7
  98. package/dist/lib/session/parse.js +19 -3
  99. package/dist/lib/session/prompt.d.ts +19 -0
  100. package/dist/lib/session/prompt.js +43 -0
  101. package/dist/lib/session/types.d.ts +43 -0
  102. package/dist/lib/startup/command-registry.d.ts +1 -0
  103. package/dist/lib/startup/command-registry.js +2 -0
  104. package/dist/lib/state.d.ts +31 -3
  105. package/dist/lib/state.js +53 -10
  106. package/dist/lib/types.d.ts +22 -4
  107. package/dist/lib/usage-refresh.d.ts +106 -0
  108. package/dist/lib/usage-refresh.js +238 -0
  109. package/dist/lib/usage.d.ts +152 -17
  110. package/dist/lib/usage.js +393 -79
  111. 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 = 21;
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`
@@ -67,6 +71,7 @@ CREATE TABLE IF NOT EXISTS sessions (
67
71
  cost_usd REAL,
68
72
  duration_ms INTEGER,
69
73
  model TEXT,
74
+ tool_call_count INTEGER,
70
75
  file_path TEXT NOT NULL,
71
76
  file_mtime_ms INTEGER,
72
77
  file_size INTEGER,
@@ -84,7 +89,9 @@ CREATE TABLE IF NOT EXISTS sessions (
84
89
  linear_project TEXT,
85
90
  linear_project_url TEXT,
86
91
  actor TEXT,
87
- initiated_by TEXT
92
+ initiated_by TEXT,
93
+ used_browser INTEGER,
94
+ used_computer INTEGER
88
95
  );
89
96
  CREATE INDEX IF NOT EXISTS idx_sessions_timestamp ON sessions(timestamp DESC);
90
97
  CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
@@ -139,6 +146,29 @@ CREATE TABLE IF NOT EXISTS dir_ledger (
139
146
  entry_count INTEGER NOT NULL,
140
147
  scanned_at INTEGER NOT NULL
141
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);
142
172
  `;
143
173
  let dbInstance = null;
144
174
  /**
@@ -399,6 +429,57 @@ function migrateSchema(db, fromVersion) {
399
429
  db.exec(`ALTER TABLE sessions ADD COLUMN spawned_team TEXT`);
400
430
  db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
401
431
  }
432
+ if (fromVersion < 22) {
433
+ // v21 → v22 (main): persist the transcript's tool-call count.
434
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
435
+ if (!cols.some(c => c.name === 'tool_call_count'))
436
+ db.exec(`ALTER TABLE sessions ADD COLUMN tool_call_count INTEGER`);
437
+ db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
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
+ }
402
483
  }
403
484
  /** Open (or return the cached) sessions database, applying migrations as needed. */
404
485
  export function getDB() {
@@ -758,20 +839,20 @@ const upsertSessionStmt = (db) => db.prepare(`
758
839
  id, short_id, agent, origin, routine_name, routine_run_id,
759
840
  version, account, timestamp, last_activity,
760
841
  project, cwd, git_branch, topic, label, message_count, token_count,
761
- output_tokens, cost_usd, duration_ms, model,
842
+ output_tokens, cost_usd, duration_ms, model, tool_call_count,
762
843
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
763
844
  pr_url, pr_number, worktree_slug, ticket_id, spawned_team, plan, todos,
764
845
  recent_directories_touched, linear_project, linear_project_url, machine,
765
- actor, initiated_by
846
+ actor, initiated_by, used_browser, used_computer
766
847
  ) VALUES (
767
848
  @id, @short_id, @agent, @origin, @routine_name, @routine_run_id,
768
849
  @version, @account, @timestamp, @last_activity,
769
850
  @project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
770
- @output_tokens, @cost_usd, @duration_ms, @model,
851
+ @output_tokens, @cost_usd, @duration_ms, @model, @tool_call_count,
771
852
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
772
853
  @pr_url, @pr_number, @worktree_slug, @ticket_id, @spawned_team, @plan, @todos,
773
854
  @recent_directories_touched, @linear_project, @linear_project_url, @machine,
774
- @actor, @initiated_by
855
+ @actor, @initiated_by, @used_browser, @used_computer
775
856
  )
776
857
  ON CONFLICT(id) DO UPDATE SET
777
858
  short_id = excluded.short_id,
@@ -802,6 +883,7 @@ const upsertSessionStmt = (db) => db.prepare(`
802
883
  cost_usd = excluded.cost_usd,
803
884
  duration_ms = excluded.duration_ms,
804
885
  model = excluded.model,
886
+ tool_call_count = excluded.tool_call_count,
805
887
  file_path = excluded.file_path,
806
888
  file_mtime_ms = excluded.file_mtime_ms,
807
889
  file_size = excluded.file_size,
@@ -815,6 +897,8 @@ const upsertSessionStmt = (db) => db.prepare(`
815
897
  plan = excluded.plan,
816
898
  todos = excluded.todos,
817
899
  recent_directories_touched = excluded.recent_directories_touched,
900
+ used_browser = excluded.used_browser,
901
+ used_computer = excluded.used_computer,
818
902
  linear_project = CASE
819
903
  WHEN excluded.ticket_id IS NOT sessions.ticket_id THEN excluded.linear_project
820
904
  ELSE COALESCE(excluded.linear_project, sessions.linear_project)
@@ -833,11 +917,124 @@ const upsertSessionStmt = (db) => db.prepare(`
833
917
  actor = COALESCE(sessions.actor, excluded.actor),
834
918
  initiated_by = COALESCE(sessions.initiated_by, excluded.initiated_by)
835
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
+ }
836
1032
  function enrichCachedSessionMeta(meta) {
837
1033
  if (!meta.filePath)
838
1034
  return meta;
839
1035
  try {
840
1036
  const events = parseSession(meta.filePath, meta.agent);
1037
+ writeResourceUsage(meta.id, events, meta.cwd);
841
1038
  return {
842
1039
  ...meta,
843
1040
  todos: extractTodoProgressFromEvents(events),
@@ -895,6 +1092,7 @@ export function upsertSession(meta, content, scan) {
895
1092
  // row AND backfills one indexed null-first (before its sidecar existed), while a
896
1093
  // rescan carrying no actor still keeps the stored owner.
897
1094
  const actorRec = meta.actor ? undefined : readSessionActorRecord(meta.id);
1095
+ const toolUsage = detectToolUsage(meta.id);
898
1096
  const db = getDB();
899
1097
  const { upsert, delText, insText, readLabel } = stmts(db);
900
1098
  const row = {
@@ -919,6 +1117,7 @@ export function upsertSession(meta, content, scan) {
919
1117
  cost_usd: meta.costUsd ?? null,
920
1118
  duration_ms: meta.durationMs ?? null,
921
1119
  model: meta.model ?? null,
1120
+ tool_call_count: meta.toolCallCount ?? null,
922
1121
  file_path: meta.filePath,
923
1122
  file_mtime_ms: scan?.fileMtimeMs ?? null,
924
1123
  file_size: scan?.fileSize ?? null,
@@ -937,6 +1136,8 @@ export function upsertSession(meta, content, scan) {
937
1136
  machine: resolveMachine(meta),
938
1137
  actor: meta.actor ?? actorRec?.actor ?? null,
939
1138
  initiated_by: meta.initiatedBy ?? actorRec?.initiatedBy ?? null,
1139
+ used_browser: toolUsage.usedBrowser ? 1 : 0,
1140
+ used_computer: toolUsage.usedComputer ? 1 : 0,
940
1141
  };
941
1142
  const txn = db.transaction(() => {
942
1143
  upsert.run(row);
@@ -1017,6 +1218,15 @@ export function upsertSessionsBatch(entries) {
1017
1218
  // back when the error escapes `fn`, so catching + skipping here leaves the txn valid
1018
1219
  // and committable. We deliberately do NOT stamp the ledger for a skipped row, so the
1019
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
+ }
1020
1230
  try {
1021
1231
  upsert.run({
1022
1232
  id: meta.id,
@@ -1040,6 +1250,7 @@ export function upsertSessionsBatch(entries) {
1040
1250
  cost_usd: meta.costUsd ?? null,
1041
1251
  duration_ms: meta.durationMs ?? null,
1042
1252
  model: meta.model ?? null,
1253
+ tool_call_count: meta.toolCallCount ?? null,
1043
1254
  file_path: meta.filePath,
1044
1255
  file_mtime_ms: scan?.fileMtimeMs ?? null,
1045
1256
  file_size: scan?.fileSize ?? null,
@@ -1058,6 +1269,8 @@ export function upsertSessionsBatch(entries) {
1058
1269
  machine: resolveMachine(meta),
1059
1270
  actor: meta.actor ?? actorIndex.get(meta.id)?.actor ?? null,
1060
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,
1061
1274
  });
1062
1275
  delText.run(meta.id);
1063
1276
  insText.run(meta.id,
@@ -1229,6 +1442,7 @@ function rowToMeta(row) {
1229
1442
  costUsd: row.cost_usd ?? undefined,
1230
1443
  durationMs: row.duration_ms ?? undefined,
1231
1444
  model: row.model ?? undefined,
1445
+ toolCallCount: row.tool_call_count ?? undefined,
1232
1446
  version: row.version ?? undefined,
1233
1447
  account: row.account ?? undefined,
1234
1448
  topic: row.topic ?? undefined,
@@ -1249,6 +1463,11 @@ function rowToMeta(row) {
1249
1463
  // Narrow the free-text column to the known kinds; an unexpected value maps
1250
1464
  // to undefined rather than being asserted as a valid kind.
1251
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,
1252
1471
  };
1253
1472
  }
1254
1473
  function parseJsonColumn(value) {
@@ -1381,6 +1600,21 @@ function buildSessionWhere(options) {
1381
1600
  if (options.onlyTeamOrigin) {
1382
1601
  where.push('IFNULL(is_team_origin, 0) = 1');
1383
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
+ }
1384
1618
  const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
1385
1619
  return { clause, params };
1386
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 {
@@ -66,6 +70,7 @@ interface ClaudeSessionScan {
66
70
  durationMs?: number;
67
71
  /** ISO time of the last timestamped event — the session's last activity. */
68
72
  lastActivity?: string;
73
+ toolCallCount?: number;
69
74
  /**
70
75
  * Value of the JSONL `entrypoint` field on the first event that carries it.
71
76
  * 'cli' for real interactive sessions, 'sdk-cli' for team-spawned ones.
@@ -86,6 +91,16 @@ interface ClaudeSessionScan {
86
91
  plan?: string;
87
92
  todos?: TodoProgress;
88
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
+ }>;
89
104
  }
90
105
  /** Lightweight metadata extracted from a Codex JSONL file during incremental scan. */
91
106
  interface CodexSessionScan {
@@ -374,6 +389,7 @@ export interface ClaudeParseState {
374
389
  aiTitle?: string;
375
390
  entrypoint?: string;
376
391
  messageCount: number;
392
+ toolCallCount: number;
377
393
  tokenCount: number;
378
394
  outputTokens: number;
379
395
  sawTokenCount: boolean;
@@ -392,6 +408,11 @@ export interface ClaudeParseState {
392
408
  plan?: string;
393
409
  checklistEvents: SessionEvent[];
394
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[];
395
416
  }
396
417
  /** Zero-value accumulator for a fresh (from-byte-0) Claude parse. */
397
418
  export declare function initClaudeParseState(): ClaudeParseState;
@@ -436,6 +457,7 @@ export interface ClaudeParserState {
436
457
  plan?: string;
437
458
  lastTsMs?: number;
438
459
  messageCount: number;
460
+ toolCallCount: number;
439
461
  tokenCount: number;
440
462
  outputTokens: number;
441
463
  sawTokenCount: boolean;
@@ -453,6 +475,9 @@ export interface ClaudeParserState {
453
475
  contentText?: string;
454
476
  checklistEvents: SessionEvent[];
455
477
  recentDirectoriesTouched: string[];
478
+ /** #12: see ClaudeParseState.skillEvents/slashCommandEvents. */
479
+ skillEvents: SessionEvent[];
480
+ slashCommandEvents: SessionEvent[];
456
481
  }
457
482
  /**
458
483
  * Snapshot a live {@link ClaudeParseState} into its serializable form at
@@ -528,6 +553,12 @@ export interface CodexParseState {
528
553
  spawnedTeam?: string;
529
554
  checklistEvents: SessionEvent[];
530
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[];
531
562
  }
532
563
  /** Zero-value accumulator for a fresh (from-byte-0) Codex parse. */
533
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
  /**
@@ -1015,6 +1018,7 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account,
1015
1018
  topic: scan.topic,
1016
1019
  label,
1017
1020
  messageCount: scan.messageCount,
1021
+ toolCallCount: scan.toolCallCount,
1018
1022
  tokenCount: scan.tokenCount,
1019
1023
  outputTokens: scan.outputTokens,
1020
1024
  costUsd: scan.costUsd,
@@ -1029,6 +1033,8 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account,
1029
1033
  plan: scan.plan,
1030
1034
  todos: scan.todos,
1031
1035
  recentDirectoriesTouched: scan.recentDirectoriesTouched,
1036
+ skillsUsed: scan.skillsUsed,
1037
+ slashCommandsUsed: scan.slashCommandsUsed,
1032
1038
  };
1033
1039
  }
1034
1040
  else {
@@ -1044,6 +1050,7 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account,
1044
1050
  model: scan.model,
1045
1051
  label,
1046
1052
  messageCount: scan.messageCount,
1053
+ toolCallCount: scan.toolCallCount,
1047
1054
  tokenCount: scan.tokenCount,
1048
1055
  outputTokens: scan.outputTokens,
1049
1056
  costUsd: scan.costUsd,
@@ -1059,6 +1066,8 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account,
1059
1066
  plan: scan.plan,
1060
1067
  todos: scan.todos,
1061
1068
  recentDirectoriesTouched: scan.recentDirectoriesTouched,
1069
+ skillsUsed: scan.skillsUsed,
1070
+ slashCommandsUsed: scan.slashCommandsUsed,
1062
1071
  };
1063
1072
  }
1064
1073
  return {
@@ -2425,6 +2434,7 @@ export function initClaudeParseState() {
2425
2434
  aiTitle: undefined,
2426
2435
  entrypoint: undefined,
2427
2436
  messageCount: 0,
2437
+ toolCallCount: 0,
2428
2438
  tokenCount: 0,
2429
2439
  outputTokens: 0,
2430
2440
  sawTokenCount: false,
@@ -2443,6 +2453,8 @@ export function initClaudeParseState() {
2443
2453
  plan: undefined,
2444
2454
  checklistEvents: [],
2445
2455
  recentDirectoriesTouched: [],
2456
+ skillEvents: [],
2457
+ slashCommandEvents: [],
2446
2458
  };
2447
2459
  }
2448
2460
  const CHECKLIST_TOOLS = new Set(['TodoWrite', 'todo_write', 'update_plan', 'TaskCreate', 'TaskUpdate']);
@@ -2450,6 +2462,16 @@ const DIRECTORY_TOOLS = new Set(['Edit', 'Write', 'edit_file', 'write_file', 'cr
2450
2462
  function foldDerivedToolState(state, event) {
2451
2463
  if (CHECKLIST_TOOLS.has(event.tool ?? ''))
2452
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
+ }
2453
2475
  if (!DIRECTORY_TOOLS.has(event.tool ?? ''))
2454
2476
  return;
2455
2477
  const next = extractRecentDirectoriesTouched([event], state.cwd);
@@ -2482,6 +2504,7 @@ export function applyClaudeLine(state, parsed) {
2482
2504
  for (const b of parsed.message.content) {
2483
2505
  if (b?.type !== 'tool_use')
2484
2506
  continue;
2507
+ state.toolCallCount++;
2485
2508
  if (!state.spawnedTeam && typeof b?.input?.command === 'string') {
2486
2509
  const team = detectSpawnedTeam(b.input.command);
2487
2510
  if (team)
@@ -2578,6 +2601,15 @@ export function applyClaudeLine(state, parsed) {
2578
2601
  state.userTexts.push(text);
2579
2602
  if (!state.topic)
2580
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
+ }
2581
2613
  }
2582
2614
  return;
2583
2615
  }
@@ -2642,6 +2674,7 @@ export function finalizeClaudeScan(state) {
2642
2674
  topic: resolvedTopic,
2643
2675
  entrypoint: state.entrypoint,
2644
2676
  messageCount: state.messageCount,
2677
+ toolCallCount: state.toolCallCount,
2645
2678
  tokenCount: state.sawTokenCount ? state.tokenCount : undefined,
2646
2679
  outputTokens: state.sawTokenCount ? state.outputTokens : undefined,
2647
2680
  costUsd: state.sawCost ? state.costUsd : undefined,
@@ -2657,6 +2690,8 @@ export function finalizeClaudeScan(state) {
2657
2690
  plan: state.plan,
2658
2691
  todos: extractTodoProgressFromEvents(state.checklistEvents),
2659
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,
2660
2695
  };
2661
2696
  }
2662
2697
  /** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
@@ -2713,6 +2748,7 @@ export function serializeClaudeParserState(state, offset) {
2713
2748
  plan: state.plan,
2714
2749
  lastTsMs: state.lastTsMs,
2715
2750
  messageCount: state.messageCount,
2751
+ toolCallCount: state.toolCallCount,
2716
2752
  tokenCount: state.tokenCount,
2717
2753
  outputTokens: state.outputTokens,
2718
2754
  sawTokenCount: state.sawTokenCount,
@@ -2734,6 +2770,8 @@ export function serializeClaudeParserState(state, offset) {
2734
2770
  contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
2735
2771
  checklistEvents: state.checklistEvents,
2736
2772
  recentDirectoriesTouched: state.recentDirectoriesTouched,
2773
+ skillEvents: state.skillEvents,
2774
+ slashCommandEvents: state.slashCommandEvents,
2737
2775
  };
2738
2776
  }
2739
2777
  /**
@@ -2771,6 +2809,7 @@ export function hydrateClaudeParseState(prior) {
2771
2809
  aiTitle: prior.aiTitle,
2772
2810
  entrypoint: prior.entrypoint,
2773
2811
  messageCount: prior.messageCount,
2812
+ toolCallCount: prior.toolCallCount ?? 0,
2774
2813
  tokenCount: prior.tokenCount,
2775
2814
  outputTokens: prior.outputTokens,
2776
2815
  sawTokenCount: prior.sawTokenCount,
@@ -2789,6 +2828,8 @@ export function hydrateClaudeParseState(prior) {
2789
2828
  plan: prior.plan,
2790
2829
  checklistEvents: prior.checklistEvents ?? [],
2791
2830
  recentDirectoriesTouched: prior.recentDirectoriesTouched ?? [],
2831
+ skillEvents: prior.skillEvents ?? [],
2832
+ slashCommandEvents: prior.slashCommandEvents ?? [],
2792
2833
  };
2793
2834
  }
2794
2835
  /**
@@ -2990,6 +3031,8 @@ export function initCodexParseState() {
2990
3031
  spawnedTeam: undefined,
2991
3032
  checklistEvents: [],
2992
3033
  recentDirectoriesTouched: [],
3034
+ skillEvents: [],
3035
+ slashCommandEvents: [],
2993
3036
  };
2994
3037
  }
2995
3038
  /**
@@ -3237,6 +3280,11 @@ export function hydrateCodexParseState(prior) {
3237
3280
  spawnedTeam: prior.spawnedTeam,
3238
3281
  checklistEvents: prior.checklistEvents ?? [],
3239
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: [],
3240
3288
  };
3241
3289
  }
3242
3290
  /**
@@ -3734,6 +3782,7 @@ export function readCursorMeta(filePath, currentVersion) {
3734
3782
  topic: firstUserText ? extractSessionTopic(firstUserText) : undefined,
3735
3783
  label: title,
3736
3784
  messageCount: events.filter((event) => event.type === 'message').length,
3785
+ todos: extractTodoProgressFromEvents(events),
3737
3786
  };
3738
3787
  return { meta, content: userTexts.join('\n') };
3739
3788
  }