@phnx-labs/agents-cli 1.22.29 → 1.22.31

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 (82) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +44 -5
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/accounts.d.ts +13 -0
  5. package/dist/commands/accounts.js +32 -0
  6. package/dist/commands/daemon.d.ts +18 -0
  7. package/dist/commands/daemon.js +581 -0
  8. package/dist/commands/exec.js +66 -20
  9. package/dist/commands/focus.d.ts +4 -1
  10. package/dist/commands/focus.js +19 -4
  11. package/dist/commands/routines.js +29 -11
  12. package/dist/commands/secrets.d.ts +37 -0
  13. package/dist/commands/secrets.js +86 -105
  14. package/dist/commands/sessions-bookmark.d.ts +20 -0
  15. package/dist/commands/{sessions-favorite.js → sessions-bookmark.js} +42 -42
  16. package/dist/commands/sessions-browser.d.ts +10 -8
  17. package/dist/commands/sessions-browser.js +61 -32
  18. package/dist/commands/sessions-picker.d.ts +33 -1
  19. package/dist/commands/sessions-picker.js +102 -27
  20. package/dist/commands/sessions-stats.js +1 -1
  21. package/dist/commands/sessions.d.ts +21 -8
  22. package/dist/commands/sessions.js +328 -74
  23. package/dist/commands/view.d.ts +11 -0
  24. package/dist/commands/view.js +56 -29
  25. package/dist/index.js +37 -2
  26. package/dist/lib/account-labels.d.ts +24 -0
  27. package/dist/lib/account-labels.js +72 -0
  28. package/dist/lib/agents.d.ts +32 -1
  29. package/dist/lib/agents.js +96 -31
  30. package/dist/lib/daemon-health.d.ts +24 -0
  31. package/dist/lib/daemon-health.js +84 -0
  32. package/dist/lib/daemon-ticks.d.ts +81 -0
  33. package/dist/lib/daemon-ticks.js +190 -0
  34. package/dist/lib/daemon.d.ts +68 -18
  35. package/dist/lib/daemon.js +303 -338
  36. package/dist/lib/device-config.d.ts +10 -0
  37. package/dist/lib/device-config.js +27 -0
  38. package/dist/lib/exec.d.ts +27 -0
  39. package/dist/lib/exec.js +49 -2
  40. package/dist/lib/hosts/dispatch.d.ts +4 -0
  41. package/dist/lib/hosts/dispatch.js +4 -0
  42. package/dist/lib/hosts/remote-cmd.js +1 -0
  43. package/dist/lib/hosts/run-target.d.ts +1 -0
  44. package/dist/lib/hosts/run-target.js +1 -0
  45. package/dist/lib/import.js +7 -6
  46. package/dist/lib/memory-cache.d.ts +19 -0
  47. package/dist/lib/memory-cache.js +31 -0
  48. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  49. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  50. package/dist/lib/migrate.d.ts +1 -1
  51. package/dist/lib/migrate.js +13 -2
  52. package/dist/lib/picker.d.ts +6 -3
  53. package/dist/lib/picker.js +7 -2
  54. package/dist/lib/routine-activation.d.ts +2 -0
  55. package/dist/lib/routine-activation.js +16 -0
  56. package/dist/lib/runner.d.ts +18 -0
  57. package/dist/lib/runner.js +52 -0
  58. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  59. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  60. package/dist/lib/secrets/agent.d.ts +19 -1
  61. package/dist/lib/secrets/agent.js +32 -6
  62. package/dist/lib/secrets/scope.d.ts +3 -3
  63. package/dist/lib/secrets/scope.js +3 -3
  64. package/dist/lib/secrets/session-store.d.ts +0 -4
  65. package/dist/lib/secrets/session-store.js +0 -5
  66. package/dist/lib/session/{favorites.d.ts → bookmarks.d.ts} +15 -15
  67. package/dist/lib/session/{favorites.js → bookmarks.js} +23 -23
  68. package/dist/lib/session/db.d.ts +15 -0
  69. package/dist/lib/session/db.js +90 -15
  70. package/dist/lib/session/discover.js +91 -39
  71. package/dist/lib/session/parse.d.ts +63 -0
  72. package/dist/lib/session/parse.js +165 -20
  73. package/dist/lib/session/session-cache.d.ts +9 -6
  74. package/dist/lib/session/session-cache.js +23 -6
  75. package/dist/lib/shims.js +12 -0
  76. package/dist/lib/startup/command-registry.d.ts +15 -1
  77. package/dist/lib/startup/command-registry.js +49 -0
  78. package/dist/lib/usage-refresh.js +3 -2
  79. package/dist/lib/usage.d.ts +12 -10
  80. package/dist/lib/usage.js +63 -144
  81. package/package.json +4 -1
  82. package/dist/commands/sessions-favorite.d.ts +0 -20
@@ -19,10 +19,10 @@ import { gatherRemoteList } from '../lib/session/remote-list.js';
19
19
  import { resolveVersionAliasLoose } from '../lib/versions.js';
20
20
  import { AGENTS } from '../lib/agents.js';
21
21
  import { enrichTeamOrigins, safeTeamText } from '../lib/session/team-filter.js';
