@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
@@ -46,17 +46,17 @@ import { getShimsDir } from '../lib/state.js';
46
46
  import { fuzzyMatch, FUZZY_PRESETS } from '../lib/fuzzy.js';
47
47
  import { itemPicker } from '../lib/picker.js';
48
48
  import { resolveSessionAlias } from '../lib/session/actor-sidecar.js';
49
- import { resolveVersionAliasLoose } from '../lib/versions.js';
49
+ import { listInstalledVersions, resolveVersionAliasLoose } from '../lib/versions.js';
50
50
  import { getAgentsInvocation } from '../lib/daemon.js';
51
51
  import { sessionRecoveryRunArgs } from '../lib/session/recovery.js';
52
52
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
53
- import { sessionPicker, buildPreview, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
53
+ import { sessionPicker, buildPreview, loadSessionPreviewDigest, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
54
54
  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';
@@ -95,6 +95,44 @@ function applyAgentShorthands(options) {
95
95
  if (hit)
96
96
  options.agent = hit;
97
97
  }
98
+ /**
99
+ * Treat a positional `agent@version` as a structured filter only when it names
100
+ * a real installed version. Everything else remains ordinary free-text search.
101
+ */
102
+ export function parseInstalledAgentVersionQuery(query, installedVersions = (agent) => (agent in AGENTS ? listInstalledVersions(agent) : [])) {
103
+ const trimmed = query?.trim();
104
+ if (!trimmed)
105
+ return undefined;
106
+ const at = trimmed.indexOf('@');
107
+ if (at <= 0 || at !== trimmed.lastIndexOf('@') || at === trimmed.length - 1)
108
+ return undefined;
109
+ const agentName = trimmed.slice(0, at).toLowerCase();
110
+ if (!SESSION_AGENTS.includes(agentName))
111
+ return undefined;
112
+ const agent = agentName;
113
+ const version = trimmed.slice(at + 1);
114
+ return installedVersions(agent).includes(version) ? `${agent}@${version}` : undefined;
115
+ }
116
+ function applyVersionFilters(query, options) {
117
+ const explicitVersion = options.version ?? options.sessionVersion;
118
+ if (explicitVersion) {
119
+ if (!options.agent) {
120
+ throw new Error('--version requires --agent (for example: --agent claude --version 2.1.181).');
121
+ }
122
+ if (options.agent.includes('@')) {
123
+ throw new Error('Pass the version either in --agent <agent@version> or with --version, not both.');
124
+ }
125
+ options.agent = `${options.agent}@${explicitVersion}`;
126
+ }
127
+ if (!options.agent) {
128
+ const positionalFilter = parseInstalledAgentVersionQuery(query);
129
+ if (positionalFilter) {
130
+ options.agent = positionalFilter;
131
+ return undefined;
132
+ }
133
+ }
134
+ return query;
135
+ }
98
136
  const CLAUDE_RESUME_MATCH_WINDOW_MS = 10 * 60_000;
99
137
  const LOAD_VERBS = ['Loading', 'Scanning', 'Gathering', 'Indexing', 'Reading'];
100
138
  const FIND_VERBS = ['Finding', 'Searching', 'Locating', 'Matching'];
@@ -1323,26 +1361,25 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
1323
1361
  const self = machineId();
1324
1362
  const gathered = await gatherActiveSessions(opts);
1325
1363
  const { remoteDeviceCount } = gathered;
1326
- // --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
1327
1369
  // browser: the browser is skipped for --json, --waiting, a pipe, a multi-host
1328
1370
  // scope, and an SSH-fanout peer, and the flag silently did nothing on every
1329
- // one of those paths — including `--active --favorites --json`, which is
1371
+ // one of those paths — including `--active --bookmarks --json`, which is
1330
1372
  // exactly what the browser's own `y` copy-cmd hands to an agent.
1331
- const merged = opts.favoritesOnly
1332
- ? 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))
1333
1375
  : gathered.sessions;
1376
+ const routineFiltered = filterActiveSessionsByRoutine(merged, opts.routine);
1334
1377
  // Status flags form a union. --waiting additionally retains its scriptable
1335
1378
  // gate: exit non-zero when the union contains a session awaiting the user.
1336
1379
  const statusFiltered = opts.statuses?.length
1337
- ? merged.filter((session) => opts.statuses.some((status) => matchesLiveStatus(session, status)))
1338
- : merged.filter(isRunningLiveSession);
1380
+ ? routineFiltered.filter((session) => opts.statuses.some((status) => matchesLiveStatus(session, status)))
1381
+ : routineFiltered.filter(isRunningLiveSession);
1339
1382
  const sessions = statusFiltered;
