@phnx-labs/agents-cli 1.22.30 → 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.
@@ -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 · * favorite · 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 favorite it just wrote — and so does one
593
- // favorited 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 favorited: 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 favorite on this row';
707
- const on = toggleFavorite(active.id);
708
- // reload so the row's favorite glyph 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
  }
@@ -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;
@@ -59,8 +59,8 @@ interface SessionsOptions extends SessionFilterOptions {
59
59
  abandoned?: boolean;
60
60
  queued?: boolean;
61
61
  unknown?: boolean;
62
- /** Show only favorited sessions — the `f` key's flag twin. */
63
- favorites?: boolean;
62
+ /** Show only bookmarked sessions — the `b` key's flag twin. */
63
+ bookmarks?: boolean;
64
64
  /** Enrich the listing with live glyphs/preview for running rows. Default on;
65
65
  * `--no-live` sets this false. Commander's `--no-` convention. */
66
66
  live?: boolean;
@@ -399,6 +399,8 @@ export declare function gatherActiveSessions(opts?: {
399
399
  sessions: ActiveSession[];
400
400
  remoteDeviceCount: number;
401
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[];
402
404
  export type LiveStatusFilter = 'working' | 'idle' | 'waiting' | 'orphaned' | 'crashed' | 'closed' | 'abandoned' | 'queued' | 'unknown';
403
405
  /** Match the status words users see, preserving activity's richer working signal. */
404
406
  export declare function matchesLiveStatus(session: ActiveSession, status: LiveStatusFilter): boolean;
@@ -453,7 +455,7 @@ export declare function renderSessionPreview(query: string, scope: {
453
455
  * reads "orphan" and knows it means "still running in tmux with no window
454
456
  * attached", so those two spell it out.
455
457
  */
456
- export declare function formatLiveStatusHeadline(live: ActiveSession | undefined, favorite?: boolean): string;
458
+ export declare function formatLiveStatusHeadline(live: ActiveSession | undefined, bookmarked?: boolean): string;
457
459
  /** Merge local and peer envelopes without changing the versioned JSON shape. */
458
460
  export declare function mergeToolSearchEnvelopes(local: ToolSearchEnvelope, remotes: ToolSearchEnvelope[]): ToolSearchEnvelope;
459
461
  export declare function mergeToolProgramCountEnvelopes(local: ToolProgramCountEnvelope, remotes: ToolProgramCountEnvelope[]): ToolProgramCountEnvelope;
@@ -487,7 +489,7 @@ export declare function teamBadge(session: SessionMeta): {
487
489
  * (tracker/PR ref, pulled out of the badge blob so refs align) is only rendered
488
490
  * when `showTicket` — otherwise a listing with no refs would waste a column of
489
491
  * dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
490
- 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;
491
493
  /**
492
494
  * Live-session index for enriching the default listing, or undefined when
493
495
  * enrichment is off (`--no-live`) or irrelevant (`--json`, which serializes
@@ -571,11 +573,11 @@ export interface PickerColumns {
571
573
  */
572
574
  showHost?: boolean;
573
575
  /**
574
- * Render the favorite marker column. Like every other conditional column here,
576
+ * Render the bookmark marker column. Like every other conditional column here,
575
577
  * it earns its 2 cells only when some row in the pool is actually starred — a
576
- * user who has never favorited anything pays nothing for the feature.
578
+ * user who has never bookmarked anything pays nothing for the feature.
577
579
  */
578
- showFavorite?: boolean;
580
+ showBookmark?: boolean;
579
581
  /**
580
582
  * Render the live status column (`working` / `waiting` / `orphan` / `crashed`).
581
583
  * Live-only, gated the same way as {@link showHost}: it comes from the
@@ -612,7 +614,7 @@ export declare function pickerColumnsFor(sessions: SessionMeta[]): PickerColumns
612
614
  * resolvable host (cloud rows, an unreadable process env).
613
615
  */
614
616
  export declare function liveHostLabel(a: ActiveSession | undefined): string;
615
- 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;
616
618
  /**
617
619
  * Pick a hint to show above the picker. Deterministic (keys off the pool size)
618
620
  * so it stays fixed across the picker's re-renders within a single run.
@@ -55,8 +55,8 @@ import { setHelpSections } from '../lib/help.js';
55
55
  import { registerSessionsTailCommand } from './sessions-tail.js';
56
56
  import { registerSessionsResumeCommand } from './sessions-resume.js';
57
57
  import { registerSessionsForkCommand } from './fork.js';
58
- import { registerSessionsFavoriteCommand } from './sessions-favorite.js';
59
- import { isFavorite, listFavorites } from '../lib/session/favorites.js';
58
+ import { registerSessionsBookmarkCommand } from './sessions-bookmark.js';
59
+ import { isBookmarked, listBookmarks } from '../lib/session/bookmarks.js';
60
60
  import { registerGoCommand } from './go.js';
61
61
  import { registerFocusCommand } from './focus.js';
62
62
  import { registerReconnectCommand } from './reconnect.js';
@@ -1361,26 +1361,25 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
1361
1361
  const self = machineId();
1362
1362
  const gathered = await gatherActiveSessions(opts);
1363
1363
  const { remoteDeviceCount } = gathered;
1364
- // --favorites narrows the live view too. Applied HERE, not only in the
1364
+ // Backfill agent version, refs, created time, and routine provenance from the
1365
+ // historical index. Routine filtering needs that provenance before it narrows
1366
+ // the live pool; doing it here also enriches both JSON and human output.
1367
+ backfillActiveRowsFromIndex(gathered.sessions);
1368
+ // --bookmarks narrows the live view too. Applied HERE, not only in the
1365
1369
  // browser: the browser is skipped for --json, --waiting, a pipe, a multi-host
1366
1370
  // scope, and an SSH-fanout peer, and the flag silently did nothing on every
1367
- // one of those paths — including `--active --favorites --json`, which is
1371
+ // one of those paths — including `--active --bookmarks --json`, which is
1368
1372
  // exactly what the browser's own `y` copy-cmd hands to an agent.
1369
- const merged = opts.favoritesOnly
1370
- ? gathered.sessions.filter((s) => !!s.sessionId && listFavorites().has(s.sessionId))
1373
+ const merged = opts.bookmarksOnly
1374
+ ? gathered.sessions.filter((s) => !!s.sessionId && listBookmarks().has(s.sessionId))
1371
1375
  : gathered.sessions;
1376
+ const routineFiltered = filterActiveSessionsByRoutine(merged, opts.routine);
1372
1377
  // Status flags form a union. --waiting additionally retains its scriptable
1373
1378
  // gate: exit non-zero when the union contains a session awaiting the user.
1374
1379
  const statusFiltered = opts.statuses?.length
1375
- ? merged.filter((session) => opts.statuses.some((status) => matchesLiveStatus(session, status)))
1376
- : merged.filter(isRunningLiveSession);
1380
+ ? routineFiltered.filter((session) => opts.statuses.some((status) => matchesLiveStatus(session, status)))
1381
+ : routineFiltered.filter(isRunningLiveSession);
1377
1382
  const sessions = statusFiltered;
1378
- // Backfill agent version + ticket/PR/label/created onto the live rows from the
1379
- // historical index (RUSH-2205) — a running process reports none of these, and
1380
- // an orphan row usually lacks them. Done before both the JSON and human paths
1381
- // so every consumer (incl. the SSH fan-out's remote --json) sees enriched rows;
1382
- // transcripts sync across the fleet, so a remote row resolves from the local DB.
1383
- backfillActiveRowsFromIndex(sessions);
1384
1383
  if (asJson) {
1385
1384
  // Resolve who is watching each local tmux pane before serializing: `viewingIn`
1386
1385
  // is how a consumer distinguishes a session someone is looking at from one
@@ -1426,6 +1425,19 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
1426
1425
  if (waitingOnly && sessions.some(isAwaitingUser))
1427
1426
  process.exitCode = 1;
1428
1427
  }
1428
+ /** Apply the routine selector to enriched live rows for every non-browser active view. */
1429
+ export function filterActiveSessionsByRoutine(sessions, routine) {
1430
+ if (!routine)
1431
+ return sessions;
1432
+ const routineSessions = sessions.filter((session) => session.origin === 'routine' || !!session.routineName);
1433
+ if (typeof routine !== 'string')
1434
+ return routineSessions;
1435
+ const names = [...new Set(routineSessions.map((session) => session.routineName).filter((name) => !!name))];
1436
+ const selected = resolveRoutineName(routine, names);
1437
+ return selected
1438
+ ? routineSessions.filter((session) => session.routineName === selected)
1439
+ : [];
1440
+ }
1429
1441
  /** Match the status words users see, preserving activity's richer working signal. */
1430
1442
  export function matchesLiveStatus(session, status) {
1431
1443
  if (status === 'working')
@@ -1492,7 +1504,9 @@ export function isBareBrowserListing(options, query) {
1492
1504
  */
1493
1505
  export function hasNoBrowserDisqualifyingFlags(options, query) {
1494
1506
  return (!query &&
1495
- !options.routine &&
1507
+ // A named team view is a flat selectable pool; the bare --teams report
1508
+ // stays grouped and printed because the browser cannot preserve its shape.
1509
+ (!options.teams || !!options.inTeam) &&
1496
1510
  !options.flat &&
1497
1511
  !options.tree &&
1498
1512
  !options.markdown &&
@@ -1657,8 +1671,8 @@ function canonicalSessionsCommand(query, options) {
1657
1671
  a.push('--local');
1658
1672
  if (options.waiting)
1659
1673
  a.push('--waiting');
1660
- if (options.favorites)
1661
- a.push('--favorites');
1674
+ if (options.bookmarks)
1675
+ a.push('--bookmarks');
1662
1676
  const q = (query ?? '').trim();
1663
1677
  if (q)
1664
1678
  a.push(JSON.stringify(q));
@@ -1768,7 +1782,7 @@ export async function renderSessionPreview(query, scope) {
1768
1782
  }));
1769
1783
  return;
1770
1784
  }
1771
- const headline = formatLiveStatusHeadline(live, isFavorite(session.id));
1785
+ const headline = formatLiveStatusHeadline(live, isBookmarked(session.id));
1772
1786
  if (headline)
1773
1787
  console.log(headline);
1774
1788
  console.log(buildPreview(session));
@@ -1783,12 +1797,12 @@ export async function renderSessionPreview(query, scope) {
1783
1797
  * reads "orphan" and knows it means "still running in tmux with no window
1784
1798
  * attached", so those two spell it out.
1785
1799
  */
1786
- export function formatLiveStatusHeadline(live, favorite = false) {
1787
- const star = favorite ? chalk.yellow('★ ') : '';
1800
+ export function formatLiveStatusHeadline(live, bookmarked = false) {
1801
+ const star = bookmarked ? chalk.yellow('★ ') : '';
1788
1802
  // With no live row there is no status to lead with, so the star has to say
1789
1803
  // what it means on its own — a bare `★` above a preview reads as noise.
1790
1804
  if (!live)
1791
- return favorite ? chalk.yellow('★ favorited') : '';
1805
+ return bookmarked ? chalk.yellow('★ bookmarked') : '';
1792
1806
  const { glyph } = liveGlyphAndPreview(live);
1793
1807
  const word = liveStatusWord(live) || live.status;
1794
1808
  // One definition, shared with `--waiting`. A second local copy drifted: the
@@ -2140,7 +2154,8 @@ limitSource) {
2140
2154
  host: options.host,
2141
2155
  since: options.since,
2142
2156
  all: options.all,
2143
- favorites: options.favorites,
2157
+ bookmarks: options.bookmarks,
2158
+ routine: options.routine,
2144
2159
  }), { local: options.local === true, hosts: options.host });
2145
2160
  return;
2146
2161
  }
@@ -2150,8 +2165,9 @@ limitSource) {
2150
2165
  await renderActiveSessions(options.json === true, options.waiting === true, {
2151
2166
  local: forceLocal,
2152
2167
  hosts: options.host,
2153
- favoritesOnly: options.favorites === true,
2168
+ bookmarksOnly: options.bookmarks === true,
2154
2169
  statuses: liveStatuses,
2170
+ routine: options.routine,
2155
2171
  });
2156
2172
  return;
2157
2173
  }
@@ -2165,12 +2181,10 @@ limitSource) {
2165
2181
  // printed/render paths (agents and scripts unaffected). An explicit --since seeds
2166
2182
  // the browser's window so the flag is honored, not swallowed.
2167
2183
  //
2168
- // `--teams` diverts to the printed team-grouped report (printTeamsView, below):
2169
- // grouping teammates under their team is a shape the flat fuzzy picker cannot
2170
- // represent, so like --tree it is a printed listing, not an interactive pick.
2171
- // --teams --flat/--tree keep their inline table rendering; a search query keeps
2172
- // the picker so `<term> --teams` still searches.
2173
- if (isBareBrowserListing(options, query) && !options.teams) {
2184
+ // Bare `--teams` still diverts to the printed team-grouped report because the
2185
+ // flat picker cannot preserve that shape. `--in-team <name> --teams` is one
2186
+ // flat lineage, so it qualifies through hasNoBrowserDisqualifyingFlags.
2187
+ if (isBareBrowserListing(options, query)) {
2174
2188
  const { runSessionBrowser, bareBrowserSeed } = await import('./sessions-browser.js');
2175
2189
  await runSessionBrowser(bareBrowserSeed({
2176
2190
  teams: options.teams,
@@ -2179,7 +2193,8 @@ limitSource) {
2179
2193
  since: options.since,
2180
2194
  host: options.host,
2181
2195
  inTeam: options.inTeam,
2182
- favorites: options.favorites,
2196
+ bookmarks: options.bookmarks,
2197
+ routine: options.routine,
2183
2198
  }), { local: options.local === true, hosts: options.host });
2184
2199
  return;
2185
2200
  }
@@ -2349,11 +2364,11 @@ limitSource) {
2349
2364
  // read. Match either, after that pass has populated `teamOrigin`.
2350
2365
  if (options.inTeam)
2351
2366
  sessions = sessions.filter((s) => matchesTeam(s, options.inTeam));
2352
- // --favorites narrows to the starred set. Applied here, before the JSON
2353
- // emit, so `--favorites --json` is the machine-readable twin of the `f` key.
2354
- if (options.favorites) {
2355
- const starred = listFavorites();
2356
- sessions = sessions.filter((s) => starred.has(s.id));
2367
+ // --bookmarks narrows to the bookmarked set. Applied here, before the JSON
2368
+ // emit, so `--bookmarks --json` is the machine-readable twin of the `b` key.
2369
+ if (options.bookmarks) {
2370
+ const bookmarks = listBookmarks();
2371
+ sessions = sessions.filter((s) => bookmarks.has(s.id));
2357
2372
  }
2358
2373
  if (toolEvidenceMode) {
2359
2374
  const self = toolSelf;
@@ -2659,7 +2674,7 @@ function timeCell(age, topicSlack) {
2659
2674
  * (tracker/PR ref, pulled out of the badge blob so refs align) is only rendered
2660
2675
  * when `showTicket` — otherwise a listing with no refs would waste a column of
2661
2676
  * dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
2662
- export function flatSessionRow(session, live, showTicket = false, cols = {}, favorite = false) {
2677
+ export function flatSessionRow(session, live, showTicket = false, cols = {}, bookmarked = false) {
2663
2678
  const agentColor = colorAgent(session.agent);
2664
2679
  const age = sessionAgeParts(session.timestamp, session.lastActivity);
2665
2680
  const project = session.project || '-';
@@ -2696,18 +2711,18 @@ export function flatSessionRow(session, live, showTicket = false, cols = {}, fav
2696
2711
  const width = terminalWidth();
2697
2712
  const requestedModelW = cols.showModel ? (cols.modelWidth ?? PICKER_MODEL_MAX) : 0;
2698
2713
  // Same conditional 2 cells as the picker's marker, for the same reason.
2699
- const favW = cols.showFavorite ? 2 : 0;
2700
- const favCell = cols.showFavorite ? (favorite ? chalk.yellow('★ ') : ' ') : '';
2714
+ const bookmarkW = cols.showBookmark ? 2 : 0;
2715
+ const bookmarkCell = cols.showBookmark ? (bookmarked ? chalk.yellow('★ ') : ' ') : '';
2701
2716
  // Sized against the last-activity label alone, so the creation field is an
2702
2717
  // additive decision the row makes only once it knows what space is left.
2703
- const fixedW = favW + (10 + 9 + 8 + 16) + glyphW + statusW + machineW + ticketW + wtW + team.width + stringWidth(age.last) + 1;
2718
+ const fixedW = bookmarkW + (10 + 9 + 8 + 16) + glyphW + statusW + machineW + ticketW + wtW + team.width + stringWidth(age.last) + 1;
2704
2719
  const modelSlack = width - fixedW - MIN_TOPIC_W;
2705
2720
  const modelW = requestedModelW <= modelSlack
2706
2721
  ? requestedModelW
2707
2722
  : modelSlack >= PICKER_MODEL_MIN ? modelSlack : 0;
2708
2723
  const when = timeCell(age, width - fixedW - modelW);
2709
2724
  const topicW = Math.max(MIN_TOPIC_W, width - fixedW - modelW - when.extraW);
2710
- return (favCell +
2725
+ return (bookmarkCell +
2711
2726
  chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
2712
2727
  agentColor(padToWidth(truncateToWidth(session.agent, 8), 9)) +
2713
2728
  chalk.yellow(padToWidth(truncateToWidth(session.version || '-', 7), 8)) +
@@ -2907,9 +2922,9 @@ function printSessionTable(sessions, hiddenCount = 0, tree = false, liveIndex) {
2907
2922
  // column (and its compact labels) is computed the same way the picker does it.
2908
2923
  const showTicket = sessions.some((s) => ticketLabel(s) !== '');
2909
2924
  const cols = pickerColumnsFor(sessions);
2910
- const favorites = listFavorites();
2925
+ const bookmarks = listBookmarks();
2911
2926
  for (const session of sessions) {
2912
- console.log(flatSessionRow(session, liveIndex?.get(session.id), showTicket, cols, favorites.has(session.id)));
2927
+ console.log(flatSessionRow(session, liveIndex?.get(session.id), showTicket, cols, bookmarks.has(session.id)));
2913
2928
  }
2914
2929
  const countLine = `${sessions.length} session${sessions.length === 1 ? '' : 's'}.`;
2915
2930
  console.log(chalk.gray(`\n${countLine}`));
@@ -3252,10 +3267,10 @@ export function pickerColumnsFor(sessions) {
3252
3267
  showModel: sessions.some((s) => !!s.model),
3253
3268
  modelWidth: modelColumnWidth(sessions),
3254
3269
  showTicket: sessions.some((s) => ticketLabel(s) !== ''),
3255
- showFavorite: (() => {
3270
+ showBookmark: (() => {
3256
3271
  // One read of the store per pool, not one per row.
3257
- const starred = listFavorites();
3258
- return starred.size > 0 && sessions.some((s) => starred.has(s.id));
3272
+ const bookmarks = listBookmarks();
3273
+ return bookmarks.size > 0 && sessions.some((s) => bookmarks.has(s.id));
3259
3274
  })(),
3260
3275
  };
3261
3276
  }
@@ -3275,7 +3290,7 @@ export function liveHostLabel(a) {
3275
3290
  const viewer = a.viewingIn?.app;
3276
3291
  return viewer && viewer !== a.host ? `${a.host}→${viewer}` : a.host;
3277
3292
  }
3278
- export function formatPickerLabel(s, query, cols = {}, ssh, host = '', favorite = false, live) {
3293
+ export function formatPickerLabel(s, query, cols = {}, ssh, host = '', bookmarked = false, live) {
3279
3294
  const agentColor = colorAgent(s.agent);
3280
3295
  const age = sessionAgeParts(s.timestamp, s.lastActivity);
3281
3296
  const project = s.project || '-';
@@ -3317,11 +3332,11 @@ export function formatPickerLabel(s, query, cols = {}, ssh, host = '', favorite
3317
3332
  const ticketW = cols.showTicket ? TICKET_W + 1 : 0;
3318
3333
  const hostW = cols.showHost ? PICKER_HOST_W : 0;
3319
3334
  const wtW = wt ? stringWidth(wt) + 1 : 0;
3320
- // Within a pool that HAS starred rows the marker holds its 2 cells whether this
3321
- // row is starred or not, so the columns after it never jog; a pool with none
3322
- // drops the column entirely (`showFavorite`) and costs nothing.
3323
- const favW = cols.showFavorite ? 2 : 0;
3324
- const favCell = cols.showFavorite ? (favorite ? chalk.yellow('★ ') : ' ') : '';
3335
+ // Within a pool that HAS bookmarked rows the marker holds its 2 cells whether
3336
+ // this row is bookmarked or not, so the columns after it never jog; a pool
3337
+ // with none drops the column entirely (`showBookmark`) and costs nothing.
3338
+ const bookmarkW = cols.showBookmark ? 2 : 0;
3339
+ const bookmarkCell = cols.showBookmark ? (bookmarked ? chalk.yellow('★ ') : ' ') : '';
3325
3340
  // The same status word the flat listing shows, so a session that is `orphan`
3326
3341
  // or `crashed` reads that way in the browser too — not only in its preview.
3327
3342
  // Constant width whenever the column is on. `liveStatusCell` already pads its
@@ -3333,10 +3348,10 @@ export function formatPickerLabel(s, query, cols = {}, ssh, host = '', favorite
3333
3348
  const statusCell = cols.showStatus ? (status.cell || ' '.repeat(LIVE_STATUS_W)) : '';
3334
3349
  // Sized against the last-activity label alone; the creation field is then an
3335
3350
  // additive decision made against whatever width is left (see the flat listing).
3336
- const baseTopicW = terminalWidth() - gutter - favW - statusW - (10 + 9 + 8 + 16) - machineColW - hostW - ticketW - wtW - sshW - team.width - stringWidth(age.last) - 1;
3351
+ const baseTopicW = terminalWidth() - gutter - bookmarkW - statusW - (10 + 9 + 8 + 16) - machineColW - hostW - ticketW - wtW - sshW - team.width - stringWidth(age.last) - 1;
3337
3352
  const when = timeCell(age, baseTopicW);
3338
3353
  const topicW = Math.max(MIN_TOPIC_W, baseTopicW - when.extraW);
3339
- return (favCell +
3354
+ return (bookmarkCell +
3340
3355
  // Truncated, not just padded: an indexed shortId is always 8 chars, but a
3341
3356
  // live row with no session id is named by its pid or cloud task, which can
3342
3357
  // run past the column and shunt every later column out of alignment.
@@ -4459,6 +4474,7 @@ export function registerSessionsCommands(program) {
4459
4474
  .option('--grok', 'Shorthand for --agent grok')
4460
4475
  .option('--opencode', 'Shorthand for --agent opencode')
4461
4476
  .option('--all', 'Widen every non-status filter to "all": every directory (not just this project) and all time (no window cap). Status filters like --active still compose; -a/--device/--since still narrow their axis.')
4477
+ .option('--bookmarks', 'Show only bookmarked sessions — bookmark them with `*` in the browser or `agents sessions bookmark <id>`')
4462
4478
  .option('--unmanaged', "Also show sessions from your own ~/.<agent> installs (hidden once agents-cli manages that agent)")
4463
4479
  .option('--team, --teams', 'Show team-spawned sessions (hidden by default), grouped by team — each team names its spawner and spawn time, teammates show their mode + handle, and team-flagged spawns with no teammate record sink into a (no team) bucket. --flat/--tree keep the plain inline table')
4464
4480
  .option('--in-team <name>', "Only this team: the session that spawned it plus (with --teams) its teammates. Spans every directory and all time, since a team's worktrees and history sit outside the default window.")
@@ -4492,7 +4508,6 @@ export function registerSessionsCommands(program) {
4492
4508
  .option('--abandoned', 'Show sessions with no transcript progress for the abandonment window (implies --active)')
4493
4509
  .option('--queued', 'Show queued sessions that have not started running (implies --active)')
4494
4510
  .option('--unknown', 'Show sessions whose live state cannot be determined (implies --active)')
4495
- .option('--favorites', 'Show only favorited sessions — favorite them with `*` in the browser or `agents sessions favorite <id>`')
4496
4511
  .option('--tree', 'Group the listing by directory; drops the id/version columns for readability')
4497
4512
  .option('--flat', 'Plain flat table (one row per session) instead of the grouped project overview')
4498
4513
  .option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
@@ -4662,7 +4677,7 @@ export function registerSessionsCommands(program) {
4662
4677
  registerSessionsTailCommand(sessionsCmd);
4663
4678
  registerSessionsResumeCommand(sessionsCmd);
4664
4679
  registerSessionsForkCommand(sessionsCmd);
4665
- registerSessionsFavoriteCommand(sessionsCmd);
4680
+ registerSessionsBookmarkCommand(sessionsCmd);
4666
4681
  registerGoCommand(sessionsCmd);
4667
4682
  registerFocusCommand(sessionsCmd);
4668
4683
  registerReconnectCommand(sessionsCmd);
package/dist/index.js CHANGED
@@ -1247,7 +1247,7 @@ if (process.env.AGENTS_SKIP_MIGRATION !== '1') {
1247
1247
  // Bumping the suffix re-runs migrations for every user; binary releases that
1248
1248
  // don't change the schema must NOT re-run (they would destroy user content
1249
1249
  // when migration steps overlap with user-authored paths). See issue #20.
1250
- const sentinelValue = 'v17';
1250
+ const sentinelValue = 'v18';
1251
1251
  let needRun = true;
1252
1252
  try {
1253
1253
  if (fs.existsSync(sentinel) && fs.readFileSync(sentinel, 'utf-8').trim() === sentinelValue) {
@@ -1015,6 +1015,11 @@ function migrateRuntimeToHistory() {
1015
1015
  catch { /* best-effort */ }
1016
1016
  }
1017
1017
  }
1018
+ /** Rename the session marker store after the product vocabulary changed from
1019
+ * favorite to bookmark. The destination wins if a newer CLI already wrote it. */
1020
+ function migrateLegacySessionMarkersToBookmarks() {
1021
+ moveFileOnce(path.join(HISTORY_DIR, 'favorites.json'), path.join(HISTORY_DIR, 'bookmarks.json'));
1022
+ }
1018
1023
  /**
1019
1024
  * Restore plugins from the cache bucket back to the user-root.
1020
1025
  *
@@ -2178,6 +2183,7 @@ export async function runMigration() {
2178
2183
  migrateSplitDeviceLocalMeta();
2179
2184
  // Bucket moves: collapse runtime state into ~/.agents/.history and ~/.agents/.cache.
2180
2185
  migrateRuntimeToHistory();
2186
+ migrateLegacySessionMarkersToBookmarks();
2181
2187
  migrateRuntimeToCache();
2182
2188
  // Restore plugins (user-authored) from cache back to user-root. Runs AFTER
2183
2189
  // migrateRuntimeToCache so any legacy plugins/ still at the user-root from