22
- import { listFavorites, toggleFavorite } from '../lib/session/favorites.js';
22
+ import { listBookmarks, toggleBookmark } from '../lib/session/bookmarks.js';
23
23
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
24
24
  import { buildPreview } from './sessions-picker.js';
25
- import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, matchesTeam, formatLiveStatusHeadline, isRunningLiveSession, matchesLiveStatus, parseAgentFilter, } from './sessions.js';
25
+ import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, matchesTeam, formatLiveStatusHeadline, isRunningLiveSession, matchesLiveStatus, parseAgentFilter, resolveRoutineName, } from './sessions.js';
26
26
  /**
27
27
  * Complete a seed into the filter the picker actually runs on.
28
28
  *
@@ -40,7 +40,7 @@ export function buildInitialFilter(initial) {
40
40
  return {
41
41
  running: initial.running ?? false,
42
42
  teams: initial.teams ?? false,
43
- favorites: initial.favorites ?? false,
43
+ bookmarks: initial.bookmarks ?? false,
44
44
  agent: initial.agent,
45
45
  device: initial.device,
46
46
  team: initial.team,
@@ -128,8 +128,8 @@ export function browserFilterToArgv(f, query = '') {
128
128
  a.push(`--${status === 'orphaned' ? 'orphan' : status}`);
129
129
  if (f.teams)
130
130
  a.push('--teams');
131
- if (f.favorites)
132
- a.push('--favorites');
131
+ if (f.bookmarks)
132
+ a.push('--bookmarks');
133
133
  if (f.agent)
134
134
  a.push('-a', f.agent);
135
135
  if (f.device)
@@ -144,8 +144,11 @@ export function browserFilterToArgv(f, query = '') {
144
144
  a.push('--until', f.until);
145
145
  if (f.project)
146
146
  a.push('--project', f.project);
147
- if (f.routine)
147
+ if (f.routine) {
148
148
  a.push('--routine');
149
+ if (typeof f.routine === 'string')
150
+ a.push(f.routine);
151
+ }
149
152
  if (f.skill)
150
153
  a.push('--skill', f.skill);
151
154
  if (f.plugin)
@@ -179,7 +182,8 @@ export function activeBrowserSeed(opts) {
179
182
  return {
180
183
  running: true,
181
184
  teams: !!opts.teams,
182
- favorites: !!opts.favorites,
185
+ bookmarks: !!opts.bookmarks,
186
+ routine: opts.routine ?? false,
183
187
  agent: opts.agent,
184
188
  projectScope: 'all',
185
189
  device: normalizeDeviceSeed(opts.host?.[0]),
@@ -211,7 +215,8 @@ export function bareBrowserSeed(opts) {
211
215
  const wholeTeam = !!opts.inTeam;
212
216
  return {
213
217
  teams: !!opts.teams,
214
- favorites: !!opts.favorites,
218
+ bookmarks: !!opts.bookmarks,
219
+ routine: opts.routine ?? false,
215
220
  agent: opts.agent,
216
221
  // The filter carries one device; seed it only when the scope names exactly
217
222
  // one, so a two-device scope isn't narrowed to the first of them.
@@ -339,8 +344,11 @@ export function remotePoolArgs(f, fixedFilters) {
339
344
  forwarded.push('--until', f.until);
340
345
  if (f.project)
341
346
  forwarded.push('--project', f.project);
342
- if (f.routine)
347
+ if (f.routine) {
343
348
  forwarded.push('--routine');
349
+ if (typeof f.routine === 'string')
350
+ forwarded.push(f.routine);
351
+ }
344
352
  if (f.skill)
345
353
  forwarded.push('--skill', f.skill);
346
354
  if (f.plugin)
@@ -417,6 +425,8 @@ export function liveSessionToMeta(a, self) {
417
425
  prNumber: a.pr?.number,
418
426
  ticketId: a.ticket?.id,
419
427
  worktreeSlug: a.worktree?.slug,
428
+ origin: a.origin,
429
+ routineName: a.routineName,
420
430
  };
421
431
  }
422
432
  /**
@@ -453,14 +463,22 @@ export function shouldShowHostColumn(f, live, rows) {
453
463
  return false;
454
464
  return rows.some((r) => liveHostLabel(live.get(r.id)) !== '');
455
465
  }
456
- /** Apply the cheap in-memory filters (agent / device / project / running / favorites). */
457
- export function applyFilters(rows, live, f, self, favorites) {
466
+ /** Apply the cheap in-memory filters (agent / device / project / running / bookmarks). */
467
+ export function applyFilters(rows, live, f, self, bookmarks) {
458
468
  let out = rows;
459
469
  // A projected live row is keyed by pid/task when it has no session id, and a
460
- // favorite is always keyed by a real session id — so an id-less row can never
461
- // be favorited and correctly drops out here.
462
- if (f.favorites)
463
- out = out.filter((r) => favorites.has(r.id));
470
+ // A bookmark is always keyed by a real session id — so an id-less row can
471
+ // never be bookmarked and correctly drops out here.
472
+ if (f.bookmarks)
473
+ out = out.filter((r) => bookmarks.has(r.id));
474
+ if (f.routine) {
475
+ out = out.filter((r) => r.origin === 'routine' || !!r.routineName);
476
+ if (typeof f.routine === 'string') {
477
+ const routineNames = distinct(out.map((r) => r.routineName));
478
+ const selected = resolveRoutineName(f.routine, routineNames);
479
+ out = selected ? out.filter((r) => r.routineName === selected) : [];
480
+ }
481
+ }
464
482
  if (f.agent) {
465
483
  const { agent, version: rawVersion } = parseAgentFilter(f.agent);
466
484
  const localVersion = agent && agent in AGENTS
@@ -527,7 +545,7 @@ export async function collectSessionCandidates(initial, opts = {}) {
527
545
  const rows = filter.running || opts.includeLive
528
546
  ? mergeLiveIntoPool(pool.rows, liveById, self, includeUnindexedLive)
529
547
  : pool.rows;
530
- const sessions = applyFilters(rows, liveById, filter, self, listFavorites());
548
+ const sessions = applyFilters(rows, liveById, filter, self, listBookmarks());
531
549
  return { sessions, liveById, self, unreachable: pool.unreachable };
532
550
  }
533
551
  /** Derive the SSH-launch origin tag for a picker row from the live index. Set
@@ -552,15 +570,17 @@ function headerFor(f) {
552
570
  bits.push('running');
553
571
  if (f.teams)
554
572
  bits.push('teams');
555
- if (f.favorites)
556
- bits.push('favorites');
573
+ if (f.routine)
574
+ bits.push(`routine:${typeof f.routine === 'string' ? f.routine : 'all'}`);
575
+ if (f.bookmarks)
576
+ bits.push('bookmarks');
557
577
  return bits.join(' · ');
558
578
  }
559
579
  function helpFor(_f, mode) {
560
580
  if (mode === 'search') {
561
581
  return 'type to filter · ↑↓ navigate · esc exit search · ⏎ resume';
562
582
  }
563
- return 's search · r running · f favorites · * star · c teams · t team · a agent · d device · p project · w window · tab preview · y copy-cmd · ⏎ resume · esc quit';
583
+ return 's search · r running · b bookmarks · * bookmark · f focus · c teams · t team · a agent · d device · p project · w window · tab preview · y copy-cmd · ⏎ resume · esc quit';
564
584
  }
565
585
  /**
566
586
  * Launch the interactive session browser. `initial` seeds the filter (e.g.
@@ -588,10 +608,11 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
588
608
  // The live index is slow (a full ps/tmux scan) and only the running filter
589
609
  // needs it — fetch it once, lazily, the first time running is toggled on.
590
610
  let liveCache = null;
611
+ const liveFor = (id) => liveCache?.get(id);
591
612
  // Re-read every load (it's an mtime-memoized parse of one small file), so the
592
- // `*` key's reload picks up the star it just wrote — and so does a favorite
593
- // starred by another session on this machine.
594
- let favorites = new Set();
613
+ // `*` key's reload picks up the bookmark it just wrote — and so does one
614
+ // bookmarked by another session on this machine.
615
+ let bookmarks = new Set();
595
616
  // Generation guard: two quick keypresses can start overlapping loads whose
596
617
  // SSH fan-outs settle out of order. dynamicPicker's own gen ref guards which
597
618
  // rows become `items`, but the shared closure state below (cols / cycle pools /
@@ -652,8 +673,8 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
652
673
  ...rows.map((r) => safeTeamText(r.spawnedTeam)),
653
674
  ...rows.map((r) => safeTeamText(r.teamOrigin?.team)),
654
675
  ]);
655
- favorites = listFavorites();
656
- const filtered = applyFilters(rows, live ?? new Map(), f, self, favorites);
676
+ bookmarks = listBookmarks();
677
+ const filtered = applyFilters(rows, live ?? new Map(), f, self, bookmarks);
657
678
  cols = pickerColumnsFor(filtered);
658
679
  cols.showHost = shouldShowHostColumn(f, live, filtered);
659
680
  // Status rides the same gate as the host column: both come from the live
@@ -667,14 +688,14 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
667
688
  initialFilter,
668
689
  load,
669
690
  keyFor: (s) => s.id,
670
- labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id), liveHostLabel(liveCache?.get(s.id)), favorites.has(s.id), liveCache?.get(s.id)),
691
+ labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id), liveHostLabel(liveCache?.get(s.id)), bookmarks.has(s.id), liveCache?.get(s.id)),
671
692
  matches: sessionMatchesQuery,
672
693
  // Lead the preview with the live status banner — the one place a `crashed` /
673
694
  // `orphaned` session gets a sentence instead of a glyph. `buildPreview` is
674
695
  // memoized per session, so the volatile live half is prepended here rather
675
696
  // than baked into the cached body.
676
697
  buildPreview: (s) => {
677
- const headline = formatLiveStatusHeadline(liveCache?.get(s.id), favorites.has(s.id));
698
+ const headline = formatLiveStatusHeadline(liveCache?.get(s.id), bookmarks.has(s.id));
678
699
  const body = buildPreview(s);
679
700
  return headline ? `${headline}\n${body}` : body;
680
701
  },
@@ -685,9 +706,10 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
685
706
  enterHint: 'resume',
686
707
  emptyMessage: 'No sessions match this filter.',
687
708
  loadingMessage: local ? 'Loading…' : 'Loading (reaching other machines)…',
709
+ submitKeys: { f: 'focus' },
688
710
  keyBindings: {
689
711
  r: (f) => ({ ...f, running: !f.running }),
690
- f: (f) => ({ ...f, favorites: !f.favorites }),
712
+ b: (f) => ({ ...f, bookmarks: !f.bookmarks }),
691
713
  c: (f) => ({ ...f, teams: !f.teams }),
692
714
  a: (f) => ({ ...f, agent: cycle(f.agent, agentsInPool) }),
693
715
  d: (f) => ({ ...f, device: cycle(f.device, devicesInPool) }),
@@ -700,13 +722,13 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
700
722
  },
701
723
  onKey: (name, f, active, query) => {
702
724
  if (name === '*') {
703
- // Only a row with a real session id can be starred: a projected live row
725
+ // Only a row with a real session id can be bookmarked: a projected live row
704
726
  // with no id is keyed by pid, which is gone the moment the process is.
705
727
  if (!active || active.id.startsWith(LIVE_ROW_PREFIX))
706
- return 'nothing to star on this row';
707
- const on = toggleFavorite(active.id);
708
- // reload so the row's star is repainted — labels are memoized per row.
709
- return { flash: on ? `★ favorited ${active.shortId}` : `☆ unfavorited ${active.shortId}`, reload: true };
728
+ return 'nothing to bookmark on this row';
729
+ const on = toggleBookmark(active.id);
730
+ // Reload so the row's bookmark glyph is repainted — labels are memoized per row.
731
+ return { flash: on ? `★ bookmarked ${active.shortId}` : `☆ unbookmarked ${active.shortId}`, reload: true };
710
732
  }
711
733
  // Both cases: `hotkeyToken` hands `onKey` the literal character, and this
712
734
  // key worked with caps lock on before it existed.
@@ -722,5 +744,12 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
722
744
  });
723
745
  if (!picked)
724
746
  return;
747
+ if (picked.action === 'focus') {
748
+ // `focus.ts` consumes this browser for its selector path, so import only
749
+ // after selection to keep the shared picker/focus dependency acyclic.
750
+ const { focusSelectedSession } = await import('./focus.js');
751
+ await focusSelectedSession(picked.item, liveFor(picked.item.id), self);
752
+ return;
753
+ }
725
754
  await handlePickedSession({ session: picked.item, action: 'resume' });
726
755
  }
@@ -1,5 +1,6 @@
1
1
  import type { SessionEvent, SessionMeta, TodoProgress } from '../lib/session/types.js';
2
- import { classifyFileChanges } from '../lib/session/digest.js';
2
+ import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
3
+ import { extractArtifacts, extractHooks, extractLinks, extractSkills } from '../lib/session/highlights.js';
3
4
  /**
4
5
  * Compact checklist tally for list rows and previews (RUSH-2045).
5
6
  * Example: `✓6/8 · A5 wiring runner`. Empty string when there is no list.
@@ -31,6 +32,12 @@ export interface SessionPickerConfig {
31
32
  /** Lines the caller printed above the prompt (hidden-session footer). */
32
33
  linesAbovePrompt?: number;
33
34
  }
35
+ export declare function clearPreviewMemoryCacheForTest(): void;
36
+ export declare function loadSessionPreviewDigest(session: SessionMeta): {
37
+ digest?: SessionPreviewDigest;
38
+ events: SessionEvent[];
39
+ error?: string;
40
+ };
34
41
  /** Build a cached multi-line preview string for display in the session picker. */
35
42
  export declare function buildPreview(session: SessionMeta): string;
36
43
  /**
@@ -65,6 +72,31 @@ export declare function extractTiming(session: Pick<SessionMeta, 'timestamp' | '
65
72
  lastActiveAgo?: string;
66
73
  duration?: string;
67
74
  };
75
+ export interface SessionPreviewDigest {
76
+ schemaVersion: 1;
77
+ firstUser: string;
78
+ lastAssistant: string;
79
+ filesRead: number;
80
+ toolCalls: number;
81
+ planFile: string;
82
+ todos?: TodoProgress;
83
+ subAgentCount: number;
84
+ toolTags: string[];
85
+ changes: ReturnType<typeof changeCounts>;
86
+ dirs: string[];
87
+ repos: string[];
88
+ artifacts: ReturnType<typeof extractArtifacts>;
89
+ skills: ReturnType<typeof extractSkills>;
90
+ plugins: string[];
91
+ hooks: ReturnType<typeof extractHooks>;
92
+ links: ReturnType<typeof extractLinks>;
93
+ errorCount: number;
94
+ firstError?: string;
95
+ toolHistogram: ReturnType<typeof toolHistogram>;
96
+ test: ReturnType<typeof detectTestResult>;
97
+ }
98
+ /** Fold a harness-normalized event stream into the stable preview data model. */
99
+ export declare function buildSessionPreviewDigest(events: SessionEvent[], session: SessionMeta): SessionPreviewDigest;
68
100
  /**
69
101
  * Unique directories the session touched, compact and human-readable.
70
102
  * Prefer `session.recentDirectoriesTouched` — the scan records it on the row, so
@@ -17,8 +17,10 @@ import { linearIssueUrl } from '../lib/session/linear.js';
17
17
  import { extractTodoProgress, WORKTREE_RE } from '../lib/session/state.js';
18
18
  import { renderMarkdown } from '../lib/markdown.js';
19
19
  import { itemPicker } from '../lib/picker.js';
20
+ import { createMemoryCache } from '../lib/memory-cache.js';
20
21
  import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
21
22
  import { extractArtifacts, extractHooks, extractLinks, extractRepos, extractSkills } from '../lib/session/highlights.js';
23
+ import { getSessionPlugins, readSessionPreviewCache, writeSessionPreviewCache } from '../lib/session/db.js';
22
24
  /** A session whose transcript FILE is on another machine (folded in over the
23
25
  * live cross-machine fan-out): its `filePath` is on that peer's disk, so the
24
26
  * preview can't parse it locally — it shows metadata + a "resume there" note
@@ -100,11 +102,71 @@ function sanitizeMeta(s) {
100
102
  todos,
101
103
  };
102
104
  }
103
- const previewCache = new Map();
105
+ const previewCache = createMemoryCache({
106
+ max: 256,
107
+ ttlMs: 5 * 60_000,
108
+ });
109
+ function previewCacheKey(session, remote) {
110
+ let fileStamp = '';
111
+ if (!remote && session.filePath) {
112
+ try {
113
+ const stat = fs.statSync(session.filePath);
114
+ fileStamp = `${stat.mtimeMs}:${stat.size}`;
115
+ }
116
+ catch {
117
+ fileStamp = 'missing';
118
+ }
119
+ }
120
+ // The transcript stamp invalidates derived activity. Metadata that can change
121
+ // independently (label/ticket/PR/live scanner enrichment) also rides the key.
122
+ return JSON.stringify([
123
+ remote ?? '', session.id, fileStamp, session.lastActivity, session.label,
124
+ session.topic, session.ticketId, session.prUrl, session.messageCount,
125
+ session.tokenCount, session.model, session.todos, session.plan,
126
+ session.recentDirectoriesTouched, session.skillsUsed,
127
+ ]);
128
+ }
129
+ export function clearPreviewMemoryCacheForTest() {
130
+ previewCache.clear();
131
+ }
132
+ export function loadSessionPreviewDigest(session) {
133
+ if (!session.filePath || !fs.existsSync(session.filePath))
134
+ return { events: [] };
135
+ const safe = sanitizeMeta(session);
136
+ let events = [];
137
+ let sourceStamp;
138
+ try {
139
+ sourceStamp = fs.statSync(session.filePath);
140
+ }
141
+ catch (err) {
142
+ return { events, error: sanitizeForTerminal(err?.message ?? String(err)) };
143
+ }
144
+ let digest = readSessionPreviewCache(session.id, {
145
+ fileMtimeMs: sourceStamp.mtimeMs,
146
+ fileSize: sourceStamp.size,
147
+ });
148
+ if (!digest) {
149
+ try {
150
+ events = parseSession(session.filePath, session.agent);
151
+ digest = buildSessionPreviewDigest(events, safe);
152
+ writeSessionPreviewCache({
153
+ id: session.id,
154
+ fileMtimeMs: sourceStamp.mtimeMs,
155
+ fileSize: sourceStamp.size,
156
+ preview: digest,
157
+ });
158
+ }
159
+ catch (err) {
160
+ return { events, error: sanitizeForTerminal(err?.message ?? String(err)) };
161
+ }
162
+ }
163
+ digest.plugins = getSessionPlugins(session.id);
164
+ return { digest, events };
165
+ }
104
166
  /** Build a cached multi-line preview string for display in the session picker. */
105
167
  export function buildPreview(session) {
106
168
  const remote = transcriptOnPeerOf(session);
107
- const cacheKey = remote ? `${remote}:${session.id}` : session.id;
169
+ const cacheKey = previewCacheKey(session, remote);
108
170
  const cached = previewCache.get(cacheKey);
109
171
  if (cached)
110
172
  return cached;
@@ -129,18 +191,11 @@ export function buildPreview(session) {
129
191
  previewCache.set(cacheKey, output);
130
192
  return output;
131
193
  }
132
- let events = [];
133
- let parseError;
134
- try {
135
- events = parseSession(session.filePath, session.agent);
136
- }
137
- catch (err) {
138
- parseError = sanitizeForTerminal(err?.message ?? String(err));
139
- }
194
+ const { digest, events, error: parseError } = loadSessionPreviewDigest(session);
140
195
  const header = formatHeader(safe, events);
141
196
  const body = parseError
142
197
  ? ' ' + chalk.red(`Failed to parse session: ${parseError}`)
143
- : formatCompactPreview(events, safe);
198
+ : formatCompactPreview(digest, safe);
144
199
  const output = [header, '', body].filter(Boolean).join('\n');
145
200
  previewCache.set(cacheKey, output);
146
201
  return output;
@@ -368,7 +423,8 @@ const LAST_RESPONSE_MAX_LINES = 15;
368
423
  const LAST_RESPONSE_MAX_LINES_WITH_TODOS = 8;
369
424
  const TODOS_MAX_ITEMS = 5;
370
425
  const DIRS_TOUCHED_MAX = 5;
371
- function formatCompactPreview(events, session) {
426
+ /** Fold a harness-normalized event stream into the stable preview data model. */
427
+ export function buildSessionPreviewDigest(events, session) {
372
428
  let firstUser = '';
373
429
  let lastAssistant = '';
374
430
  const filesRead = new Set();
@@ -436,9 +492,35 @@ function formatCompactPreview(events, session) {
436
492
  // checklist write); fall back to SessionMeta.todos for rows where the scan/
437
493
  // fan-out attached progress but the event stream has no TodoWrite yet.
438
494
  const todos = latestTodos ?? session.todos;
439
- // Digest signals folded into the preview: change lifecycle, tool mix, tests.
440
495
  const changes = classifyFileChanges(events);
441
496
  const chg = changeCounts(changes);
497
+ const errorEvents = events.filter(e => e.type === 'error');
498
+ return {
499
+ schemaVersion: 1,
500
+ firstUser,
501
+ lastAssistant,
502
+ filesRead: filesRead.size,
503
+ toolCalls,
504
+ planFile,
505
+ todos,
506
+ subAgentCount,
507
+ toolTags: [...toolTags],
508
+ changes: chg,
509
+ dirs: directoriesTouched(session, events, changes),
510
+ repos: extractRepos(events, session.cwd),
511
+ artifacts: extractArtifacts(changes),
512
+ skills: extractSkills(events),
513
+ plugins: getSessionPlugins(session.id),
514
+ hooks: extractHooks(events),
515
+ links: extractLinks(events),
516
+ errorCount: errorEvents.length,
517
+ firstError: errorEvents[0]?.tool,
518
+ toolHistogram: toolHistogram(toolCounts, 4),
519
+ test: detectTestResult(events),
520
+ };
521
+ }
522
+ function formatCompactPreview(digest, session) {
523
+ const { firstUser, lastAssistant, filesRead, toolCalls, planFile, todos, subAgentCount, toolTags, changes: chg, dirs, repos, artifacts, skills, plugins, hooks, links, errorCount, firstError, toolHistogram: hist, test, } = digest;
442
524
  const lines = [];
443
525
  const termWidth = process.stdout.columns || 80;
444
526
  // Originating user prompt (first non-system user turn).
@@ -464,12 +546,10 @@ function formatCompactPreview(events, session) {
464
546
  // Recent activity = directories touched (not raw tool calls). Prefer a
465
547
  // parser-supplied dirsTouched when present; else derive from event paths.
466
548
  // Width-capped: a long Dirs line used to wrap and swamp the whole pane.
467
- const dirs = directoriesTouched(session, events, changes);
468
549
  if (dirs.length) {
469
550
  lines.push(chalk.cyan('Dirs: ') + joinWidthCapped(dirs, termWidth - 12));
470
551
  }
471
552
  // Repos worked in (basename of each `.git` root under the touched paths).
472
- const repos = extractRepos(events, session.cwd);
473
553
  if (repos.length) {
474
554
  lines.push(chalk.cyan('Repos: ') + chalk.white(repos.slice(0, 4).join(chalk.gray(' · '))));
475
555
  }
@@ -487,8 +567,8 @@ function formatCompactPreview(events, session) {
487
567
  ].filter(Boolean).join(' ');
488
568
  activity.push(`${parts} ${chalk.gray('changed')}`);
489
569
  }
490
- if (filesRead.size)
491
- activity.push(chalk.gray(`${filesRead.size} read`));
570
+ if (filesRead)
571
+ activity.push(chalk.gray(`${filesRead} read`));
492
572
  if (toolCalls)
493
573
  activity.push(chalk.gray(`${toolCalls} tool${toolCalls === 1 ? '' : 's'}`));
494
574
  if (activity.length) {
@@ -496,38 +576,35 @@ function formatCompactPreview(events, session) {
496
576
  }
497
577
  // Documents the session produced (`.agents/artifacts|plans|reports`, other
498
578
  // *.md/*.html creations) — the files a human browses later, named + clickable.
499
- const artifacts = extractArtifacts(changes);
500
579
  if (artifacts.length) {
501
580
  const shown = artifacts.slice(0, 5).map(a => linkPath(a.path, a.basename));
502
581
  const more = artifacts.length > 5 ? chalk.gray(` · +${artifacts.length - 5} more`) : '';
503
582
  lines.push(chalk.cyan('Artifacts: ') + shown.join(chalk.gray(' · ')) + more);
504
583
  }
505
584
  // Skills invoked (plugin skills included — they ride the same Skill tool).
506
- const skills = extractSkills(events);
507
585
  if (skills.length) {
508
586
  const shown = skills.slice(0, 5).map(s => chalk.white(s.name) + (s.count > 1 ? chalk.gray(` ×${s.count}`) : ''));
509
587
  const more = skills.length > 5 ? chalk.gray(` · +${skills.length - 5} more`) : '';
510
588
  lines.push(chalk.cyan('Skills: ') + shown.join(chalk.gray(' · ')) + more);
511
589
  }
590
+ if (plugins.length) {
591
+ lines.push(chalk.cyan('Plugins: ') + chalk.white(plugins.slice(0, 5).join(chalk.gray(' · '))));
592
+ }
512
593
  // Hooks fired (Claude transcripts record firings; other harnesses don't).
513
- const hooks = extractHooks(events);
514
594
  if (hooks.length) {
515
595
  const shown = hooks.slice(0, 4).map(h => chalk.white(h.name) + (h.count > 1 ? chalk.gray(` ×${h.count}`) : '') + (h.failed ? chalk.red(` (${h.failed} failed)`) : ''));
516
596
  const more = hooks.length > 4 ? chalk.gray(` · +${hooks.length - 4} more`) : '';
517
597
  lines.push(chalk.cyan('Hooks: ') + shown.join(chalk.gray(' · ')) + more);
518
598
  }
519
599
  // Links mentioned in the conversation — clickable (OSC 8), tracker-classified.
520
- const links = extractLinks(events);
521
600
  if (links.length) {
522
601
  const shown = links.slice(0, 5).map(l => chalk.blue(linkUrl(l.url, l.label)));
523
602
  const more = links.length > 5 ? chalk.gray(` · +${links.length - 5} more`) : '';
524
603
  lines.push(chalk.cyan('Links: ') + shown.join(chalk.gray(' · ')) + more);
525
604
  }
526
605
  // Error tally, mirroring the full summary's Errors section in one line.
527
- const errorEvents = events.filter(e => e.type === 'error');
528
- if (errorEvents.length) {
529
- const first = errorEvents[0].tool || 'unknown';
530
- lines.push(chalk.cyan('Errors: ') + chalk.red(`${errorEvents.length} failure${errorEvents.length === 1 ? '' : 's'}`) + chalk.gray(` — first: ${first}`));
606
+ if (errorCount) {
607
+ lines.push(chalk.cyan('Errors: ') + chalk.red(`${errorCount} failure${errorCount === 1 ? '' : 's'}`) + chalk.gray(` — first: ${firstError || 'unknown'}`));
531
608
  }
532
609
  const metadata = [
533
610
  ...toolTags,
@@ -537,12 +614,10 @@ function formatCompactPreview(events, session) {
537
614
  lines.push(chalk.cyan('Meta: ') + chalk.gray(metadata.join(' · ')));
538
615
  }
539
616
  // Tool mix (top 4) — what kind of work this was.
540
- const hist = toolHistogram(toolCounts, 4);
541
617
  if (hist.length) {
542
618
  lines.push(chalk.cyan('Tools: ') + chalk.gray(hist.map(h => `${h.tool} ${h.count}`).join(' · ')));
543
619
  }
544
620
  // Last test/build verdict.
545
- const test = detectTestResult(events);
546
621
  if (test?.ok) {
547
622
  const bits = [
548
623
  test.passed !== undefined ? chalk.green(`${test.passed} pass`) : '',
@@ -95,7 +95,7 @@ async function statsAction(cmd) {
95
95
  // The parent `sessions` command owns --agent/--project/--plugin/--since/--json
96
96
  // and keeps parsing them past the subcommand name (it has a positional
97
97
  // [query]), binding them to the PARENT — so read the merged view, not the
98
- // action's own options. Same reason sessions-favorite.ts uses optsWithGlobals.
98
+ // action's own options. Same reason sessions-bookmark.ts uses optsWithGlobals.
99
99
  const opts = cmd.optsWithGlobals();
100
100
  const kind = normalizeKind(opts.kind);
101
101
  const sinceMs = opts.since ? parseTimeFilter(opts.since) : undefined;
@@ -7,6 +7,9 @@ import { type PickedSession } from './sessions-picker.js';
7
7
  import { type ToolSearchEnvelope, type ToolProgramCountEnvelope } from '../lib/session/tool-index.js';
8
8
  interface SessionFilterOptions {
9
9
  agent?: string;
10
+ version?: string;
11
+ /** Internal Commander spelling after index.ts disambiguates root --version. */
12
+ sessionVersion?: string;
10
13
  project?: string;
11
14
  all?: boolean;
12
15
  teams?: boolean;
@@ -56,8 +59,8 @@ interface SessionsOptions extends SessionFilterOptions {
56
59
  abandoned?: boolean;
57
60
  queued?: boolean;
58
61
  unknown?: boolean;
59
- /** Show only favorited (starred) sessions — the `f` key's flag twin. */
60
- favorites?: boolean;
62
+ /** Show only bookmarked sessions — the `b` key's flag twin. */
63
+ bookmarks?: boolean;
61
64
  /** Enrich the listing with live glyphs/preview for running rows. Default on;
62
65
  * `--no-live` sets this false. Commander's `--no-` convention. */
63
66
  live?: boolean;
@@ -94,6 +97,12 @@ interface SessionsOptions extends SessionFilterOptions {
94
97
  /** Only sessions that used a skill/command owned by this plugin (#12). */
95
98
  plugin?: string;
96
99
  }
100
+ type InstalledVersionsForAgent = (agent: SessionAgentId) => string[];
101
+ /**
102
+ * Treat a positional `agent@version` as a structured filter only when it names
103
+ * a real installed version. Everything else remains ordinary free-text search.
104
+ */
105
+ export declare function parseInstalledAgentVersionQuery(query: string | undefined, installedVersions?: InstalledVersionsForAgent): string | undefined;
97
106
  /**
98
107
  * Strip terminal/harness noise from a preview so the column stays a single line
99
108
  * of plain prose: OSC title escapes, CSI/SGR ANSI, and the harness wrapper tags
@@ -390,6 +399,8 @@ export declare function gatherActiveSessions(opts?: {
390
399
  sessions: ActiveSession[];
391
400
  remoteDeviceCount: number;
392
401
  }>;
402
+ /** Apply the routine selector to enriched live rows for every non-browser active view. */
403
+ export declare function filterActiveSessionsByRoutine(sessions: ActiveSession[], routine: boolean | string | undefined): ActiveSession[];
393
404
  export type LiveStatusFilter = 'working' | 'idle' | 'waiting' | 'orphaned' | 'crashed' | 'closed' | 'abandoned' | 'queued' | 'unknown';
394
405
  /** Match the status words users see, preserving activity's richer working signal. */
395
406
  export declare function matchesLiveStatus(session: ActiveSession, status: LiveStatusFilter): boolean;
@@ -431,6 +442,8 @@ export declare function renderSessionPreview(query: string, scope: {
431
442
  agent?: string;
432
443
  project?: string;
433
444
  local?: boolean;
445
+ hosts?: string[];
446
+ json?: boolean;
434
447
  }): Promise<void>;
435
448
  /**
436
449
  * The one-line live status banner shown above a session preview: the glyph, the
@@ -442,7 +455,7 @@ export declare function renderSessionPreview(query: string, scope: {
442
455
  * reads "orphan" and knows it means "still running in tmux with no window
443
456
  * attached", so those two spell it out.
444
457
  */
445
- export declare function formatLiveStatusHeadline(live: ActiveSession | undefined, favorite?: boolean): string;
458
+ export declare function formatLiveStatusHeadline(live: ActiveSession | undefined, bookmarked?: boolean): string;
446
459
  /** Merge local and peer envelopes without changing the versioned JSON shape. */
447
460
  export declare function mergeToolSearchEnvelopes(local: ToolSearchEnvelope, remotes: ToolSearchEnvelope[]): ToolSearchEnvelope;
448
461
  export declare function mergeToolProgramCountEnvelopes(local: ToolProgramCountEnvelope, remotes: ToolProgramCountEnvelope[]): ToolProgramCountEnvelope;
@@ -476,7 +489,7 @@ export declare function teamBadge(session: SessionMeta): {
476
489
  * (tracker/PR ref, pulled out of the badge blob so refs align) is only rendered
477
490
  * when `showTicket` — otherwise a listing with no refs would waste a column of
478
491
  * dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
479
- export declare function flatSessionRow(session: SessionMeta, live?: ActiveSession, showTicket?: boolean, cols?: PickerColumns, favorite?: boolean): string;
492
+ export declare function flatSessionRow(session: SessionMeta, live?: ActiveSession, showTicket?: boolean, cols?: PickerColumns, bookmarked?: boolean): string;
480
493
  /**
481
494
  * Live-session index for enriching the default listing, or undefined when
482
495
  * enrichment is off (`--no-live`) or irrelevant (`--json`, which serializes
@@ -560,11 +573,11 @@ export interface PickerColumns {
560
573
  */
561
574
  showHost?: boolean;
562
575
  /**
563
- * Render the favorite marker column. Like every other conditional column here,
576
+ * Render the bookmark marker column. Like every other conditional column here,
564
577
  * it earns its 2 cells only when some row in the pool is actually starred — a
565
- * user who has never favorited anything pays nothing for the feature.
578
+ * user who has never bookmarked anything pays nothing for the feature.
566
579
  */
567
- showFavorite?: boolean;
580
+ showBookmark?: boolean;
568
581
  /**
569
582
  * Render the live status column (`working` / `waiting` / `orphan` / `crashed`).
570
583
  * Live-only, gated the same way as {@link showHost}: it comes from the
@@ -601,7 +614,7 @@ export declare function pickerColumnsFor(sessions: SessionMeta[]): PickerColumns
601
614
  * resolvable host (cloud rows, an unreadable process env).
602
615
  */
603
616
  export declare function liveHostLabel(a: ActiveSession | undefined): string;
604
- export declare function formatPickerLabel(s: SessionMeta, query: string, cols?: PickerColumns, ssh?: SshOriginTag, host?: string, favorite?: boolean, live?: ActiveSession): string;
617
+ export declare function formatPickerLabel(s: SessionMeta, query: string, cols?: PickerColumns, ssh?: SshOriginTag, host?: string, bookmarked?: boolean, live?: ActiveSession): string;
605
618
  /**
606
619
  * Pick a hint to show above the picker. Deterministic (keys off the pool size)
607
620
  * so it stays fixed across the picker's re-renders within a single run.