@phnx-labs/agents-cli 1.20.77 → 1.20.82

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 (212) hide show
  1. package/CHANGELOG.md +589 -0
  2. package/README.md +27 -25
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +9 -0
  5. package/dist/commands/activity.js +153 -24
  6. package/dist/commands/apply.js +11 -4
  7. package/dist/commands/cli.js +1 -1
  8. package/dist/commands/commands.d.ts +1 -1
  9. package/dist/commands/commands.js +7 -6
  10. package/dist/commands/defaults.js +3 -11
  11. package/dist/commands/doctor.d.ts +74 -0
  12. package/dist/commands/doctor.js +718 -40
  13. package/dist/commands/events.js +1 -1
  14. package/dist/commands/exec.js +53 -8
  15. package/dist/commands/feed.d.ts +4 -0
  16. package/dist/commands/feed.js +87 -11
  17. package/dist/commands/harness.js +2 -2
  18. package/dist/commands/hooks.js +1 -1
  19. package/dist/commands/import.js +8 -4
  20. package/dist/commands/logs.js +6 -0
  21. package/dist/commands/models.js +1 -1
  22. package/dist/commands/packages.js +6 -6
  23. package/dist/commands/permissions.js +1 -1
  24. package/dist/commands/profiles.js +1 -1
  25. package/dist/commands/pull.d.ts +1 -1
  26. package/dist/commands/pull.js +4 -4
  27. package/dist/commands/repo.d.ts +78 -0
  28. package/dist/commands/repo.js +195 -11
  29. package/dist/commands/routines.js +16 -4
  30. package/dist/commands/rules.js +13 -12
  31. package/dist/commands/secrets.d.ts +3 -1
  32. package/dist/commands/secrets.js +108 -34
  33. package/dist/commands/sessions-browser.d.ts +45 -3
  34. package/dist/commands/sessions-browser.js +118 -12
  35. package/dist/commands/sessions-export.d.ts +3 -0
  36. package/dist/commands/sessions-export.js +13 -10
  37. package/dist/commands/sessions-inject.d.ts +7 -4
  38. package/dist/commands/sessions-inject.js +18 -18
  39. package/dist/commands/sessions-picker.d.ts +15 -1
  40. package/dist/commands/sessions-picker.js +90 -7
  41. package/dist/commands/sessions-resume.d.ts +16 -0
  42. package/dist/commands/sessions-resume.js +14 -13
  43. package/dist/commands/sessions.d.ts +126 -1
  44. package/dist/commands/sessions.js +365 -42
  45. package/dist/commands/set.d.ts +15 -0
  46. package/dist/commands/set.js +79 -0
  47. package/dist/commands/setup-fleet.d.ts +21 -0
  48. package/dist/commands/setup-fleet.js +201 -0
  49. package/dist/commands/setup-secrets.d.ts +20 -0
  50. package/dist/commands/setup-secrets.js +223 -0
  51. package/dist/commands/setup.js +17 -3
  52. package/dist/commands/skills.js +1 -1
  53. package/dist/commands/ssh.js +9 -1
  54. package/dist/commands/sync.js +7 -2
  55. package/dist/commands/teams-picker.js +0 -1
  56. package/dist/commands/teams.d.ts +33 -1
  57. package/dist/commands/teams.js +208 -44
  58. package/dist/commands/versions.js +10 -5
  59. package/dist/commands/view.d.ts +1 -0
  60. package/dist/commands/view.js +11 -0
  61. package/dist/index.js +8 -13
  62. package/dist/lib/acp/harnesses.js +0 -7
  63. package/dist/lib/activity.d.ts +156 -0
  64. package/dist/lib/activity.js +282 -0
  65. package/dist/lib/agents.d.ts +9 -23
  66. package/dist/lib/agents.js +72 -100
  67. package/dist/lib/auto-pull.d.ts +16 -8
  68. package/dist/lib/auto-pull.js +23 -28
  69. package/dist/lib/browser/service.js +3 -0
  70. package/dist/lib/browser/types.d.ts +7 -0
  71. package/dist/lib/capabilities.js +6 -2
  72. package/dist/lib/crabbox/lease.js +2 -2
  73. package/dist/lib/crabbox/setup-copy.d.ts +4 -4
  74. package/dist/lib/crabbox/setup-copy.js +4 -4
  75. package/dist/lib/daemon.d.ts +26 -40
  76. package/dist/lib/daemon.js +63 -123
  77. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  78. package/dist/lib/devices/fleet-divergence.js +188 -0
  79. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  80. package/dist/lib/devices/fleet-inventory.js +57 -0
  81. package/dist/lib/devices/fleet.d.ts +1 -1
  82. package/dist/lib/devices/fleet.js +1 -1
  83. package/dist/lib/devices/health-report.d.ts +10 -2
  84. package/dist/lib/devices/health-report.js +32 -1
  85. package/dist/lib/doctor-diff.d.ts +20 -0
  86. package/dist/lib/doctor-diff.js +6 -1
  87. package/dist/lib/drift.d.ts +31 -11
  88. package/dist/lib/drift.js +58 -7
  89. package/dist/lib/events.d.ts +8 -1
  90. package/dist/lib/events.js +10 -1
  91. package/dist/lib/exec.js +35 -8
  92. package/dist/lib/feed-post.d.ts +28 -1
  93. package/dist/lib/feed-post.js +110 -2
  94. package/dist/lib/fleet/apply.js +8 -1
  95. package/dist/lib/fleet/auth-sync.d.ts +18 -1
  96. package/dist/lib/fleet/auth-sync.js +25 -11
  97. package/dist/lib/fleet/remote-login.js +5 -0
  98. package/dist/lib/git.d.ts +27 -14
  99. package/dist/lib/git.js +138 -43
  100. package/dist/lib/hooks.d.ts +42 -0
  101. package/dist/lib/hooks.js +137 -93
  102. package/dist/lib/hosts/dispatch.d.ts +28 -1
  103. package/dist/lib/hosts/dispatch.js +33 -5
  104. package/dist/lib/hosts/option.js +2 -2
  105. package/dist/lib/hosts/passthrough.d.ts +22 -1
  106. package/dist/lib/hosts/passthrough.js +238 -13
  107. package/dist/lib/hosts/remote-cmd.js +2 -0
  108. package/dist/lib/hosts/run-target.d.ts +2 -0
  109. package/dist/lib/hosts/run-target.js +1 -0
  110. package/dist/lib/hq/floor.d.ts +1 -1
  111. package/dist/lib/hq/floor.js +6 -1
  112. package/dist/lib/mcp.js +3 -71
  113. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  114. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  115. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  116. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  117. package/dist/lib/menubar/notify-desktop.d.ts +15 -0
  118. package/dist/lib/menubar/notify-desktop.js +41 -5
  119. package/dist/lib/merged-resources.d.ts +11 -0
  120. package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
  121. package/dist/lib/permissions.d.ts +0 -32
  122. package/dist/lib/permissions.js +4 -157
  123. package/dist/lib/picker.js +1 -1
  124. package/dist/lib/platform/process.d.ts +16 -5
  125. package/dist/lib/platform/process.js +54 -0
  126. package/dist/lib/plugins.d.ts +0 -8
  127. package/dist/lib/plugins.js +4 -110
  128. package/dist/lib/project-resources.js +4 -1
  129. package/dist/lib/refresh.d.ts +5 -3
  130. package/dist/lib/refresh.js +7 -5
  131. package/dist/lib/resources/commands.js +4 -1
  132. package/dist/lib/resources/mcp.js +0 -4
  133. package/dist/lib/resources/permissions.d.ts +1 -1
  134. package/dist/lib/resources/permissions.js +1 -5
  135. package/dist/lib/resources/rules.js +4 -1
  136. package/dist/lib/resources/skills.js +4 -1
  137. package/dist/lib/resources/subagents.js +4 -0
  138. package/dist/lib/resources/types.d.ts +1 -1
  139. package/dist/lib/routines.d.ts +20 -0
  140. package/dist/lib/routines.js +6 -0
  141. package/dist/lib/run-defaults.d.ts +1 -0
  142. package/dist/lib/run-defaults.js +9 -0
  143. package/dist/lib/runner.d.ts +0 -12
  144. package/dist/lib/runner.js +71 -29
  145. package/dist/lib/sandbox.js +0 -16
  146. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  147. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  148. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  149. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  150. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  151. package/dist/lib/secrets/agent.d.ts +18 -0
  152. package/dist/lib/secrets/agent.js +131 -17
  153. package/dist/lib/secrets/audit.d.ts +46 -0
  154. package/dist/lib/secrets/audit.js +56 -0
  155. package/dist/lib/secrets/bundles.d.ts +12 -9
  156. package/dist/lib/secrets/bundles.js +47 -31
  157. package/dist/lib/secrets/index.d.ts +2 -1
  158. package/dist/lib/secrets/index.js +8 -2
  159. package/dist/lib/secrets/remote.d.ts +75 -2
  160. package/dist/lib/secrets/remote.js +139 -5
  161. package/dist/lib/secrets/scope.d.ts +26 -0
  162. package/dist/lib/secrets/scope.js +29 -0
  163. package/dist/lib/secrets/session-store.d.ts +10 -0
  164. package/dist/lib/secrets/session-store.js +64 -11
  165. package/dist/lib/secrets/sync.js +3 -3
  166. package/dist/lib/session/active.d.ts +117 -26
  167. package/dist/lib/session/active.js +199 -53
  168. package/dist/lib/session/actor-sidecar.d.ts +23 -0
  169. package/dist/lib/session/actor-sidecar.js +101 -0
  170. package/dist/lib/session/db.d.ts +41 -1
  171. package/dist/lib/session/db.js +173 -8
  172. package/dist/lib/session/digest.js +126 -21
  173. package/dist/lib/session/discover.d.ts +19 -2
  174. package/dist/lib/session/discover.js +32 -11
  175. package/dist/lib/session/hook-sessions.d.ts +9 -0
  176. package/dist/lib/session/hook-sessions.js +57 -8
  177. package/dist/lib/session/origin-machine.d.ts +18 -0
  178. package/dist/lib/session/origin-machine.js +34 -0
  179. package/dist/lib/session/pid-registry.d.ts +13 -0
  180. package/dist/lib/session/render.d.ts +2 -0
  181. package/dist/lib/session/render.js +1 -1
  182. package/dist/lib/session/state.d.ts +1 -0
  183. package/dist/lib/session/state.js +1 -1
  184. package/dist/lib/session/sync/config.js +2 -2
  185. package/dist/lib/session/types.d.ts +16 -0
  186. package/dist/lib/smart-launch.d.ts +86 -0
  187. package/dist/lib/smart-launch.js +172 -0
  188. package/dist/lib/staleness/detectors/commands.js +2 -2
  189. package/dist/lib/staleness/detectors/permissions.js +1 -45
  190. package/dist/lib/staleness/writers/commands.js +4 -5
  191. package/dist/lib/staleness/writers/hooks.js +1 -1
  192. package/dist/lib/startup/command-registry.d.ts +1 -2
  193. package/dist/lib/startup/command-registry.js +2 -4
  194. package/dist/lib/subagents-registry.js +5 -12
  195. package/dist/lib/subagents.d.ts +0 -10
  196. package/dist/lib/subagents.js +0 -12
  197. package/dist/lib/teams/agents.d.ts +0 -2
  198. package/dist/lib/teams/agents.js +10 -27
  199. package/dist/lib/teams/index.d.ts +1 -1
  200. package/dist/lib/teams/index.js +1 -1
  201. package/dist/lib/types.d.ts +17 -9
  202. package/dist/lib/usage.d.ts +28 -5
  203. package/dist/lib/usage.js +271 -8
  204. package/dist/lib/versions.js +7 -1
  205. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  206. package/dist/lib/watchdog/watchdog.js +25 -13
  207. package/package.json +1 -2
  208. package/dist/commands/check.d.ts +0 -15
  209. package/dist/commands/check.js +0 -180
  210. package/dist/commands/resources.d.ts +0 -5
  211. package/dist/lib/secrets/account-token.d.ts +0 -20
  212. package/dist/lib/secrets/account-token.js +0 -64
