@phnx-labs/agents-cli 1.22.29 → 1.22.30

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 (73) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +39 -1
  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/routines.js +29 -11
  10. package/dist/commands/secrets.d.ts +17 -0
  11. package/dist/commands/secrets.js +30 -15
  12. package/dist/commands/sessions-browser.js +6 -6
  13. package/dist/commands/sessions-favorite.d.ts +7 -7
  14. package/dist/commands/sessions-favorite.js +30 -30
  15. package/dist/commands/sessions-picker.d.ts +33 -1
  16. package/dist/commands/sessions-picker.js +102 -27
  17. package/dist/commands/sessions.d.ts +12 -1
  18. package/dist/commands/sessions.js +259 -20
  19. package/dist/commands/view.d.ts +11 -0
  20. package/dist/commands/view.js +56 -29
  21. package/dist/index.js +37 -2
  22. package/dist/lib/account-labels.d.ts +24 -0
  23. package/dist/lib/account-labels.js +72 -0
  24. package/dist/lib/agents.d.ts +32 -1
  25. package/dist/lib/agents.js +96 -31
  26. package/dist/lib/daemon-health.d.ts +24 -0
  27. package/dist/lib/daemon-health.js +84 -0
  28. package/dist/lib/daemon-ticks.d.ts +81 -0
  29. package/dist/lib/daemon-ticks.js +190 -0
  30. package/dist/lib/daemon.d.ts +68 -18
  31. package/dist/lib/daemon.js +303 -338
  32. package/dist/lib/device-config.d.ts +10 -0
  33. package/dist/lib/device-config.js +27 -0
  34. package/dist/lib/exec.d.ts +27 -0
  35. package/dist/lib/exec.js +49 -2
  36. package/dist/lib/hosts/dispatch.d.ts +4 -0
  37. package/dist/lib/hosts/dispatch.js +4 -0
  38. package/dist/lib/hosts/remote-cmd.js +1 -0
  39. package/dist/lib/hosts/run-target.d.ts +1 -0
  40. package/dist/lib/hosts/run-target.js +1 -0
  41. package/dist/lib/import.js +7 -6
  42. package/dist/lib/memory-cache.d.ts +19 -0
  43. package/dist/lib/memory-cache.js +31 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  46. package/dist/lib/migrate.d.ts +1 -1
  47. package/dist/lib/migrate.js +7 -2
  48. package/dist/lib/routine-activation.d.ts +2 -0
  49. package/dist/lib/routine-activation.js +16 -0
  50. package/dist/lib/runner.d.ts +18 -0
  51. package/dist/lib/runner.js +52 -0
  52. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  53. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  54. package/dist/lib/secrets/agent.d.ts +19 -0
  55. package/dist/lib/secrets/agent.js +32 -2
  56. package/dist/lib/secrets/scope.d.ts +3 -3
  57. package/dist/lib/secrets/scope.js +3 -3
  58. package/dist/lib/session/db.d.ts +15 -0
  59. package/dist/lib/session/db.js +90 -15
  60. package/dist/lib/session/discover.js +91 -39
  61. package/dist/lib/session/favorites.d.ts +2 -2
  62. package/dist/lib/session/favorites.js +2 -2
  63. package/dist/lib/session/parse.d.ts +63 -0
  64. package/dist/lib/session/parse.js +165 -20
  65. package/dist/lib/session/session-cache.d.ts +9 -6
  66. package/dist/lib/session/session-cache.js +23 -6
  67. package/dist/lib/shims.js +12 -0
  68. package/dist/lib/startup/command-registry.d.ts +15 -1
  69. package/dist/lib/startup/command-registry.js +49 -0
  70. package/dist/lib/usage-refresh.js +3 -2
  71. package/dist/lib/usage.d.ts +12 -10
  72. package/dist/lib/usage.js +63 -144
  73. package/package.json +4 -1
@@ -46,11 +46,11 @@ 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';
@@ -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'];
@@ -1629,28 +1667,107 @@ function canonicalSessionsCommand(query, options) {
1629
1667
  /** Resolve a session by id/query globally and print its compact preview (no pager).
1630
1668
  * Backs `--preview` — the fast path for the "peek before resume" hot loop. */
1631
1669
  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}".`));