1340
- // Backfill agent version + ticket/PR/label/created onto the live rows from the
1341
- // historical index (RUSH-2205) — a running process reports none of these, and
1342
- // an orphan row usually lacks them. Done before both the JSON and human paths
1343
- // so every consumer (incl. the SSH fan-out's remote --json) sees enriched rows;
1344
- // transcripts sync across the fleet, so a remote row resolves from the local DB.
1345
- backfillActiveRowsFromIndex(sessions);
1346
1383
  if (asJson) {
1347
1384
  // Resolve who is watching each local tmux pane before serializing: `viewingIn`
1348
1385
  // is how a consumer distinguishes a session someone is looking at from one
@@ -1388,6 +1425,19 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
1388
1425
  if (waitingOnly && sessions.some(isAwaitingUser))
1389
1426
  process.exitCode = 1;
1390
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
+ }
1391
1441
  /** Match the status words users see, preserving activity's richer working signal. */
1392
1442
  export function matchesLiveStatus(session, status) {
1393
1443
  if (status === 'working')
@@ -1454,7 +1504,9 @@ export function isBareBrowserListing(options, query) {
1454
1504
  */
1455
1505
  export function hasNoBrowserDisqualifyingFlags(options, query) {
1456
1506
  return (!query &&
1457
- !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) &&
1458
1510
  !options.flat &&
1459
1511
  !options.tree &&
1460
1512
  !options.markdown &&
@@ -1619,8 +1671,8 @@ function canonicalSessionsCommand(query, options) {
1619
1671
  a.push('--local');
1620
1672
  if (options.waiting)
1621
1673
  a.push('--waiting');
1622
- if (options.favorites)
1623
- a.push('--favorites');
1674
+ if (options.bookmarks)
1675
+ a.push('--bookmarks');
1624
1676
  const q = (query ?? '').trim();
1625
1677
  if (q)
1626
1678
  a.push(JSON.stringify(q));
@@ -1629,29 +1681,108 @@ function canonicalSessionsCommand(query, options) {
1629
1681
  /** Resolve a session by id/query globally and print its compact preview (no pager).
1630
1682
  * Backs `--preview` — the fast path for the "peek before resume" hot loop. */
1631
1683
  export async function renderSessionPreview(query, scope) {
1632
- const discovered = await discoverSessions({ all: true, cwd: process.cwd(), limit: 5000 });
1633
- const pool = applyScopeFilters(discovered, scope);
1634
- const { matches, completeId } = resolveSessionQuery(pool, query);
1635
- const session = matches[0];
1636
- if (!session) {
1637
- // A complete id that missed is not "no match for this text" — say which, and
1638
- // give the same fleet pointer the render paths give.
1639
- if (completeId)
1640
- notFoundByIdMessage(query).forEach(l => console.log(l));
1641
- else
1642
- console.log(chalk.gray(`No session matches "${query}".`));
1684
+ let outcome = await resolveSessionMetadataValue(query, scope);
1685
+ // A just-created transcript may not have reached the incremental index yet.
1686
+ // Keep the indexed path hot, but repair a local cold miss once before saying
1687
+ // it does not exist. This also preserves Claude history-alias discovery.
1688
+ if (outcome.kind !== 'resolved'
1689
+ && (!scope.hosts?.length || shouldIncludeLocal(scope.hosts, machineId()))) {
1690
+ const discovered = applyScopeFilters(await discoverSessions({ all: true, cwd: process.cwd(), limit: 5000 }), scope);
1691
+ const localMatches = resolveSessionQuery(discovered, query, { indexFallback: false }).matches
1692
+ .map(session => ({ ...session, machine: session.machine || machineId() }));
1693
+ const exact = localMatches.find(session => selectorAllowsEarlyExit(query)
1694
+ && session.id.toLowerCase() === query.trim().toLowerCase());
1695
+ if (exact)
1696
+ outcome = { kind: 'resolved', session: exact };
1697
+ else if (outcome.kind === 'not-found' && localMatches.length > 0) {
1698
+ outcome = metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, query);
1699
+ }
1700
+ }
1701
+ if (outcome.kind === 'partial') {
1702
+ console.error(chalk.red(`Partial session resolution: ${outcome.failedPeers.join(', ')} did not answer.`));
1703
+ console.error(chalk.gray('No preview was rendered because the short ID may be ambiguous on an unreachable peer.'));
1704
+ process.exitCode = 2;
1705
+ return;
1706
+ }
1707
+ if (outcome.kind === 'not-found') {
1708
+ notFoundByIdMessage(query).forEach(l => console.error(l));
1709
+ process.exitCode = 1;
1710
+ return;
1711
+ }
1712
+ if (outcome.kind === 'ambiguous') {
1713
+ console.error(chalk.red(`Multiple sessions match "${query}" across the fleet:`));
1714
+ for (const candidate of outcome.candidates) {
1715
+ const match = candidate.hits[0].session;
1716
+ const machines = candidate.hits.map(hit => hit.machine).join(', ');
1717
+ console.error(chalk.cyan(` ${match.shortId} ${match.id}`) + chalk.gray(` ${machines} ${match.agent}${match.version ? ` ${match.version}` : ''}`));
1718
+ }
1719
+ console.error(chalk.gray('Pass the full session ID to narrow it down.'));
1720
+ process.exitCode = 1;
1721
+ return;
1722
+ }
1723
+ const session = outcome.session;
1724
+ if (session._remote && session.machine && session.machine !== machineId()) {
1725
+ const args = ['sessions', 'preview', session.id, '--local'];
1726
+ if (scope.json)
1727
+ args.push('--json');
1728
+ const rendered = await runOnPeer(args, session.machine);
1729
+ if (rendered === 'no-target') {
1730
+ console.error(chalk.red(`Session ${session.id} is on ${session.machine}, but that device is not reachable.`));
1731
+ process.exitCode = 1;
1732
+ }
1643
1733
  return;
1644
1734
  }
1645
1735
  // Lead with the live status when the session is still running, so the preview
1646
1736
  // says working / waiting / idle up front — not just the historical transcript.
1647
- // `--local --preview` is freely combinable with `--local` (RUSH-2118): thread
1648
- // it through so this probe never dials a remote-host teammate either.
1737
+ // The shared snapshot is accepted for at most 15 seconds. The durable preview
1738
+ // below contains no live status, so a long-lived process can never keep a
1739
+ // stale working/waiting headline in its transcript cache.
1649
1740
  let live;
1650
1741
  try {
1651
- live = indexActiveBySessionId(await getActiveSessions({ localOnly: scope.local === true })).get(session.id);
1742
+ const loaded = await loadLocalActiveSessions();
1743
+ live = indexActiveBySessionId(loaded.sessions).get(session.id);
1652
1744
  }
1653
1745
  catch { /* plain preview on any probe failure */ }
1654
- const headline = formatLiveStatusHeadline(live, isFavorite(session.id));
1746
+ if (scope.json) {
1747
+ const { digest, error } = loadSessionPreviewDigest(session);
1748
+ console.log(JSON.stringify({
1749
+ schemaVersion: 1,
1750
+ session: {
1751
+ id: session.id,
1752
+ shortId: session.shortId,
1753
+ agent: session.agent,
1754
+ version: session.version,
1755
+ model: session.model,
1756
+ account: session.account,
1757
+ machine: session.machine ?? machineId(),
1758
+ cwd: session.cwd,
1759
+ project: session.project,
1760
+ gitBranch: session.gitBranch,
1761
+ createdAt: session.timestamp,
1762
+ lastActivity: session.lastActivity,
1763
+ durationMs: session.durationMs,
1764
+ messageCount: session.messageCount,
1765
+ tokenCount: session.tokenCount,
1766
+ costUsd: session.costUsd,
1767
+ label: session.label,
1768
+ ticketId: session.ticketId,
1769
+ prUrl: session.prUrl,
1770
+ },
1771
+ active: live ? {
1772
+ status: live.status,
1773
+ activity: live.activity,
1774
+ awaitingReason: live.awaitingReason,
1775
+ lastActivityMs: live.lastActivityMs,
1776
+ startedAtMs: live.startedAtMs,
1777
+ pid: live.pid,
1778
+ host: live.host,
1779
+ } : null,
1780
+ preview: digest ?? null,
1781
+ error: error ?? null,
1782
+ }));
1783
+ return;
1784
+ }
1785
+ const headline = formatLiveStatusHeadline(live, isBookmarked(session.id));
1655
1786
  if (headline)
1656
1787
  console.log(headline);
1657
1788
  console.log(buildPreview(session));
@@ -1666,12 +1797,12 @@ export async function renderSessionPreview(query, scope) {
1666
1797
  * reads "orphan" and knows it means "still running in tmux with no window
1667
1798
  * attached", so those two spell it out.
1668
1799
  */
1669
- export function formatLiveStatusHeadline(live, favorite = false) {
1670
- const star = favorite ? chalk.yellow('★ ') : '';
1800
+ export function formatLiveStatusHeadline(live, bookmarked = false) {
1801
+ const star = bookmarked ? chalk.yellow('★ ') : '';
1671
1802
  // With no live row there is no status to lead with, so the star has to say
1672
1803
  // what it means on its own — a bare `★` above a preview reads as noise.
1673
1804
  if (!live)
1674
- return favorite ? chalk.yellow('★ favorited') : '';
1805
+ return bookmarked ? chalk.yellow('★ bookmarked') : '';
1675
1806
  const { glyph } = liveGlyphAndPreview(live);
1676
1807
  const word = liveStatusWord(live) || live.status;
1677
1808
  // One definition, shared with `--waiting`. A second local copy drifted: the
@@ -1879,6 +2010,14 @@ limitSource) {
1879
2010
  // shorthands fold into --agent, and --device is an alias for --host (both
1880
2011
  // resolve against the same device registry).
1881
2012
  applyAgentShorthands(options);
2013
+ try {
2014
+ query = applyVersionFilters(query, options);
2015
+ }
2016
+ catch (error) {
2017
+ console.error(chalk.red(error instanceof Error ? error.message : String(error)));
2018
+ process.exitCode = 1;
2019
+ return;
2020
+ }
1882
2021
  // --device / --devices both alias --host. A bare `all` / `fleet` sentinel means
1883
2022
  // "search every peer" — which is already the default — so it resolves to no
1884
2023
  // explicit host set rather than erroring on a device literally named "all".
@@ -1984,7 +2123,7 @@ limitSource) {
1984
2123
  console.error(chalk.red('--preview requires a session id or query.'));
1985
2124
  process.exit(1);
1986
2125
  }
1987
- await renderSessionPreview(query, { agent: options.agent, project: options.project, local: options.local });
2126
+ await renderSessionPreview(query, { agent: options.agent, project: options.project, local: options.local, hosts: options.host });
1988
2127
  return;
1989
2128
  }
1990
2129
  if (liveOnly) {
@@ -2015,7 +2154,8 @@ limitSource) {
2015
2154
  host: options.host,
2016
2155
  since: options.since,
2017
2156
  all: options.all,
2018
- favorites: options.favorites,
2157
+ bookmarks: options.bookmarks,
2158
+ routine: options.routine,
2019
2159
  }), { local: options.local === true, hosts: options.host });
2020
2160
  return;
2021
2161
  }
@@ -2025,8 +2165,9 @@ limitSource) {
2025
2165
  await renderActiveSessions(options.json === true, options.waiting === true, {
2026
2166
  local: forceLocal,
2027
2167
  hosts: options.host,
2028
- favoritesOnly: options.favorites === true,
2168
+ bookmarksOnly: options.bookmarks === true,
2029
2169
  statuses: liveStatuses,
2170
+ routine: options.routine,
2030
2171
  });
2031
2172
  return;
2032
2173
  }
@@ -2040,12 +2181,10 @@ limitSource) {
2040
2181
  // printed/render paths (agents and scripts unaffected). An explicit --since seeds
2041
2182
  // the browser's window so the flag is honored, not swallowed.
2042
2183
  //
2043
- // `--teams` diverts to the printed team-grouped report (printTeamsView, below):
2044
- // grouping teammates under their team is a shape the flat fuzzy picker cannot
2045
- // represent, so like --tree it is a printed listing, not an interactive pick.
2046
- // --teams --flat/--tree keep their inline table rendering; a search query keeps
2047
- // the picker so `<term> --teams` still searches.
2048
- 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)) {
2049
2188
  const { runSessionBrowser, bareBrowserSeed } = await import('./sessions-browser.js');
2050
2189
  await runSessionBrowser(bareBrowserSeed({
2051
2190
  teams: options.teams,
@@ -2054,7 +2193,8 @@ limitSource) {
2054
2193
  since: options.since,
2055
2194
  host: options.host,
2056
2195
  inTeam: options.inTeam,
2057
- favorites: options.favorites,
2196
+ bookmarks: options.bookmarks,
2197
+ routine: options.routine,
2058
2198
  }), { local: options.local === true, hosts: options.host });
2059
2199
  return;
2060
2200
  }
@@ -2095,6 +2235,12 @@ limitSource) {
2095
2235
  await renderArtifactsGlobal(searchQuery, options.artifacts ?? false, options.artifact, artifactLookupScope(options.agent, options.project, options.routine));
2096
2236
  return;
2097
2237
  }