@@ -2,7 +2,7 @@
2
2
  * SQLite-backed session index and full-text search.
3
3
  *
4
4
  * Stores session metadata and user-prompt text in a WAL-mode SQLite database
5
- * at ~/.agents/sessions/sessions.db. Provides incremental upsert, scan-stamp
5
+ * at ~/.agents/.history/sessions/sessions.db. Provides incremental upsert, scan-stamp
6
6
  * ledger (mtime/size tracking to skip unchanged files), FTS5 search with
7
7
  * BM25 ranking, and label-first search for /rename'd sessions.
8
8
  */
@@ -12,10 +12,12 @@ 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 { machineForSessionFile } from './origin-machine.js';
16
+ import { loadSessionActorIndex, readSessionActorRecord } from './actor-sidecar.js';
15
17
  const SESSIONS_DIR = getSessionsDir();
16
18
  const DB_PATH = getSessionsDbPath();
17
19
  /** Current schema version; bumped when migrations are added. */
18
- const SCHEMA_VERSION = 17;
20
+ const SCHEMA_VERSION = 20;
19
21
  /**
20
22
  * Canonicalize a file path for use as a scan_ledger key. The same physical
21
23
  * session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
@@ -62,6 +64,7 @@ CREATE TABLE IF NOT EXISTS sessions (
62
64
  output_tokens INTEGER,
63
65
  cost_usd REAL,
64
66
  duration_ms INTEGER,
67
+ model TEXT,
65
68
  file_path TEXT NOT NULL,
66
69
  file_mtime_ms INTEGER,
67
70
  file_size INTEGER,
@@ -72,16 +75,21 @@ CREATE TABLE IF NOT EXISTS sessions (
72
75
  worktree_slug TEXT,
73
76
  ticket_id TEXT,
74
77
  plan TEXT,
78
+ machine TEXT,
75
79
  todos TEXT,
76
80
  recent_directories_touched TEXT,
77
81
  linear_project TEXT,
78
- linear_project_url TEXT
82
+ linear_project_url TEXT,
83
+ actor TEXT,
84
+ initiated_by TEXT
79
85
  );
80
86
  CREATE INDEX IF NOT EXISTS idx_sessions_timestamp ON sessions(timestamp DESC);
81
87
  CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
82
88
  CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent);
83
89
  CREATE INDEX IF NOT EXISTS idx_sessions_file_path ON sessions(file_path);
84
90
  CREATE INDEX IF NOT EXISTS idx_sessions_short_id ON sessions(short_id);
91
+ -- idx_sessions_machine_ts / idx_sessions_agent_ts are created after migration
92
+ -- v17 guarantees the machine column exists (same pattern as last_activity).
85
93
 
86
94
  CREATE VIRTUAL TABLE IF NOT EXISTS session_text USING fts5(
87
95
  session_id UNINDEXED,
@@ -323,6 +331,7 @@ function migrateSchema(db, fromVersion) {
323
331
  db.exec(`UPDATE sessions SET short_id = substr(id, 1, 8) WHERE short_id IS NULL OR short_id = ''`);
324
332
  }
325
333
  if (fromVersion < 17) {
334
+ // v16 → v17 (main): todos / recent dirs / linear project metadata.
326
335
  const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
327
336
  if (!cols.some(c => c.name === 'todos'))
328
337
  db.exec(`ALTER TABLE sessions ADD COLUMN todos TEXT`);
@@ -334,6 +343,46 @@ function migrateSchema(db, fromVersion) {
334
343
  db.exec(`ALTER TABLE sessions ADD COLUMN linear_project_url TEXT`);
335
344
  db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
336
345
  }
346
+ if (fromVersion < 18) {
347
+ // v17 → v18: persist origin machine for smart-launch affinity GROUP BY machine.
348
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
349
+ if (!cols.some(c => c.name === 'machine')) {
350
+ db.exec(`ALTER TABLE sessions ADD COLUMN machine TEXT`);
351
+ }
352
+ db.exec(`
353
+ CREATE INDEX IF NOT EXISTS idx_sessions_machine_ts ON sessions(machine, timestamp DESC);
354
+ CREATE INDEX IF NOT EXISTS idx_sessions_agent_ts ON sessions(agent, timestamp DESC);
355
+ `);
356
+ const rows = db
357
+ .prepare(`SELECT id, agent, file_path FROM sessions WHERE machine IS NULL OR machine = ''`)
358
+ .all();
359
+ const upd = db.prepare(`UPDATE sessions SET machine = ? WHERE id = ?`);
360
+ const txn = db.transaction((items) => {
361
+ for (const r of items) {
362
+ upd.run(machineForSessionFile(r.file_path, r.agent), r.id);
363
+ }
364
+ });
365
+ txn(rows);
366
+ }
367
+ if (fromVersion < 19) {
368
+ // v18 → v19: actor provenance (RUSH-2018) — who initiated the session and
369
+ // the actor's kind. Populated at write time from the resolved actor (the
370
+ // pid-registry/spawn path), not derivable from the transcript, so there is
371
+ // no ledger wipe here: a rescan can't backfill these, and forcing a full
372
+ // re-parse would be pure churn. Existing rows stay NULL until re-created.
373
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
374
+ if (!cols.some(c => c.name === 'actor'))
375
+ db.exec(`ALTER TABLE sessions ADD COLUMN actor TEXT`);
376
+ if (!cols.some(c => c.name === 'initiated_by'))
377
+ db.exec(`ALTER TABLE sessions ADD COLUMN initiated_by TEXT`);
378
+ }
379
+ if (fromVersion < 20) {
380
+ // v19 → v20: persist the transcript's model for the static session list.
381
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
382
+ if (!cols.some(c => c.name === 'model'))
383
+ db.exec(`ALTER TABLE sessions ADD COLUMN model TEXT`);
384
+ db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
385
+ }
337
386
  }
338
387
  /** Open (or return the cached) sessions database, applying migrations as needed. */