1670
+ let outcome = await resolveSessionMetadataValue(query, scope);
1671
+ // A just-created transcript may not have reached the incremental index yet.
1672
+ // Keep the indexed path hot, but repair a local cold miss once before saying
1673
+ // it does not exist. This also preserves Claude history-alias discovery.
1674
+ if (outcome.kind !== 'resolved'
1675
+ && (!scope.hosts?.length || shouldIncludeLocal(scope.hosts, machineId()))) {
1676
+ const discovered = applyScopeFilters(await discoverSessions({ all: true, cwd: process.cwd(), limit: 5000 }), scope);
1677
+ const localMatches = resolveSessionQuery(discovered, query, { indexFallback: false }).matches
1678
+ .map(session => ({ ...session, machine: session.machine || machineId() }));
1679
+ const exact = localMatches.find(session => selectorAllowsEarlyExit(query)
1680
+ && session.id.toLowerCase() === query.trim().toLowerCase());
1681
+ if (exact)
1682
+ outcome = { kind: 'resolved', session: exact };
1683
+ else if (outcome.kind === 'not-found' && localMatches.length > 0) {
1684
+ outcome = metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, query);
1685
+ }
1686
+ }
1687
+ if (outcome.kind === 'partial') {
1688
+ console.error(chalk.red(`Partial session resolution: ${outcome.failedPeers.join(', ')} did not answer.`));
1689
+ console.error(chalk.gray('No preview was rendered because the short ID may be ambiguous on an unreachable peer.'));
1690
+ process.exitCode = 2;
1691
+ return;
1692
+ }
1693
+ if (outcome.kind === 'not-found') {
1694
+ notFoundByIdMessage(query).forEach(l => console.error(l));
1695
+ process.exitCode = 1;
1696
+ return;
1697
+ }
1698
+ if (outcome.kind === 'ambiguous') {
1699
+ console.error(chalk.red(`Multiple sessions match "${query}" across the fleet:`));
1700
+ for (const candidate of outcome.candidates) {
1701
+ const match = candidate.hits[0].session;
1702
+ const machines = candidate.hits.map(hit => hit.machine).join(', ');
1703
+ console.error(chalk.cyan(` ${match.shortId} ${match.id}`) + chalk.gray(` ${machines} ${match.agent}${match.version ? ` ${match.version}` : ''}`));
1704
+ }
1705
+ console.error(chalk.gray('Pass the full session ID to narrow it down.'));
1706
+ process.exitCode = 1;
1707
+ return;
1708
+ }
1709
+ const session = outcome.session;
1710
+ if (session._remote && session.machine && session.machine !== machineId()) {
1711
+ const args = ['sessions', 'preview', session.id, '--local'];
1712
+ if (scope.json)
1713
+ args.push('--json');
1714
+ const rendered = await runOnPeer(args, session.machine);
1715
+ if (rendered === 'no-target') {
1716
+ console.error(chalk.red(`Session ${session.id} is on ${session.machine}, but that device is not reachable.`));
1717
+ process.exitCode = 1;
1718
+ }
1643
1719
  return;
1644
1720
  }
1645
1721
  // Lead with the live status when the session is still running, so the preview
1646
1722
  // 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.
1723
+ // The shared snapshot is accepted for at most 15 seconds. The durable preview
1724
+ // below contains no live status, so a long-lived process can never keep a
1725
+ // stale working/waiting headline in its transcript cache.
1649
1726
  let live;
1650
1727
  try {
1651
- live = indexActiveBySessionId(await getActiveSessions({ localOnly: scope.local === true })).get(session.id);
1728
+ const loaded = await loadLocalActiveSessions();
1729
+ live = indexActiveBySessionId(loaded.sessions).get(session.id);
1652
1730
  }
1653
1731
  catch { /* plain preview on any probe failure */ }
1732
+ if (scope.json) {
1733
+ const { digest, error } = loadSessionPreviewDigest(session);
1734
+ console.log(JSON.stringify({
1735
+ schemaVersion: 1,
1736
+ session: {
1737
+ id: session.id,
1738
+ shortId: session.shortId,
1739
+ agent: session.agent,
1740
+ version: session.version,
1741
+ model: session.model,
1742
+ account: session.account,
1743
+ machine: session.machine ?? machineId(),
1744
+ cwd: session.cwd,
1745
+ project: session.project,
1746
+ gitBranch: session.gitBranch,
1747
+ createdAt: session.timestamp,
1748
+ lastActivity: session.lastActivity,
1749
+ durationMs: session.durationMs,
1750
+ messageCount: session.messageCount,
1751
+ tokenCount: session.tokenCount,
1752
+ costUsd: session.costUsd,
1753
+ label: session.label,
1754
+ ticketId: session.ticketId,
1755
+ prUrl: session.prUrl,
1756
+ },
1757
+ active: live ? {
1758
+ status: live.status,
1759
+ activity: live.activity,
1760
+ awaitingReason: live.awaitingReason,
1761
+ lastActivityMs: live.lastActivityMs,
1762
+ startedAtMs: live.startedAtMs,
1763
+ pid: live.pid,
1764
+ host: live.host,
1765
+ } : null,
1766
+ preview: digest ?? null,
1767
+ error: error ?? null,
1768
+ }));
1769
+ return;
1770
+ }
1654
1771
  const headline = formatLiveStatusHeadline(live, isFavorite(session.id));