2238
+ // A supplied ID is already the lookup key. Resolve it directly from SQLite
2239
+ // and, when needed, the peer indexes before any broad transcript discovery.
2240
+ if (!toolEvidenceMode && searchQuery && looksLikeSessionId(searchQuery)) {
2241
+ await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, routine: options.routine, filter: filterOpts, redact: options.redact, local: options.local, hosts: options.host });
2242
+ return;
2243
+ }
2098
2244
  // When the user explicitly asks to render (via mode flag), resolve the
2099
2245
  // query globally so sessions outside the default cwd/30d window are found.
2100
2246
  if (wantsRender && searchQuery) {
@@ -2218,11 +2364,11 @@ limitSource) {
2218
2364
  // read. Match either, after that pass has populated `teamOrigin`.
2219
2365
  if (options.inTeam)
2220
2366
  sessions = sessions.filter((s) => matchesTeam(s, options.inTeam));
2221
- // --favorites narrows to the starred set. Applied here, before the JSON
2222
- // emit, so `--favorites --json` is the machine-readable twin of the `f` key.
2223
- if (options.favorites) {
2224
- const starred = listFavorites();
2225
- 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));
2226
2372
  }
2227
2373
  if (toolEvidenceMode) {
2228
2374
  const self = toolSelf;
@@ -2528,7 +2674,7 @@ function timeCell(age, topicSlack) {
2528
2674
  * (tracker/PR ref, pulled out of the badge blob so refs align) is only rendered
2529
2675
  * when `showTicket` — otherwise a listing with no refs would waste a column of
2530
2676
  * dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
2531
- export function flatSessionRow(session, live, showTicket = false, cols = {}, favorite = false) {
2677
+ export function flatSessionRow(session, live, showTicket = false, cols = {}, bookmarked = false) {
2532
2678
  const agentColor = colorAgent(session.agent);
2533
2679
  const age = sessionAgeParts(session.timestamp, session.lastActivity);
2534
2680
  const project = session.project || '-';
@@ -2565,18 +2711,18 @@ export function flatSessionRow(session, live, showTicket = false, cols = {}, fav
2565
2711
  const width = terminalWidth();
2566
2712
  const requestedModelW = cols.showModel ? (cols.modelWidth ?? PICKER_MODEL_MAX) : 0;
2567
2713
  // Same conditional 2 cells as the picker's marker, for the same reason.
2568
- const favW = cols.showFavorite ? 2 : 0;
2569
- const favCell = cols.showFavorite ? (favorite ? chalk.yellow('★ ') : ' ') : '';
2714
+ const bookmarkW = cols.showBookmark ? 2 : 0;
2715
+ const bookmarkCell = cols.showBookmark ? (bookmarked ? chalk.yellow('★ ') : ' ') : '';
2570
2716
  // Sized against the last-activity label alone, so the creation field is an
2571
2717
  // additive decision the row makes only once it knows what space is left.
2572
- 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;
2573
2719
  const modelSlack = width - fixedW - MIN_TOPIC_W;
2574
2720
  const modelW = requestedModelW <= modelSlack
2575
2721
  ? requestedModelW
2576
2722
  : modelSlack >= PICKER_MODEL_MIN ? modelSlack : 0;
2577
2723
  const when = timeCell(age, width - fixedW - modelW);
2578
2724
  const topicW = Math.max(MIN_TOPIC_W, width - fixedW - modelW - when.extraW);
2579
- return (favCell +
2725
+ return (bookmarkCell +
2580
2726
  chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
2581
2727
  agentColor(padToWidth(truncateToWidth(session.agent, 8), 9)) +
2582
2728
  chalk.yellow(padToWidth(truncateToWidth(session.version || '-', 7), 8)) +
@@ -2776,9 +2922,9 @@ function printSessionTable(sessions, hiddenCount = 0, tree = false, liveIndex) {
2776
2922
  // column (and its compact labels) is computed the same way the picker does it.
2777
2923
  const showTicket = sessions.some((s) => ticketLabel(s) !== '');
2778
2924
  const cols = pickerColumnsFor(sessions);
2779
- const favorites = listFavorites();
2925
+ const bookmarks = listBookmarks();
2780
2926
  for (const session of sessions) {
2781
- 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)));
2782
2928
  }
2783
2929
  const countLine = `${sessions.length} session${sessions.length === 1 ? '' : 's'}.`;
2784
2930
  console.log(chalk.gray(`\n${countLine}`));
@@ -3121,10 +3267,10 @@ export function pickerColumnsFor(sessions) {
3121
3267
  showModel: sessions.some((s) => !!s.model),
3122
3268
  modelWidth: modelColumnWidth(sessions),
3123
3269
  showTicket: sessions.some((s) => ticketLabel(s) !== ''),
3124
- showFavorite: (() => {
3270
+ showBookmark: (() => {
3125
3271
  // One read of the store per pool, not one per row.
3126
- const starred = listFavorites();
3127
- 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));
3128
3274
  })(),
3129
3275
  };
3130
3276
  }
@@ -3144,7 +3290,7 @@ export function liveHostLabel(a) {
3144
3290
  const viewer = a.viewingIn?.app;
3145
3291
  return viewer && viewer !== a.host ? `${a.host}→${viewer}` : a.host;
3146
3292
  }
3147
- export function formatPickerLabel(s, query, cols = {}, ssh, host = '', favorite = false, live) {
3293
+ export function formatPickerLabel(s, query, cols = {}, ssh, host = '', bookmarked = false, live) {
3148
3294
  const agentColor = colorAgent(s.agent);
3149
3295
  const age = sessionAgeParts(s.timestamp, s.lastActivity);
3150
3296
  const project = s.project || '-';
@@ -3186,11 +3332,11 @@ export function formatPickerLabel(s, query, cols = {}, ssh, host = '', favorite
3186
3332
  const ticketW = cols.showTicket ? TICKET_W + 1 : 0;
3187
3333
  const hostW = cols.showHost ? PICKER_HOST_W : 0;
3188
3334
  const wtW = wt ? stringWidth(wt) + 1 : 0;
3189
- // Within a pool that HAS starred rows the marker holds its 2 cells whether this
3190
- // row is starred or not, so the columns after it never jog; a pool with none
3191
- // drops the column entirely (`showFavorite`) and costs nothing.
3192
- const favW = cols.showFavorite ? 2 : 0;
3193
- 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('★ ') : ' ') : '';
3194
3340
  // The same status word the flat listing shows, so a session that is `orphan`
3195
3341
  // or `crashed` reads that way in the browser too — not only in its preview.
3196
3342
  // Constant width whenever the column is on. `liveStatusCell` already pads its
@@ -3202,10 +3348,10 @@ export function formatPickerLabel(s, query, cols = {}, ssh, host = '', favorite
3202
3348
  const statusCell = cols.showStatus ? (status.cell || ' '.repeat(LIVE_STATUS_W)) : '';
3203
3349
  // Sized against the last-activity label alone; the creation field is then an
3204
3350
  // additive decision made against whatever width is left (see the flat listing).
3205
- 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;
3206
3352
  const when = timeCell(age, baseTopicW);
3207
3353
  const topicW = Math.max(MIN_TOPIC_W, baseTopicW - when.extraW);
3208
- return (favCell +
3354
+ return (bookmarkCell +
3209
3355
  // Truncated, not just padded: an indexed shortId is always 8 chars, but a
3210
3356
  // live row with no session id is named by its pid or cloud task, which can
3211
3357
  // run past the column and shunt every later column out of alignment.
@@ -3694,6 +3840,11 @@ export function filterSessionsByQuery(sessions, query) {
3694
3840
  const trimmed = query?.trim().toLowerCase() || '';
3695
3841
  if (!trimmed)
3696
3842
  return sessions;
3843
+ const installedAgentVersion = parseInstalledAgentVersionQuery(trimmed);
3844
+ if (installedAgentVersion) {
3845
+ const { agent, version } = parseAgentFilter(installedAgentVersion);
3846
+ return sessions.filter((session) => session.agent === agent && session.version === version);
3847
+ }
3697
3848
  const terms = trimmed.split(/\s+/).filter(Boolean);
3698
3849
  const contentIndex = searchContentIndex(sessions, trimmed);
3699
3850
  // If the query exactly matches a session label, short-circuit the structural
@@ -3863,6 +4014,53 @@ async function renderArtifactsGlobal(query, listAll, name, scope) {
3863
4014
  }
3864
4015
  }
3865
4016
  async function renderOneSession(query, mode, scope) {
4017
+ if (looksLikeSessionId(query)) {
4018
+ const outcome = await resolveSessionMetadataValue(query, scope);
4019
+ if (outcome.kind === 'partial') {
4020
+ console.error(chalk.red(`Partial session resolution: ${outcome.failedPeers.join(', ')} did not answer.`));
4021
+ process.exit(2);
4022
+ }
4023
+ // An index miss can be a transcript created since the last incremental
4024
+ // scan, or a Claude history alias. Fall through to the established
4025
+ // discovery/history resolver below only on that cold miss.
4026
+ if (outcome.kind === 'ambiguous') {
4027
+ console.error(chalk.red(`Multiple sessions match "${query}" across the fleet:`));
4028
+ for (const candidate of outcome.candidates) {
4029
+ const match = candidate.hits[0].session;
4030
+ const machines = candidate.hits.map(hit => hit.machine).join(', ');
4031
+ console.error(chalk.cyan(` ${match.shortId} ${match.id}`) + chalk.gray(` ${machines} ${match.agent}${match.version ? ` ${match.version}` : ''}`));
4032
+ }
4033
+ console.error(chalk.gray('Pass the full session ID to narrow it down.'));
4034
+ process.exit(1);
4035
+ }
4036
+ if (outcome.kind === 'resolved') {
4037
+ const resolved = outcome.session;
4038
+ if (resolved._remote && resolved.machine && resolved.machine !== machineId()) {
4039
+ const args = ['sessions', resolved.id, '--local'];
4040
+ const flag = modeFlag(mode);
4041
+ if (flag)
4042
+ args.push(flag);
4043
+ if (scope.filter.include?.length)
4044
+ args.push('--include', scope.filter.include.join(','));
4045
+ if (scope.filter.exclude?.length)
4046
+ args.push('--exclude', scope.filter.exclude.join(','));
4047
+ if (scope.filter.first !== undefined)
4048
+ args.push('--first', String(scope.filter.first));
4049
+ if (scope.filter.last !== undefined)
4050
+ args.push('--last', String(scope.filter.last));
4051
+ if (scope.redact === false)
4052
+ args.push('--no-redact');
4053
+ const rendered = await runOnPeer(args, resolved.machine);
4054
+ if (rendered === 'no-target') {
4055
+ console.error(chalk.red(`Session ${resolved.id} is on ${resolved.machine}, but that device is not reachable.`));
4056
+ process.exit(1);
4057
+ }
4058
+ return;
4059
+ }
4060
+ await renderSession(resolved, mode, scope.filter, { redact: scope.redact });
4061
+ return;
4062
+ }
4063
+ }
3866
4064
  const spinner = ora().start();
3867
4065
  const tracker = createScanProgressTracker(FIND_VERBS, 'session', spinner);
3868
4066
  try {
@@ -4070,6 +4268,16 @@ function resolveIndexedMetadataRows(indexed, selector) {
4070
4268
  }
4071
4269
  return resolveSessionQuery(indexed, selector, { indexFallback: false }).matches;
4072
4270
  }
4271
+ /**
4272
+ * ID-shaped selectors go straight to SQLite's primary-key/short-id lookup.
4273
+ * Keyword and label selectors still need the broader metadata pool.
4274
+ */
4275
+ function indexedRowsForSelector(selector, scope) {
4276
+ const indexed = looksLikeSessionId(selector)
4277
+ ? findSessionsById(selector)
4278
+ : querySessions();
4279
+ return applyScopeFilters(indexed, scope);
4280
+ }
4073
4281
  /** Fixed peer argv for the metadata resolver. Scope flags compose identically on
4074
4282
  * every host; `--all` removes the SSH login cwd/time window, not agent/project filters. */
4075
4283
  export function metadataResolveForwardedArgs(selector, scope) {
@@ -4121,7 +4329,7 @@ export function metadataResolveOutcome(localMatches, remote, selector) {
4121
4329
  export async function resolveSessionMetadataValue(selector, scope = {}, deps = { gatherRemoteList }) {
4122
4330
  const localMachine = machineId();
4123
4331
  const includeLocal = !scope.hosts?.length || shouldIncludeLocal(scope.hosts, localMachine);
4124
- const indexed = includeLocal ? applyScopeFilters(querySessions(), scope) : [];
4332
+ const indexed = includeLocal ? indexedRowsForSelector(selector, scope) : [];
4125
4333
  const localMatches = resolveIndexedMetadataRows(indexed, selector)
4126
4334
  .map(session => ({ ...session, machine: session.machine || localMachine }));
4127
4335
  // A full-UUID local hit resolves with ZERO SSH: a UUID is globally unique, so
@@ -4158,7 +4366,7 @@ export async function resolveSessionMetadataValue(selector, scope = {}, deps = {
4158
4366
  async function resolveSessionMetadata(selector, scope, deps = { gatherRemoteList }) {
4159
4367
  const localMachine = machineId();
4160
4368
  const includeLocal = !scope.hosts?.length || shouldIncludeLocal(scope.hosts, localMachine);
4161
- const indexed = includeLocal ? applyScopeFilters(querySessions(), scope) : [];
4369
+ const indexed = includeLocal ? indexedRowsForSelector(selector, scope) : [];
4162
4370
  const localMatches = resolveIndexedMetadataRows(indexed, selector)
4163
4371
  .map(session => ({ ...session, machine: session.machine || localMachine }));
4164
4372
  // A peer is already inside gatherRemoteList. Return all local candidates so
@@ -4257,6 +4465,8 @@ export function registerSessionsCommands(program) {
4257
4465
  .addOption(new Option('--resolve-safe-v1 <selector>').hideHelp())
4258
4466
  .description('Find, browse, and read agent conversation transcripts. Live roster: `agents sessions --active` (alias: `agents roster`).')
4259
4467
  .option('-a, --agent <agent>', 'Filter by agent type and version (e.g., claude, codex@0.116.0)')
4468
+ .addOption(new Option('--session-version <version>', 'Internal spelling for the public sessions --version filter')
4469
+ .hideHelp())
4260
4470
  .option('--claude', 'Shorthand for --agent claude')
4261
4471
  .option('--codex', 'Shorthand for --agent codex')
4262
4472
  .option('--kimi', 'Shorthand for --agent kimi')
@@ -4264,6 +4474,7 @@ export function registerSessionsCommands(program) {
4264
4474
  .option('--grok', 'Shorthand for --agent grok')
4265
4475
  .option('--opencode', 'Shorthand for --agent opencode')
4266
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>`')
4267
4478
  .option('--unmanaged', "Also show sessions from your own ~/.<agent> installs (hidden once agents-cli manages that agent)")
4268
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')
4269
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.")
@@ -4297,7 +4508,6 @@ export function registerSessionsCommands(program) {
4297
4508
  .option('--abandoned', 'Show sessions with no transcript progress for the abandonment window (implies --active)')
4298
4509
  .option('--queued', 'Show queued sessions that have not started running (implies --active)')
4299
4510
  .option('--unknown', 'Show sessions whose live state cannot be determined (implies --active)')
4300
- .option('--favorites', 'Show only favorited (starred) sessions — star them with `*` in the browser or `agents sessions favorite <id>`')
4301
4511
  .option('--tree', 'Group the listing by directory; drops the id/version columns for readability')
4302
4512
  .option('--flat', 'Plain flat table (one row per session) instead of the grouped project overview')
4303
4513
  .option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
@@ -4350,6 +4560,10 @@ export function registerSessionsCommands(program) {
4350
4560
  # Search across every directory, not just this project
4351
4561
  agents sessions "topic" --all
4352
4562
 
4563
+ # Filter one installed harness version (equivalent forms)
4564
+ agents sessions claude@2.1.181
4565
+ agents sessions --agent claude --version 2.1.181
4566
+
4353
4567
  # Team-spawned sessions, grouped by team (spawner + spawn time per team,
4354
4568
  # teammate mode/handle per row; team-flagged spawns with no team record
4355
4569
  # in a trailing (no team) bucket)
@@ -4397,6 +4611,7 @@ export function registerSessionsCommands(program) {
4397
4611
  resume [query] multi-select history → open tabs (or run --resume <id>)
4398
4612
  - The interactive listing and every live-status flag fold in your other online machines automatically (live over SSH, no sync) — each row is labelled by host, this machine first. Use --local to skip the fan-out; single-id lookups stay local.
4399
4613
  - --all is not a device flag: it widens historical directory and time filters. Fleet collection is already the default. A status flag (--working/--idle/--waiting/--orphan/--crashed/--closed/--abandoned/--queued/--unknown) implies --active; combine status flags for a union.
4614
+ - --version <version> requires --agent and is equivalent to --agent <agent@version>.
4400
4615
  - --host runs the query on the remote's own index over SSH (host alias or user@host); repeat or pass several to fan out. SSH access is the only auth.
4401
4616
  - --in-team matches both ends of the lineage: the session that ran 'agents teams create/add', and (with --teams) that team's teammates. In the interactive list, 't' cycles the same filter over the teams in view.
4402
4617
  - --include and --exclude are mutually exclusive.
@@ -4420,10 +4635,49 @@ export function registerSessionsCommands(program) {
4420
4635
  }
4421
4636
  await sessionsAction(query, options, command.getOptionValueSource('limit'));
4422
4637
  });
4638
+ const previewCmd = sessionsCmd
4639
+ .command('preview')
4640
+ .argument('<id>', 'Full session ID or displayed 8-character short ID')
4641
+ .description('Show one rich session card without rendering the full transcript')
4642
+ .option('-a, --agent <agent>', 'Narrow the ID to one agent type/version')
4643
+ .option('-p, --project <name>', 'Narrow the ID to one project')
4644
+ .option('--local', 'Only this machine; do not resolve the ID across the fleet')
4645
+ .option('-H, --host <target...>', 'Resolve only on the named device(s)')
4646
+ .option('--device <target...>', 'Alias for --host')
4647
+ .option('--json', 'Output the session preview as JSON');
4648
+ setHelpSections(previewCmd, {
4649
+ examples: `
4650
+ # Preview by the 8-character ID shown in agents sessions
4651
+ agents sessions preview 407b8dd5
4652
+
4653
+ # A full UUID resolves on the first device that owns it
4654
+ agents sessions preview c70ecdea-6210-4039-9845-246a3a7a9942
4655
+
4656
+ # Stay on this machine or restrict the authoritative lookup to one peer
4657
+ agents sessions preview 407b8dd5 --local
4658
+ agents sessions preview 407b8dd5 --device zion
4659
+ `,
4660
+ notes: `
4661
+ - Full UUIDs are globally unique and may stop the fleet lookup at the first exact hit.
4662
+ - Short IDs wait for every selected device so ambiguity is never hidden.
4663
+ - Active status is refreshed through the bounded live-state TTL; transcript-derived details use the durable session index.
4664
+ `,
4665
+ });
4666
+ previewCmd.action(async (id) => {
4667
+ const options = previewCmd.optsWithGlobals();
4668
+ const hosts = [...(options.host ?? []), ...(options.device ?? [])];
4669
+ await renderSessionPreview(id, {
4670
+ agent: options.agent,
4671
+ project: options.project,
4672
+ local: options.local,
4673
+ hosts: hosts.length > 0 ? hosts : undefined,
4674
+ json: options.json,
4675
+ });
4676
+ });
4423
4677
  registerSessionsTailCommand(sessionsCmd);
4424
4678
  registerSessionsResumeCommand(sessionsCmd);
4425
4679
  registerSessionsForkCommand(sessionsCmd);
4426
- registerSessionsFavoriteCommand(sessionsCmd);
4680
+ registerSessionsBookmarkCommand(sessionsCmd);
4427
4681
  registerGoCommand(sessionsCmd);
4428
4682
  registerFocusCommand(sessionsCmd);
4429
4683
  registerReconnectCommand(sessionsCmd);