339
388
  export function getDB() {
@@ -367,6 +416,27 @@ export function getDB() {
367
416
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_last_activity ON sessions(last_activity DESC)`);
368
417
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_origin ON sessions(origin)`);
369
418
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_routine_run_id ON sessions(routine_run_id)`);
419
+ // machine column + indexes: only after the column is guaranteed present.
420
+ // Fresh SCHEMA (v17) includes the column; older DBs get it from migrate v17.
421
+ // If a partial upgrade left schema_version ahead of the column, repair here.
422
+ {
423
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
424
+ if (!cols.some((c) => c.name === 'machine')) {
425
+ db.exec(`ALTER TABLE sessions ADD COLUMN machine TEXT`);
426
+ const rows = db
427
+ .prepare(`SELECT id, agent, file_path FROM sessions WHERE machine IS NULL OR machine = ''`)
428
+ .all();
429
+ const upd = db.prepare(`UPDATE sessions SET machine = ? WHERE id = ?`);
430
+ const txn = db.transaction((items) => {
431
+ for (const r of items) {
432
+ upd.run(machineForSessionFile(r.file_path, r.agent), r.id);
433
+ }
434
+ });
435
+ txn(rows);
436
+ }
437
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_machine_ts ON sessions(machine, timestamp DESC)`);
438
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_agent_ts ON sessions(agent, timestamp DESC)`);
439
+ }
370
440
  // One-shot cleanup of the pre-SQLite JSONL indexes. Safe — nothing reads
371
441
  // them anymore. Guarded by a meta flag so we only try once.
372
442
  const cleaned = db.prepare(`SELECT value FROM meta WHERE key = 'legacy_indexes_removed'`).get();
@@ -672,18 +742,20 @@ const upsertSessionStmt = (db) => db.prepare(`
672
742
  id, short_id, agent, origin, routine_name, routine_run_id,