1655
1772
  if (headline)
1656
1773
  console.log(headline);
@@ -1879,6 +1996,14 @@ limitSource) {
1879
1996
  // shorthands fold into --agent, and --device is an alias for --host (both
1880
1997
  // resolve against the same device registry).
1881
1998
  applyAgentShorthands(options);
1999
+ try {
2000
+ query = applyVersionFilters(query, options);
2001
+ }
2002
+ catch (error) {
2003
+ console.error(chalk.red(error instanceof Error ? error.message : String(error)));
2004
+ process.exitCode = 1;
2005
+ return;
2006
+ }
1882
2007
  // --device / --devices both alias --host. A bare `all` / `fleet` sentinel means
1883
2008
  // "search every peer" — which is already the default — so it resolves to no
1884
2009
  // explicit host set rather than erroring on a device literally named "all".
@@ -1984,7 +2109,7 @@ limitSource) {
1984
2109
  console.error(chalk.red('--preview requires a session id or query.'));
1985
2110
  process.exit(1);
1986
2111
  }
1987
- await renderSessionPreview(query, { agent: options.agent, project: options.project, local: options.local });
2112
+ await renderSessionPreview(query, { agent: options.agent, project: options.project, local: options.local, hosts: options.host });
1988
2113
  return;
1989
2114
  }
1990
2115
  if (liveOnly) {
@@ -2095,6 +2220,12 @@ limitSource) {
2095
2220
  await renderArtifactsGlobal(searchQuery, options.artifacts ?? false, options.artifact, artifactLookupScope(options.agent, options.project, options.routine));
2096
2221
  return;
2097
2222
  }
2223
+ // A supplied ID is already the lookup key. Resolve it directly from SQLite
2224
+ // and, when needed, the peer indexes before any broad transcript discovery.
2225
+ if (!toolEvidenceMode && searchQuery && looksLikeSessionId(searchQuery)) {
2226
+ await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, routine: options.routine, filter: filterOpts, redact: options.redact, local: options.local, hosts: options.host });
2227
+ return;
2228
+ }
2098
2229
  // When the user explicitly asks to render (via mode flag), resolve the
2099
2230
  // query globally so sessions outside the default cwd/30d window are found.