673
743
  version, account, timestamp, last_activity,
674
744
  project, cwd, git_branch, topic, label, message_count, token_count,
675
- output_tokens, cost_usd, duration_ms,
745
+ output_tokens, cost_usd, duration_ms, model,
676
746
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
677
747
  pr_url, pr_number, worktree_slug, ticket_id, plan, todos,
678
- recent_directories_touched, linear_project, linear_project_url
748
+ recent_directories_touched, linear_project, linear_project_url, machine,
749
+ actor, initiated_by
679
750
  ) VALUES (
680
751
  @id, @short_id, @agent, @origin, @routine_name, @routine_run_id,
681
752
  @version, @account, @timestamp, @last_activity,
682
753
  @project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
683
- @output_tokens, @cost_usd, @duration_ms,
754
+ @output_tokens, @cost_usd, @duration_ms, @model,
684
755
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
685
756
  @pr_url, @pr_number, @worktree_slug, @ticket_id, @plan, @todos,
686
- @recent_directories_touched, @linear_project, @linear_project_url
757
+ @recent_directories_touched, @linear_project, @linear_project_url, @machine,
758
+ @actor, @initiated_by
687
759
  )
688
760
  ON CONFLICT(id) DO UPDATE SET
689
761
  short_id = excluded.short_id,
@@ -713,6 +785,7 @@ const upsertSessionStmt = (db) => db.prepare(`
713
785
  output_tokens = excluded.output_tokens,
714
786
  cost_usd = excluded.cost_usd,
715
787
  duration_ms = excluded.duration_ms,
788
+ model = excluded.model,
716
789
  file_path = excluded.file_path,
717
790
  file_mtime_ms = excluded.file_mtime_ms,
718
791
  file_size = excluded.file_size,
@@ -732,7 +805,16 @@ const upsertSessionStmt = (db) => db.prepare(`
732
805
  linear_project_url = CASE
733
806
  WHEN excluded.ticket_id IS NOT sessions.ticket_id THEN excluded.linear_project_url
734
807
  ELSE COALESCE(excluded.linear_project_url, sessions.linear_project_url)
735
- END
808
+ END,
809
+ machine = excluded.machine,
810
+ -- actor / initiated_by record who launched the session. COALESCE(existing,
811
+ -- incoming) keeps a stored owner (a rescan carries no actor -> excluded.actor
812
+ -- is NULL -> the stored value wins, never clobbered) BUT backfills a row that
813
+ -- was inserted NULL-first — e.g. an older scanner, or any scan that ran before
814
+ -- the actor sidecar landed — once the sidecar-join finally provides one. Plain
815
+ -- exclusion locked those rows to NULL forever (RUSH-2018/2019 fix).
816
+ actor = COALESCE(sessions.actor, excluded.actor),
817
+ initiated_by = COALESCE(sessions.initiated_by, excluded.initiated_by)
736
818
  `);
737
819
  function enrichCachedSessionMeta(meta) {
738
820
  if (!meta.filePath)
@@ -778,12 +860,24 @@ function storedFtsLabel(readLabel, id) {
778
860
  const row = readLabel.get(id);
779
861
  return row?.label ?? '';
780
862
  }
863
+ /** Resolve origin machine for a row: prefer caller-stamped meta, else path. */
864
+ function resolveMachine(meta) {
865
+ if (meta.machine && meta.machine.trim())
866
+ return meta.machine.trim();
867
+ return machineForSessionFile(meta.filePath, meta.agent);
868
+ }
781
869
  /**
782
870
  * Upsert a session row and replace its FTS5 content in a single transaction.
783
871
  * `content` is the tokenizable user-prompt text; pass '' to leave the row unsearchable.
784
872
  */
785
873
  export function upsertSession(meta, content, scan) {
786
874
  meta = enrichCachedSessionMeta(meta);
875
+ // Join the durable sessionId -> actor sidecar (RUSH-2019) when the caller
876
+ // didn't already carry an actor, so a scanned transcript still attributes to a
877
+ // person. The ON CONFLICT COALESCEs actor/initiated_by, so this fills a fresh
878
+ // row AND backfills one indexed null-first (before its sidecar existed), while a
879
+ // rescan carrying no actor still keeps the stored owner.
880
+ const actorRec = meta.actor ? undefined : readSessionActorRecord(meta.id);
787
881
  const db = getDB();
788
882
  const { upsert, delText, insText, readLabel } = stmts(db);
789
883
  const row = {
@@ -807,6 +901,7 @@ export function upsertSession(meta, content, scan) {
807
901
  output_tokens: meta.outputTokens ?? null,
808
902
  cost_usd: meta.costUsd ?? null,
809
903
  duration_ms: meta.durationMs ?? null,
904
+ model: meta.model ?? null,
810
905
  file_path: meta.filePath,
811
906
  file_mtime_ms: scan?.fileMtimeMs ?? null,
812
907
  file_size: scan?.fileSize ?? null,
@@ -821,6 +916,9 @@ export function upsertSession(meta, content, scan) {
821
916
  recent_directories_touched: meta.recentDirectoriesTouched ? JSON.stringify(meta.recentDirectoriesTouched) : null,
822
917
  linear_project: meta.linearProject ?? null,
823
918
  linear_project_url: meta.linearProjectUrl ?? null,
919
+ machine: resolveMachine(meta),
920
+ actor: meta.actor ?? actorRec?.actor ?? null,
921
+ initiated_by: meta.initiatedBy ?? actorRec?.initiatedBy ?? null,
824
922
  };
825
923
  const txn = db.transaction(() => {
826
924
  upsert.run(row);
@@ -839,6 +937,12 @@ export function upsertSessionsBatch(entries) {
839
937
  const db = getDB();
840
938
  const { upsert, delText, insText, readLabel } = stmts(db);
841
939
  const now = Date.now();
940
+ // One directory read for the whole batch: join the durable sessionId -> actor
941
+ // sidecar (RUSH-2019) so scanned transcripts attribute to a person. Only used
942
+ // for entries whose meta carries no actor; the ON CONFLICT COALESCEs the column,
943
+ // so this fills fresh rows AND backfills null-first ones, never clobbering a
944
+ // stored owner on rescan.
945
+ const actorIndex = loadSessionActorIndex();
842
946
  // Persist the Claude resumable-parse continuation (parser_state + content_text)
843
947
  // alongside the stamp. On a full/incremental Claude parse the caller passes the
844
948
  // serialized newState + accumulated user doc so the NEXT scan can resume from
@@ -917,6 +1021,7 @@ export function upsertSessionsBatch(entries) {
917
1021
  output_tokens: meta.outputTokens ?? null,
918
1022
  cost_usd: meta.costUsd ?? null,
919
1023
  duration_ms: meta.durationMs ?? null,
1024
+ model: meta.model ?? null,
920
1025
  file_path: meta.filePath,
921
1026
  file_mtime_ms: scan?.fileMtimeMs ?? null,
922
1027
  file_size: scan?.fileSize ?? null,
@@ -931,6 +1036,9 @@ export function upsertSessionsBatch(entries) {
931
1036
  recent_directories_touched: meta.recentDirectoriesTouched ? JSON.stringify(meta.recentDirectoriesTouched) : null,
932
1037
  linear_project: meta.linearProject ?? null,
933
1038
  linear_project_url: meta.linearProjectUrl ?? null,
1039
+ machine: resolveMachine(meta),
1040
+ actor: meta.actor ?? actorIndex.get(meta.id)?.actor ?? null,
1041
+ initiated_by: meta.initiatedBy ?? actorIndex.get(meta.id)?.initiatedBy ?? null,
934
1042
  });
935
1043
  delText.run(meta.id);
936
1044
  insText.run(meta.id,
@@ -1101,6 +1209,7 @@ function rowToMeta(row) {
1101
1209
  outputTokens: row.output_tokens ?? undefined,
1102
1210
  costUsd: row.cost_usd ?? undefined,
1103
1211
  durationMs: row.duration_ms ?? undefined,
1212
+ model: row.model ?? undefined,
1104
1213
  version: row.version ?? undefined,
1105
1214
  account: row.account ?? undefined,
1106
1215
  topic: row.topic ?? undefined,
@@ -1115,6 +1224,11 @@ function rowToMeta(row) {
1115
1224
  recentDirectoriesTouched: parseJsonColumn(row.recent_directories_touched),
1116
1225
  linearProject: row.linear_project ?? undefined,
1117
1226
  linearProjectUrl: row.linear_project_url ?? undefined,
1227
+ machine: row.machine ?? undefined,
1228
+ actor: row.actor ?? undefined,
1229
+ // Narrow the free-text column to the known kinds; an unexpected value maps
1230
+ // to undefined rather than being asserted as a valid kind.
1231
+ initiatedBy: row.initiated_by === 'human' || row.initiated_by === 'agent' ? row.initiated_by : undefined,
1118
1232
  };
1119
1233
  }
1120
1234
  function parseJsonColumn(value) {
@@ -1287,6 +1401,57 @@ export function countSessions(options = {}) {
1287
1401
  const row = db.prepare(sql).get(...params);
1288
1402
  return row ? row.n : 0;
1289
1403
  }
1404
+ export function queryAffinityRollup(options) {
1405
+ const db = getDB();
1406
+ const where = [];
1407
+ const params = [];
1408
+ const sinceMs = options.sinceMs ?? (Date.now() - 14 * 24 * 60 * 60 * 1000);
1409
+ // ISO timestamps sort lexicographically; compare as string prefix of datetime.
1410
+ where.push(`timestamp >= ?`);
1411
+ params.push(new Date(sinceMs).toISOString());
1412
+ if (options.onlyCli !== false) {
1413
+ where.push(`IFNULL(origin, 'cli') = 'cli'`);
1414
+ }
1415
+ if (options.excludeTeamOrigin !== false) {
1416
+ where.push(`IFNULL(is_team_origin, 0) = 0`);
1417
+ }
1418
+ if (options.agents && options.agents.length > 0) {
1419
+ where.push(`agent IN (${options.agents.map(() => '?').join(',')})`);
1420
+ params.push(...options.agents);
1421
+ }
1422
+ if (options.project) {
1423
+ where.push(`LOWER(IFNULL(project, '')) LIKE ?`);
1424
+ params.push(`%${options.project.toLowerCase()}%`);
1425
+ }
1426
+ let keyExpr;
1427
+ let selectExtra;
1428
+ if (options.groupBy === 'machine') {
1429
+ keyExpr = `IFNULL(NULLIF(machine, ''), '(unknown)')`;
1430
+ selectExtra = `${keyExpr} AS key, ${keyExpr} AS machine, NULL AS agent`;
1431
+ }
1432
+ else if (options.groupBy === 'agent') {
1433
+ keyExpr = `agent`;
1434
+ selectExtra = `agent AS key, NULL AS machine, agent AS agent`;
1435
+ }
1436
+ else {
1437
+ keyExpr = `IFNULL(NULLIF(machine, ''), '(unknown)') || char(9) || agent`;
1438
+ selectExtra = `${keyExpr} AS key, IFNULL(NULLIF(machine, ''), '(unknown)') AS machine, agent AS agent`;
1439
+ }
1440
+ const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
1441
+ const sql = `
1442
+ SELECT
1443
+ ${selectExtra},
1444
+ COUNT(*) AS launches,
1445
+ IFNULL(SUM(duration_ms), 0) AS durationMs,
1446
+ IFNULL(SUM(token_count), 0) AS tokenCount,
1447
+ IFNULL(SUM(cost_usd), 0) AS costUsd
1448
+ FROM sessions
1449
+ ${clause}
1450
+ GROUP BY key
1451
+ ORDER BY launches DESC, key ASC
1452
+ `;
1453
+ return db.prepare(sql).all(...params);
1454
+ }
1290
1455
  /**
1291
1456
  * Aggregate cost / duration / tokens across sessions, grouped by agent,
1292
1457
  * project, or calendar day. Honors the same filter shape as querySessions
@@ -99,31 +99,136 @@ export function toolHistogram(toolCounts, top = 8) {
99
99
  .sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool))
100
100
  .slice(0, top);
101
101
  }
102
- /** Recognized test/build runners → the label we show. */
102
+ /**
103
+ * Recognized test/build runners → the label we show.
104
+ *
105
+ * These match a *real invocation* of a runner, not any command that merely
106
+ * contains a runner token. In particular we must NOT fire on npm-script
107
+ * sub-targets like `bun test:setup`, `npm run test:watch`, or `pnpm test:ci` —
108
+ * those are setup/watch/CI-orchestration scripts, not a test run whose output we
109
+ * can scrape for a pass/fail verdict. A `test` token followed by `:something` is
110
+ * a distinct script name and is excluded via the `(?![:\w-])` guard.
111
+ */
103
112
  const TEST_RUNNERS = [
104
- { re: /\b((?:bun|npm|yarn|pnpm)\s+(?:run\s+)?test|vitest|jest)\b/, label: 'tests' },
105
- { re: /\bpytest\b/, label: 'pytest' },
106
- { re: /\bgo\s+test\b/, label: 'go test' },
107
- { re: /\bcargo\s+test\b/, label: 'cargo test' },
108
- { re: /\b(tsc|tsc\s+--noEmit)\b/, label: 'tsc' },
113
+ // Package-manager `test` script: `bun test`, `npm test`, `npm run test`,
114
+ // `yarn test`, `pnpm test`. The negative lookahead `(?![:\w-])` rejects
115
+ // `test:watch` / `test-ci` / `testfoo` — only the bare `test` target counts.
116
+ { re: /\b(?:bun|npm|yarn|pnpm)\s+(?:run\s+)?test(?![:\w-])/, label: 'tests' },
117
+ // Direct binaries — `vitest`, `jest`, `mocha` (optionally via a runner). These
118
+ // are real invocations; a trailing subcommand like `vitest run` is fine.
119
+ { re: /\b(?:vitest|jest|mocha)(?![:\w-])/, label: 'tests' },
120
+ { re: /\bpytest(?![:\w-])/, label: 'pytest' },
121
+ { re: /\bgo\s+test(?![:\w-])/, label: 'go test' },
122
+ { re: /\bcargo\s+test(?![:\w-])/, label: 'cargo test' },
123
+ { re: /\btsc(?:\s+--noEmit)?(?![:\w-])/, label: 'tsc' },
109
124
  ];
125
+ /**
126
+ * Anchored summary-line extractors, per runner family. Each returns a pass/fail
127
+ * verdict ONLY when it matches that runner's authoritative summary construct —
128
+ * never an arbitrary `\d+ pass`-shaped substring elsewhere in stdout. This is
129
+ * what stops `442 passwords generated`, `442 files`, or `442 passes/sec` from
130
+ * being reported as `442 pass`.
131
+ */
132
+ function parseSummaryLine(output) {
133
+ const lines = output.split(/\r?\n/);
134
+ // vitest: ` Tests 1 failed | 2 passed (3)` (mixed) or ` Tests 2 passed (2)`
135
+ // (all green). Leading indent + the `Tests` / `Test Files` label + double
136
+ // space is the summary row. Prefer the ` Tests ` row (test count) over the
137
+ // ` Test Files ` row (file count).
138
+ for (const label of ['Tests', 'Test Files']) {
139
+ for (const line of lines) {
140
+ const row = new RegExp(`^\\s*${label}\\s{2,}(.+)$`).exec(line);
141
+ if (!row)
142
+ continue;
143
+ const seg = row[1];
144
+ const passed = /(\d+)\s+passed/.exec(seg);
145
+ const failed = /(\d+)\s+failed/.exec(seg);
146
+ // Only accept a vitest row that actually reports a pass/fail count.
147
+ if (passed || failed) {
148
+ return {
149
+ passed: passed ? +passed[1] : undefined,
150
+ failed: failed ? +failed[1] : 0,
151
+ };
152
+ }
153
+ }
154
+ }
155
+ // jest: `Tests: 1 failed, 2 passed, 3 total`. The `Tests:` label anchors
156
+ // it; order of failed/passed varies.
157
+ for (const line of lines) {
158
+ const row = /^\s*Tests:\s+(.+)$/.exec(line);
159
+ if (!row)
160
+ continue;
161
+ const seg = row[1];
162
+ const passed = /(\d+)\s+passed/.exec(seg);
163
+ const failed = /(\d+)\s+failed/.exec(seg);
164
+ if (passed || failed) {
165
+ return { passed: passed ? +passed[1] : undefined, failed: failed ? +failed[1] : 0 };
166
+ }
167
+ }
168
+ // pytest: the summary rule line `==== 3 passed, 1 failed in 0.12s ====`. The
169
+ // surrounding `=` rule and the ` in <dur>s` tail anchor it to the real footer,
170
+ // not a stray `N passed` in captured stdout.
171
+ for (const line of lines) {
172
+ if (!/={3,}.*\bin\s+[\d.]+s\b.*={3,}/.test(line) && !/^={3,}.*={3,}$/.test(line))
173
+ continue;
174
+ if (!/\bin\s+[\d.]+m?s\b/.test(line))
175
+ continue;
176
+ const passed = /(\d+)\s+passed/.exec(line);
177
+ const failed = /(\d+)\s+failed/.exec(line);
178
+ const errors = /(\d+)\s+error/.exec(line);
179
+ if (passed || failed || errors) {
180
+ const failCount = (failed ? +failed[1] : 0) + (errors ? +errors[1] : 0);
181
+ return { passed: passed ? +passed[1] : undefined, failed: failed || errors ? failCount : 0 };
182
+ }
183
+ }
184
+ // bun test: a summary block of ` N pass` / ` N fail` lines (each its own line,
185
+ // leading-indented), closed by `Ran N tests across M files.`. Require the
186
+ // `Ran … tests` sentinel to be present so we only read bun's real summary
187
+ // block — bun also prints inline `(pass)`/`(fail)` per-test lines we ignore.
188
+ if (/^Ran\s+\d+\s+tests?\b/m.test(output)) {
189
+ let passed;
190
+ let failed;
191
+ for (const line of lines) {
192
+ const p = /^\s*(\d+)\s+pass$/.exec(line);
193
+ const f = /^\s*(\d+)\s+fail$/.exec(line);
194
+ if (p)
195
+ passed = +p[1];
196
+ if (f)
197
+ failed = +f[1];
198
+ }
199
+ if (passed !== undefined || failed !== undefined) {
200
+ return { passed, failed: failed ?? 0 };
201
+ }
202
+ }
203
+ // mocha: ` 2 passing` / ` 1 failing` (leading-indented, own line). The
204
+ // `passing`/`failing` gerund is mocha-specific and won't match `442 passwords`.
205
+ {
206
+ let passed;
207
+ let failed;
208
+ for (const line of lines) {
209
+ const p = /^\s*(\d+)\s+passing\b/.exec(line);
210
+ const f = /^\s*(\d+)\s+failing\b/.exec(line);
211
+ if (p)
212
+ passed = +p[1];
213
+ if (f)
214
+ failed = +f[1];
215
+ }
216
+ if (passed !== undefined || failed !== undefined) {
217
+ return { passed, failed: failed ?? 0 };
218
+ }
219
+ }
220
+ return undefined;
221
+ }
110
222
  /** Parse pass/fail counts from common runner output. */
111
223
  function parseTestOutput(runner, output) {
112
- // vitest/jest/bun: "N passed", "N failed"; pytest: "N passed, N failed".
113
- // Take the LAST occurrence runners print a per-file line first, then the
114
- // authoritative aggregate ("Tests 4 failed | 294 passed") at the end.
115
- const lastNum = (re) => {
116
- let m;
117
- let val;
118
- const g = new RegExp(re.source, 'gi');
119
- while ((m = g.exec(output)) !== null)
120
- val = +m[1];
121
- return val;
122
- };
123
- const passed = lastNum(/(\d+)\s+pass(?:ed)?/);
124
- const failed = lastNum(/(\d+)\s+fail(?:ed|ures?)?/);
125
- if (passed !== undefined || failed !== undefined) {
126
- return { passed, failed, ok: true };
224
+ // Anchor to a recognized runner summary construct (vitest ` Tests `, jest
225
+ // `Tests:`, pytest `=== N passed in Xs ===`, bun's `N pass`/`N fail` block,
226
+ // mocha `N passing`) never a blanket `\d+ pass`-shaped scrape over stdout.
227
+ // That blanket scrape used to report `442 passwords`, `442 files passed
228
+ // validation`, or a `442 passes/sec` benchmark as `442 pass`.
229
+ const summary = parseSummaryLine(output);
230
+ if (summary) {
231
+ return { ...summary, ok: true };
127
232
  }
128
233
  // tsc: no news is good news; "error TSxxxx" means failure.
129
234
  if (runner === 'tsc') {
@@ -7,6 +7,7 @@
7
7
  * subsequent queries are served entirely from the cache.
8
8
  */
9
9
  import type { SessionAgentId, SessionEvent, SessionMeta, TodoProgress } from './types.js';
10
+ export { machineForSessionFile } from './origin-machine.js';
10
11
  import { type ScanStamp } from './db.js';
11
12
  /** Options controlling which sessions to discover and how to report progress. */
12
13
  export interface DiscoverOptions {
@@ -53,6 +54,7 @@ interface ClaudeSessionScan {
53
54
  cwd?: string;
54
55
  gitBranch?: string;
55
56
  version?: string;
57
+ model?: string;
56
58
  topic?: string;
57
59
  messageCount: number;
58
60
  tokenCount?: number;
@@ -92,6 +94,7 @@ interface CodexSessionScan {
92
94
  cwd?: string;
93
95
  gitBranch?: string;
94
96
  version?: string;
97
+ model?: string;
95
98
  topic?: string;
96
99
  messageCount: number;
97
100
  tokenCount?: number;
@@ -148,7 +151,6 @@ export declare function scanAgentsBounded<T>(items: readonly T[], run: (item: T)
148
151
  * `agents add --isolated`, where the whole point was to keep the two apart.
149
152
  */
150
153
  export declare function isManagedSessionFile(filePath: string): boolean;
151
- export declare function machineForSessionFile(filePath: string, agent: string): string;
152
154
  /**
153
155
  * Count sessions in scope without running an incremental scan. Assumes the DB
154
156
  * is already fresh (typically true because `discoverSessions` ran first this
@@ -194,6 +196,20 @@ export declare function _normalizeCwdForTest(cwd?: string): string;
194
196
  * ids, whose charset is too permissive to distinguish from a search phrase.
195
197
  */
196
198
  export declare function isCompleteSessionId(query: string): boolean;
199
+ /**
200
+ * Whether a query should be treated as a session id rather than a search phrase
201
+ * — the one canonical id-shaped test, shared by every session-id resolver.
202
+ *
203
+ * True for a complete id (`isCompleteSessionId`) AND for a bare hex short-id or
204
+ * prefix (`d3470b57`), which the complete-id check rejects. Any id-shaped query
205
+ * resolves by id ONLY (exact -> prefix -> `findSessionsById` index) and must
206
+ * never fall back to fuzzy content search: a short id like `d3470b57` otherwise
207
+ * surfaces every transcript that merely MENTIONS the string (a resume prompt
208
+ * echoes the parent id into the body of many later sessions). The hex test
209
+ * catches the bare short-id/prefix; `isCompleteSessionId` additionally catches
210
+ * the prefixed whole ids (`session_…`, `ses_…`) that the hex test rejects.
211
+ */
212
+ export declare function looksLikeSessionId(query: string): boolean;
197
213
  /**
198
214
  * Resolve a session by full or short ID. Accepts a pre-loaded session list
199
215
  * (fast path from discoverSessions) and falls back to a DB lookup for the
@@ -352,6 +368,7 @@ export interface ClaudeParseState {
352
368
  cwd?: string;
353
369
  gitBranch?: string;
354
370
  version?: string;
371
+ model?: string;
355
372
  topic?: string;
356
373
  customTitle?: string;
357
374
  aiTitle?: string;
@@ -410,6 +427,7 @@ export interface ClaudeParserState {
410
427
  cwd?: string;
411
428
  gitBranch?: string;
412
429
  version?: string;
430
+ model?: string;
413
431
  entrypoint?: string;
414
432
  firstTsMs?: number;
415
433
  topic?: string;
@@ -653,4 +671,3 @@ export declare function readGrokMeta(filePath: string, currentVersion?: string):
653
671
  } | null;
654
672
  /** Parse a time filter string (relative like '7d' or ISO timestamp) into epoch milliseconds. */
655
673
  export declare function parseTimeFilter(input: string): number;
656
- export {};