2100
2231
  if (wantsRender && searchQuery) {
@@ -3694,6 +3825,11 @@ export function filterSessionsByQuery(sessions, query) {
3694
3825
  const trimmed = query?.trim().toLowerCase() || '';
3695
3826
  if (!trimmed)
3696
3827
  return sessions;
3828
+ const installedAgentVersion = parseInstalledAgentVersionQuery(trimmed);
3829
+ if (installedAgentVersion) {
3830
+ const { agent, version } = parseAgentFilter(installedAgentVersion);
3831
+ return sessions.filter((session) => session.agent === agent && session.version === version);
3832
+ }
3697
3833
  const terms = trimmed.split(/\s+/).filter(Boolean);
3698
3834
  const contentIndex = searchContentIndex(sessions, trimmed);
3699
3835
  // If the query exactly matches a session label, short-circuit the structural
@@ -3863,6 +3999,53 @@ async function renderArtifactsGlobal(query, listAll, name, scope) {
3863
3999
  }
3864
4000
  }
3865
4001
  async function renderOneSession(query, mode, scope) {
4002
+ if (looksLikeSessionId(query)) {
4003
+ const outcome = await resolveSessionMetadataValue(query, scope);
4004
+ if (outcome.kind === 'partial') {
4005
+ console.error(chalk.red(`Partial session resolution: ${outcome.failedPeers.join(', ')} did not answer.`));
4006
+ process.exit(2);
4007
+ }
4008
+ // An index miss can be a transcript created since the last incremental
4009
+ // scan, or a Claude history alias. Fall through to the established
4010
+ // discovery/history resolver below only on that cold miss.
4011
+ if (outcome.kind === 'ambiguous') {
4012
+ console.error(chalk.red(`Multiple sessions match "${query}" across the fleet:`));
4013
+ for (const candidate of outcome.candidates) {
4014
+ const match = candidate.hits[0].session;
4015
+ const machines = candidate.hits.map(hit => hit.machine).join(', ');
4016
+ console.error(chalk.cyan(` ${match.shortId} ${match.id}`) + chalk.gray(` ${machines} ${match.agent}${match.version ? ` ${match.version}` : ''}`));
4017
+ }
4018
+ console.error(chalk.gray('Pass the full session ID to narrow it down.'));
4019
+ process.exit(1);
4020
+ }
4021
+ if (outcome.kind === 'resolved') {
4022
+ const resolved = outcome.session;
4023
+ if (resolved._remote && resolved.machine && resolved.machine !== machineId()) {
4024
+ const args = ['sessions', resolved.id, '--local'];
4025
+ const flag = modeFlag(mode);
4026
+ if (flag)
4027
+ args.push(flag);
4028
+ if (scope.filter.include?.length)
4029
+ args.push('--include', scope.filter.include.join(','));
4030
+ if (scope.filter.exclude?.length)
4031
+ args.push('--exclude', scope.filter.exclude.join(','));
4032
+ if (scope.filter.first !== undefined)
4033
+ args.push('--first', String(scope.filter.first));
4034
+ if (scope.filter.last !== undefined)
4035
+ args.push('--last', String(scope.filter.last));
4036
+ if (scope.redact === false)
4037
+ args.push('--no-redact');
4038
+ const rendered = await runOnPeer(args, resolved.machine);
4039
+ if (rendered === 'no-target') {
4040
+ console.error(chalk.red(`Session ${resolved.id} is on ${resolved.machine}, but that device is not reachable.`));
4041
+ process.exit(1);
4042
+ }
4043
+ return;
4044
+ }
4045
+ await renderSession(resolved, mode, scope.filter, { redact: scope.redact });
4046
+ return;
4047
+ }
4048
+ }
3866
4049
  const spinner = ora().start();
3867
4050
  const tracker = createScanProgressTracker(FIND_VERBS, 'session', spinner);
3868
4051
  try {
@@ -4070,6 +4253,16 @@ function resolveIndexedMetadataRows(indexed, selector) {
4070
4253
  }
4071
4254
  return resolveSessionQuery(indexed, selector, { indexFallback: false }).matches;
4072
4255
  }
4256
+ /**
4257
+ * ID-shaped selectors go straight to SQLite's primary-key/short-id lookup.
4258
+ * Keyword and label selectors still need the broader metadata pool.
4259
+ */
4260
+ function indexedRowsForSelector(selector, scope) {
4261
+ const indexed = looksLikeSessionId(selector)
4262
+ ? findSessionsById(selector)
4263
+ : querySessions();
4264
+ return applyScopeFilters(indexed, scope);
4265
+ }
4073
4266
  /** Fixed peer argv for the metadata resolver. Scope flags compose identically on
4074
4267
  * every host; `--all` removes the SSH login cwd/time window, not agent/project filters. */
4075
4268
  export function metadataResolveForwardedArgs(selector, scope) {
@@ -4121,7 +4314,7 @@ export function metadataResolveOutcome(localMatches, remote, selector) {
4121
4314
  export async function resolveSessionMetadataValue(selector, scope = {}, deps = { gatherRemoteList }) {
4122
4315
  const localMachine = machineId();
4123
4316
  const includeLocal = !scope.hosts?.length || shouldIncludeLocal(scope.hosts, localMachine);
4124
- const indexed = includeLocal ? applyScopeFilters(querySessions(), scope) : [];
4317
+ const indexed = includeLocal ? indexedRowsForSelector(selector, scope) : [];
4125
4318
  const localMatches = resolveIndexedMetadataRows(indexed, selector)
4126
4319
  .map(session => ({ ...session, machine: session.machine || localMachine }));
4127
4320
  // A full-UUID local hit resolves with ZERO SSH: a UUID is globally unique, so
@@ -4158,7 +4351,7 @@ export async function resolveSessionMetadataValue(selector, scope = {}, deps = {
4158
4351
  async function resolveSessionMetadata(selector, scope, deps = { gatherRemoteList }) {
4159
4352
  const localMachine = machineId();
4160
4353
  const includeLocal = !scope.hosts?.length || shouldIncludeLocal(scope.hosts, localMachine);
4161
- const indexed = includeLocal ? applyScopeFilters(querySessions(), scope) : [];
4354
+ const indexed = includeLocal ? indexedRowsForSelector(selector, scope) : [];
4162
4355
  const localMatches = resolveIndexedMetadataRows(indexed, selector)
4163
4356
  .map(session => ({ ...session, machine: session.machine || localMachine }));
4164
4357
  // A peer is already inside gatherRemoteList. Return all local candidates so
@@ -4257,6 +4450,8 @@ export function registerSessionsCommands(program) {
4257
4450
  .addOption(new Option('--resolve-safe-v1 <selector>').hideHelp())
4258
4451
  .description('Find, browse, and read agent conversation transcripts. Live roster: `agents sessions --active` (alias: `agents roster`).')
4259
4452
  .option('-a, --agent <agent>', 'Filter by agent type and version (e.g., claude, codex@0.116.0)')
4453
+ .addOption(new Option('--session-version <version>', 'Internal spelling for the public sessions --version filter')
4454
+ .hideHelp())
4260
4455
  .option('--claude', 'Shorthand for --agent claude')
4261
4456
  .option('--codex', 'Shorthand for --agent codex')
4262
4457
  .option('--kimi', 'Shorthand for --agent kimi')
@@ -4297,7 +4492,7 @@ export function registerSessionsCommands(program) {
4297
4492
  .option('--abandoned', 'Show sessions with no transcript progress for the abandonment window (implies --active)')
4298
4493
  .option('--queued', 'Show queued sessions that have not started running (implies --active)')
4299
4494
  .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>`')
4495
+ .option('--favorites', 'Show only favorited sessions — favorite them with `*` in the browser or `agents sessions favorite <id>`')
4301
4496
  .option('--tree', 'Group the listing by directory; drops the id/version columns for readability')
4302
4497
  .option('--flat', 'Plain flat table (one row per session) instead of the grouped project overview')
4303
4498
  .option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
@@ -4350,6 +4545,10 @@ export function registerSessionsCommands(program) {
4350
4545
  # Search across every directory, not just this project
4351
4546
  agents sessions "topic" --all
4352
4547
 
4548
+ # Filter one installed harness version (equivalent forms)
4549
+ agents sessions claude@2.1.181
4550
+ agents sessions --agent claude --version 2.1.181
4551
+
4353
4552
  # Team-spawned sessions, grouped by team (spawner + spawn time per team,
4354
4553
  # teammate mode/handle per row; team-flagged spawns with no team record
4355
4554
  # in a trailing (no team) bucket)
@@ -4397,6 +4596,7 @@ export function registerSessionsCommands(program) {
4397
4596
  resume [query] multi-select history → open tabs (or run --resume <id>)
4398
4597
  - 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
4598
  - --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.
4599
+ - --version <version> requires --agent and is equivalent to --agent <agent@version>.
4400
4600
  - --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
4601
  - --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
4602
  - --include and --exclude are mutually exclusive.
@@ -4420,6 +4620,45 @@ export function registerSessionsCommands(program) {
4420
4620
  }
4421
4621
  await sessionsAction(query, options, command.getOptionValueSource('limit'));
4422
4622
  });
4623
+ const previewCmd = sessionsCmd
4624
+ .command('preview')
4625
+ .argument('<id>', 'Full session ID or displayed 8-character short ID')
4626
+ .description('Show one rich session card without rendering the full transcript')
4627
+ .option('-a, --agent <agent>', 'Narrow the ID to one agent type/version')
4628
+ .option('-p, --project <name>', 'Narrow the ID to one project')
4629
+ .option('--local', 'Only this machine; do not resolve the ID across the fleet')
4630
+ .option('-H, --host <target...>', 'Resolve only on the named device(s)')
4631
+ .option('--device <target...>', 'Alias for --host')
4632
+ .option('--json', 'Output the session preview as JSON');
4633
+ setHelpSections(previewCmd, {
4634
+ examples: `
4635
+ # Preview by the 8-character ID shown in agents sessions
4636
+ agents sessions preview 407b8dd5
4637
+
4638
+ # A full UUID resolves on the first device that owns it
4639
+ agents sessions preview c70ecdea-6210-4039-9845-246a3a7a9942
4640
+
4641
+ # Stay on this machine or restrict the authoritative lookup to one peer
4642
+ agents sessions preview 407b8dd5 --local
4643
+ agents sessions preview 407b8dd5 --device zion
4644
+ `,
4645
+ notes: `
4646
+ - Full UUIDs are globally unique and may stop the fleet lookup at the first exact hit.
4647
+ - Short IDs wait for every selected device so ambiguity is never hidden.
4648
+ - Active status is refreshed through the bounded live-state TTL; transcript-derived details use the durable session index.
4649
+ `,
4650
+ });
4651
+ previewCmd.action(async (id) => {
4652
+ const options = previewCmd.optsWithGlobals();
4653
+ const hosts = [...(options.host ?? []), ...(options.device ?? [])];
4654
+ await renderSessionPreview(id, {
4655
+ agent: options.agent,
4656
+ project: options.project,
4657
+ local: options.local,
4658
+ hosts: hosts.length > 0 ? hosts : undefined,
4659
+ json: options.json,
4660
+ });
4661
+ });
4423
4662
  registerSessionsTailCommand(sessionsCmd);
4424
4663
  registerSessionsResumeCommand(sessionsCmd);
4425
4664
  registerSessionsForkCommand(sessionsCmd);
@@ -15,6 +15,17 @@ import { type ProfileSummary } from '../lib/profiles.js';
15
15
  import { type ByokUsageResult } from '../lib/byok-usage.js';
16
16
  /** Shared account identity formatter, re-exported for the view-specific tests. */
17
17
  export declare const accountColumnLabel: typeof accountDisplayLabel;
18
+ export interface AccountOrderedVersion {
19
+ version: string;
20
+ email: string | null;
21
+ }
22
+ /**
23
+ * Human `agents view` row order: selected default first, then email-bearing
24
+ * accounts alphabetically, then installs whose account has no email. Version
25
+ * descending is the deterministic tie-breaker and preserves the old order for
26
+ * every non-email harness.
27
+ */
28
+ export declare function compareAccountOrderedVersions(a: AccountOrderedVersion, b: AccountOrderedVersion, globalDefault: string | null): number;
18
29
  /**
19
30
  * Join fixed view columns with a consistent two-space gutter. Empty trailing
20
31
  * columns are dropped so a row without an auth chip does not grow a dangling
@@ -33,6 +33,30 @@ import { formatPath, isInteractiveTerminal, isPromptCancelled } from './utils.js
33
33
  import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
34
34
  /** Shared account identity formatter, re-exported for the view-specific tests. */
35
35
  export const accountColumnLabel = accountDisplayLabel;
36
+ /**
37
+ * Human `agents view` row order: selected default first, then email-bearing
38
+ * accounts alphabetically, then installs whose account has no email. Version
39
+ * descending is the deterministic tie-breaker and preserves the old order for
40
+ * every non-email harness.
41
+ */
42
+ export function compareAccountOrderedVersions(a, b, globalDefault) {
43
+ const aIsDefault = a.version === globalDefault;
44
+ const bIsDefault = b.version === globalDefault;
45
+ if (aIsDefault !== bIsDefault)
46
+ return aIsDefault ? -1 : 1;
47
+ const aEmail = a.email?.toLowerCase() ?? null;
48
+ const bEmail = b.email?.toLowerCase() ?? null;
49
+ if (aEmail !== null && bEmail === null)
50
+ return -1;
51
+ if (aEmail === null && bEmail !== null)
52
+ return 1;
53
+ if (aEmail !== null && bEmail !== null) {
54
+ const emailOrder = aEmail.localeCompare(bEmail);
55
+ if (emailOrder !== 0)
56
+ return emailOrder;
57
+ }
58
+ return compareVersions(b.version, a.version);
59
+ }
36
60
  /**
37
61
  * Overview (`agents view` with no agent filter) caps compact usage windows so
38
62
  * multi-meter agents (Antigravity's four model quotas, Droid's three buckets)
@@ -56,15 +80,12 @@ export function joinViewColumns(cols) {
56
80
  return cols.slice(0, end).join(' ');
57
81
  }
58
82
  /**
59
- * Custom harnesses (the `~/.agents/profiles/*.yml` bundles), sorted by name and
60
- * optionally narrowed to the ones that run on one host agent. YAMLs that fail
61
- * validation are silently skipped by `listProfiles`, so this never throws on a
62
- * malformed file.
83
+ * Custom harnesses (the `~/.agents/profiles/*.yml` bundles), sorted by name.
84
+ * YAMLs that fail validation are silently skipped by `listProfiles`, so this
85
+ * never throws on a malformed file.
63
86
  */
64
- function getHarnesses(filterAgentId) {
65
- return listProfiles()
66
- .filter((profile) => !filterAgentId || profile.host.agent === filterAgentId)
67
- .map(profileSummary);
87
+ function getHarnesses() {
88
+ return listProfiles().map(profileSummary);
68
89
  }
69
90
  /** Version-first label: "<version> (forked from <host>[, tracks default])" */
70
91
  function harnessVersionLabel(harness, globalDefault) {
@@ -307,7 +328,9 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
307
328
  .filter((agentId) => !hasNonIsolatedVersion(agentId))
308
329
  .map(async (agentId) => [agentId, await getUnmanagedCliState(agentId)])));
309
330
  const showPaths = !!filterAgentId;
310
- const harnesses = getHarnesses(filterAgentId);
331
+ // A filtered native view is about that native harness's installed versions.
332
+ // Custom forks are standalone agent types and only belong in the overview.
333
+ const harnesses = filterAgentId ? [] : getHarnesses();
311
334
  // Auto-heal stale versioned aliases. Pre-v2 aliases (e.g. pre-CLAUDE_CONFIG_DIR
312
335
  // claude shims) silently route login through the default version's symlinked
313
336
  // home, so `agents view` would never reflect the right account. Regenerate on
@@ -524,14 +547,16 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
524
547
  ? chalk.yellow(' (no default)')
525
548
  : '';
526
549
  console.log(` ${chalk.bold(agentLabel(agentId))}${strategyLabel}${noDefaultLabel}`);
527
- // Sort versions with default first, then by semver descending
528
- const sortedVersions = [...versions].sort((a, b) => {
529
- if (a === globalDefault)
530
- return -1;
531
- if (b === globalDefault)
532
- return 1;
533
- return compareVersions(b, a);
534
- });
550
+ // Account information is already loaded above. Keep the selected default
551
+ // first, then make multi-account installs scannable by email. Harnesses
552
+ // without email identities retain their prior version-descending order.
553
+ const sortedVersions = versions
554
+ .map((version) => ({
555
+ version,
556
+ email: infoMap.get(`${agentId}:${version}`)?.email ?? null,
557
+ }))
558
+ .sort((a, b) => compareAccountOrderedVersions(a, b, globalDefault))
559
+ .map(({ version }) => version);
535
560
  for (const version of sortedVersions) {
536
561
  const isDefault = version === globalDefault;
537
562
  const isolated = !isDefault && isVersionIsolated(agentId, version);
@@ -1273,7 +1298,9 @@ export async function collectAgentsJson(filterAgentId, resourceSections) {
1273
1298
  else
1274
1299
  byAgent.set(agentId, [entry]);
1275
1300
  }
1276
- const harnesses = getHarnesses(filterAgentId);
1301
+ // Keep filtered native JSON consistent with the text view: custom forks are
1302
+ // not children of the native harness they execute through.
1303
+ const harnesses = filterAgentId ? [] : getHarnesses();
1277
1304
  const out = [];
1278
1305
  for (const agentId of agentsToShow) {
1279
1306
  const versions = byAgent.get(agentId) ?? [];
@@ -1544,19 +1571,19 @@ export async function viewAction(agentArg, options) {
1544
1571
  // Parse agent@version syntax
1545
1572
  const parts = agentArg.split('@');
1546
1573
  const agentName = parts[0];
1547
- const agentId = resolveAgentName(agentName);
1548
- if (!agentId) {
1549
- // A custom harness is an agent type here, not an unknown name: `agents run
1550
- // <name>` launches it, so `agents view <name>` describes it.
1551
- if (profileExists(agentName)) {
1552
- const harness = profileSummary(readProfile(agentName));
1553
- if (json) {
1554
- console.log(JSON.stringify(harness, null, 2));
1555
- return;
1556
- }
1557
- renderHarnessDetail(agentName);
1574
+ // Match run resolution: an exact custom harness name wins over a native id or
1575
+ // alias with the same spelling, so every fork remains independently viewable.
1576
+ if (profileExists(agentName)) {
1577
+ const harness = profileSummary(readProfile(agentName));
1578
+ if (json) {
1579
+ console.log(JSON.stringify(harness, null, 2));
1558
1580
  return;
1559
1581
  }
1582
+ renderHarnessDetail(agentName);
1583
+ return;
1584
+ }
1585
+ const agentId = resolveAgentName(agentName);
1586
+ if (!agentId) {
1560
1587
  if (json) {
1561
1588
  console.log(JSON.stringify({ error: formatAgentError(agentName) }));
1562
1589
  process.exit(1);
package/dist/index.js CHANGED
@@ -94,7 +94,7 @@ if (IS_DEV_BUILD) {
94
94
  // module on each invocation (which loaded the whole ~50-module tree before the
95
95
  // first byte of output), the registry maps a command name to a thunk that
96
96
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
97
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadModes, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadSnapshot, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadInsights, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadHumans, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadBench, loadShare, loadSend, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
97
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadDaemon, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadModes, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadSnapshot, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadInsights, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadHumans, loadAccounts, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadBench, loadShare, loadSend, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
98
98
  import { applyGlobalHelpConventions } from './lib/help.js';
99
99
  import { renderWhatsNew } from './lib/whats-new.js';
100
100
  import { getCliLaunch } from './lib/cli-entry.js';
@@ -120,6 +120,22 @@ if (process.argv[2] === '__daemon-run') {
120
120
  await runDaemon();
121
121
  process.exit(process.exitCode ?? 0);
122
122
  }
123
+ // One-shot invocation of a migrated daemon housekeeping tick (RUSH-2353). The
124
+ // shipped system routines (`watchdog`, `device-probe`, `fleet-cache-warm`, ...)
125
+ // run this as their `command:` instead of the daemon holding a setInterval —
126
+ // same tick body, now scheduled/tracked/pinnable through the routines system.
127
+ if (process.argv[2] === '__daemon-tick') {
128
+ const name = process.argv[3] || '';
129
+ const { runDaemonTick } = await import('./lib/daemon-ticks.js');
130
+ try {
131
+ await runDaemonTick(name);
132
+ process.exit(0);
133
+ }
134
+ catch (err) {
135
+ process.stderr.write(`[agents] daemon tick '${name}' failed: ${err.message}\n`);
136
+ process.exit(1);
137
+ }
138
+ }
123
139
  // White-label: the shim for a brand (e.g. `jack`) exports AGENTS_BRAND, so the
124
140
  // CLI presents its own name/help/errors as the brand. Unbranded (AGENTS_BRAND
125
141
  // unset) resolves to 'agents' and everything below is byte-identical to before.
@@ -303,6 +319,7 @@ Run and dispatch:
303
319
  defaults Configure run defaults by agent/version selector
304
320
  teams Coordinate multiple agents on shared work
305
321
  routines Run agents on a cron schedule (scheduler auto-starts)
322
+ daemon Runtime status/control for the always-on daemon (secrets broker, browser IPC, scheduler)
306
323
  webhook Receive signed GitHub/Linear webhooks for trigger routines
307
324
  funnel Expose a webhook receiver through Tailscale Funnel
308
325
  sessions Browse, search, and replay past runs (live-search in TTY; grouped by workspace)
@@ -929,6 +946,7 @@ async function registerAllEagerCommands() {
929
946
  await reg(loadExport);
930
947
  await reg(loadPackages);
931
948
  await reg(loadRoutines);
949
+ await reg(loadDaemon);
932
950
  await reg(loadMonitors);
933
951
  await reg(loadProjects);
934
952
  await reg(loadRun);
@@ -980,6 +998,7 @@ async function registerAllEagerCommands() {
980
998
  await reg(loadWebhook);
981
999
  await reg(loadFunnel);
982
1000
  await reg(loadHumans);
1001
+ await reg(loadAccounts);
983
1002
  registerHqTombstoneCommand(program);
984
1003
  await reg(loadFeed);
985
1004
  await reg(loadMailboxes);
@@ -1060,6 +1079,22 @@ program.on('command:*', (operands) => {
1060
1079
  // and the doc flags (--version/--help/-h) drive both the registration strategy
1061
1080
  // and whether the update check + background sync run at all.
1062
1081
  const passedArgs = process.argv.slice(2);
1082
+ // Commander owns `--version` on the root command and otherwise intercepts it
1083
+ // even after `sessions`, before the subcommand can parse its version filter.
1084
+ // Rewrite only that value-taking nested form; bare `agents --version` and every
1085
+ // other command retain the root documentation flag unchanged.
1086
+ if (passedArgs[0] === 'sessions') {
1087
+ const nestedVersionIndex = passedArgs.indexOf('--version', 1);
1088
+ if (nestedVersionIndex >= 0) {
1089
+ const nestedVersion = passedArgs[nestedVersionIndex + 1];
1090
+ if (!nestedVersion || nestedVersion.startsWith('-')) {
1091
+ console.error("error: option '--version <version>' argument missing");
1092
+ process.exit(1);
1093
+ }
1094
+ passedArgs[nestedVersionIndex] = '--session-version';
1095
+ process.argv[nestedVersionIndex + 2] = '--session-version';
1096
+ }
1097
+ }
1063
1098
  const requestedCommand = passedArgs.find((arg) => !arg.startsWith('-'));
1064
1099
  const verboseStartup = passedArgs.includes('--verbose');
1065
1100
  // Help and version output are pure documentation — they must never gate on
@@ -1212,7 +1247,7 @@ if (process.env.AGENTS_SKIP_MIGRATION !== '1') {
1212
1247
  // Bumping the suffix re-runs migrations for every user; binary releases that
1213
1248
  // don't change the schema must NOT re-run (they would destroy user content
1214
1249
  // when migration steps overlap with user-authored paths). See issue #20.
1215
- const sentinelValue = 'v15';
1250
+ const sentinelValue = 'v17';
1216
1251
  let needRun = true;
1217
1252
  try {
1218
1253
  if (fs.existsSync(sentinel) && fs.readFileSync(sentinel, 'utf-8').trim() === sentinelValue) {