@phnx-labs/agents-cli 1.22.26 → 1.22.27

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 (131) hide show
  1. package/CHANGELOG.md +244 -0
  2. package/README.md +18 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/bench.d.ts +2 -0
  5. package/dist/commands/bench.js +101 -0
  6. package/dist/commands/doctor.js +24 -10
  7. package/dist/commands/exec.js +17 -16
  8. package/dist/commands/insights.js +39 -5
  9. package/dist/commands/output.js +100 -25
  10. package/dist/commands/resume.d.ts +11 -0
  11. package/dist/commands/resume.js +51 -0
  12. package/dist/commands/sessions-picker.js +12 -7
  13. package/dist/commands/sessions.d.ts +47 -1
  14. package/dist/commands/sessions.js +345 -37
  15. package/dist/commands/ssh.js +184 -11
  16. package/dist/commands/teams.js +3 -3
  17. package/dist/index.js +16 -20
  18. package/dist/lib/auth-health.d.ts +47 -3
  19. package/dist/lib/auth-health.js +74 -15
  20. package/dist/lib/bench/index.d.ts +4 -0
  21. package/dist/lib/bench/index.js +4 -0
  22. package/dist/lib/bench/runner.d.ts +16 -0
  23. package/dist/lib/bench/runner.js +111 -0
  24. package/dist/lib/bench/schema.d.ts +5 -0
  25. package/dist/lib/bench/schema.js +91 -0
  26. package/dist/lib/bench/storage.d.ts +5 -0
  27. package/dist/lib/bench/storage.js +32 -0
  28. package/dist/lib/bench/types.d.ts +40 -0
  29. package/dist/lib/bench/types.js +1 -0
  30. package/dist/lib/claude-account-token.d.ts +2 -0
  31. package/dist/lib/claude-account-token.js +27 -1
  32. package/dist/lib/crabbox/cli.d.ts +2 -0
  33. package/dist/lib/crabbox/cli.js +2 -0
  34. package/dist/lib/crabbox/lease.js +7 -1
  35. package/dist/lib/daemon.js +20 -0
  36. package/dist/lib/devices/connect.d.ts +2 -0
  37. package/dist/lib/devices/connect.js +7 -0
  38. package/dist/lib/devices/doctor-findings.d.ts +4 -1
  39. package/dist/lib/devices/doctor-findings.js +15 -1
  40. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  41. package/dist/lib/devices/harness-inventory.js +0 -0
  42. package/dist/lib/devices/registry.d.ts +2 -0
  43. package/dist/lib/devices/ssh-config.js +3 -0
  44. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  45. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  46. package/dist/lib/exec.bench.d.ts +1 -0
  47. package/dist/lib/exec.bench.js +186 -0
  48. package/dist/lib/exec.js +18 -8
  49. package/dist/lib/fleet/apply.js +9 -7
  50. package/dist/lib/fleet/remote-login.d.ts +4 -3
  51. package/dist/lib/fleet/remote-login.js +11 -9
  52. package/dist/lib/gemini-settings.d.ts +0 -1
  53. package/dist/lib/gemini-settings.js +12 -7
  54. package/dist/lib/hooks.d.ts +8 -5
  55. package/dist/lib/hooks.js +10 -6
  56. package/dist/lib/hosts/dispatch.js +7 -3
  57. package/dist/lib/hosts/passthrough.d.ts +22 -0
  58. package/dist/lib/hosts/passthrough.js +14 -8
  59. package/dist/lib/hosts/progress.d.ts +2 -1
  60. package/dist/lib/hosts/progress.js +4 -3
  61. package/dist/lib/hosts/providers/devices.js +1 -0
  62. package/dist/lib/hosts/ready.d.ts +4 -2
  63. package/dist/lib/hosts/ready.js +24 -8
  64. package/dist/lib/hosts/reconcile.d.ts +1 -1
  65. package/dist/lib/hosts/reconcile.js +16 -7
  66. package/dist/lib/hosts/reconnect.d.ts +45 -12
  67. package/dist/lib/hosts/reconnect.js +90 -34
  68. package/dist/lib/hosts/registry.d.ts +2 -2
  69. package/dist/lib/hosts/registry.js +3 -5
  70. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  71. package/dist/lib/hosts/remote-cmd.js +29 -0
  72. package/dist/lib/hosts/tasks.d.ts +2 -0
  73. package/dist/lib/hosts/types.d.ts +1 -0
  74. package/dist/lib/hosts/types.js +3 -0
  75. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  76. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  77. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  78. package/dist/lib/menubar/install-menubar.js +20 -9
  79. package/dist/lib/models.d.ts +30 -1
  80. package/dist/lib/models.js +42 -144
  81. package/dist/lib/pricing/cost.d.ts +9 -0
  82. package/dist/lib/pricing/cost.js +24 -0
  83. package/dist/lib/pricing/index.d.ts +1 -1
  84. package/dist/lib/pricing/index.js +1 -1
  85. package/dist/lib/redact.js +8 -3
  86. package/dist/lib/remote-agents-json.d.ts +32 -0
  87. package/dist/lib/remote-agents-json.js +47 -16
  88. package/dist/lib/resource-profiles.js +1 -2
  89. package/dist/lib/routine-notify-owner.d.ts +102 -0
  90. package/dist/lib/routine-notify-owner.js +232 -0
  91. package/dist/lib/routines.d.ts +10 -0
  92. package/dist/lib/runner.d.ts +9 -1
  93. package/dist/lib/runner.js +182 -17
  94. package/dist/lib/sandbox.d.ts +0 -2
  95. package/dist/lib/sandbox.js +2 -19
  96. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  97. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  98. package/dist/lib/secrets/agent.js +3 -2
  99. package/dist/lib/secrets/lease.d.ts +25 -0
  100. package/dist/lib/secrets/lease.js +44 -0
  101. package/dist/lib/self-update.d.ts +20 -5
  102. package/dist/lib/self-update.js +93 -16
  103. package/dist/lib/session/db.d.ts +19 -2
  104. package/dist/lib/session/db.js +54 -5
  105. package/dist/lib/session/discover.d.ts +21 -1
  106. package/dist/lib/session/discover.js +124 -32
  107. package/dist/lib/session/insights.d.ts +19 -0
  108. package/dist/lib/session/insights.js +78 -10
  109. package/dist/lib/session/recovery.d.ts +17 -1
  110. package/dist/lib/session/recovery.js +111 -4
  111. package/dist/lib/session/remote-list.d.ts +10 -9
  112. package/dist/lib/session/remote-list.js +25 -23
  113. package/dist/lib/session/resume-owner.d.ts +55 -0
  114. package/dist/lib/session/resume-owner.js +69 -0
  115. package/dist/lib/session/team-filter.d.ts +65 -0
  116. package/dist/lib/session/team-filter.js +98 -3
  117. package/dist/lib/session/types.d.ts +33 -0
  118. package/dist/lib/smart-launch.d.ts +31 -5
  119. package/dist/lib/smart-launch.js +43 -7
  120. package/dist/lib/ssh-exec.d.ts +27 -0
  121. package/dist/lib/ssh-exec.js +34 -1
  122. package/dist/lib/ssh-tunnel.d.ts +3 -2
  123. package/dist/lib/ssh-tunnel.js +25 -16
  124. package/dist/lib/startup/command-registry.d.ts +1 -0
  125. package/dist/lib/startup/command-registry.js +2 -0
  126. package/dist/lib/teams/placement-probe.d.ts +1 -1
  127. package/dist/lib/teams/placement-probe.js +24 -24
  128. package/dist/lib/tmux/session.d.ts +8 -0
  129. package/dist/lib/tmux/session.js +22 -0
  130. package/dist/lib/types.d.ts +2 -0
  131. package/package.json +1 -1
@@ -33,16 +33,18 @@ import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/
33
33
  import { inferSessionState } from '../lib/session/state.js';
34
34
  import { discoverSessions, queryIndexedSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, looksLikeSessionId, searchContentIndex, getSessionRoots, scopeToManaged } from '../lib/session/discover.js';
35
35
  import { findSessionsById, querySessions, getSessionById } from '../lib/session/db.js';
36
- import { filterTeamSessions, safeTeamText } from '../lib/session/team-filter.js';
36
+ import { filterTeamSessions, safeTeamText, groupSessionsByTeam, NO_TEAM_GROUP_KEY, } from '../lib/session/team-filter.js';
37
37
  import { parseSession } from '../lib/session/parse.js';
38
38
  import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
39
39
  import { formatRelativeTime, formatCompactAge, sessionAgeParts } from '../lib/session/relative-time.js';
40
40
  import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList, linkPath, linkUrl, shortenModel } from '../lib/session/render.js';
41
41
  import { linearIssueUrl } from '../lib/session/linear.js';
42
+ import { sessionOwnerDevice, RESUME_PINNED_ENV } from '../lib/session/resume-owner.js';
42
43
  import { renderMarkdown } from '../lib/markdown.js';
43
44
  import { AGENTS, colorAgent, resolveAgentName } from '../lib/agents.js';
44
45
  import { getShimsDir } from '../lib/state.js';
45
46
  import { fuzzyMatch, FUZZY_PRESETS } from '../lib/fuzzy.js';
47
+ import { itemPicker } from '../lib/picker.js';
46
48
  import { resolveSessionAlias } from '../lib/session/actor-sidecar.js';
47
49
  import { resolveVersionAliasLoose } from '../lib/versions.js';
48
50
  import { getAgentsInvocation } from '../lib/daemon.js';
@@ -1442,6 +1444,96 @@ export function hasNoBrowserDisqualifyingFlags(options, query) {
1442
1444
  // legacy per-host stream, which prints each peer under its own banner.
1443
1445
  (options.host?.length ?? 0) <= 1);
1444
1446
  }
1447
+ /** Resolve a typed routine selector by exact name, substring, or one unambiguous typo. */
1448
+ export function resolveRoutineName(query, names) {
1449
+ const normalized = query.trim().toLowerCase();
1450
+ if (!normalized)
1451
+ return null;
1452
+ const exact = names.find((name) => name.toLowerCase() === normalized);
1453
+ if (exact)
1454
+ return exact;
1455
+ const containing = names.filter((name) => name.toLowerCase().includes(normalized));
1456
+ if (containing.length === 1)
1457
+ return containing[0];
1458
+ return fuzzyMatch(query, names, FUZZY_PRESETS.dynamic);
1459
+ }
1460
+ export function buildRoutineRunGroups(sessions) {
1461
+ const byRun = new Map();
1462
+ for (const session of sessions) {
1463
+ const runId = session.routineRunId ?? session.timestamp;
1464
+ (byRun.get(runId) ?? byRun.set(runId, []).get(runId)).push(session);
1465
+ }
1466
+ return [...byRun.entries()]
1467
+ .map(([runId, rows]) => ({
1468
+ runId,
1469
+ timestamp: rows.reduce((latest, row) => (row.lastActivity ?? row.timestamp) > latest ? (row.lastActivity ?? row.timestamp) : latest, rows[0].lastActivity ?? rows[0].timestamp),
1470
+ sessions: rows.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1)),
1471
+ }))
1472
+ .sort((a, b) => (a.timestamp < b.timestamp ? 1 : a.timestamp > b.timestamp ? -1 : a.runId.localeCompare(b.runId)));
1473
+ }
1474
+ export function buildRoutineChoices(sessions) {
1475
+ const byName = new Map();
1476
+ for (const session of sessions) {
1477
+ if (!session.routineName)
1478
+ continue;
1479
+ (byName.get(session.routineName) ?? byName.set(session.routineName, []).get(session.routineName)).push(session);
1480
+ }
1481
+ return [...byName.entries()]
1482
+ .map(([name, rows]) => {
1483
+ const runs = buildRoutineRunGroups(rows);
1484
+ return {
1485
+ name,
1486
+ lastRunAt: runs[0].timestamp,
1487
+ runCount: runs.length,
1488
+ latestRunSessionCount: runs[0].sessions.length,
1489
+ };
1490
+ })
1491
+ .sort((a, b) => (a.lastRunAt < b.lastRunAt ? 1 : a.lastRunAt > b.lastRunAt ? -1 : a.name.localeCompare(b.name)));
1492
+ }
1493
+ async function selectRoutineName(sessions) {
1494
+ const choices = buildRoutineChoices(sessions);
1495
+ const picked = await itemPicker({
1496
+ message: 'Select a routine:',
1497
+ items: choices,
1498
+ filter: (query) => {
1499
+ const normalized = query.trim().toLowerCase();
1500
+ if (!normalized)
1501
+ return choices;
1502
+ return choices.filter((choice) => choice.name.toLowerCase().includes(normalized));
1503
+ },
1504
+ labelFor: (choice) => {
1505
+ const runs = `${choice.runCount} run${choice.runCount === 1 ? '' : 's'}`;
1506
+ const sessions = `${choice.latestRunSessionCount} session${choice.latestRunSessionCount === 1 ? '' : 's'} in latest`;
1507
+ return `${choice.name} ${chalk.gray(`${runs} · ${sessions} · ${formatRelativeTime(choice.lastRunAt)}`)}`;
1508
+ },
1509
+ shortIdFor: (choice) => choice.name,
1510
+ emptyMessage: 'No routines match.',
1511
+ enterHint: 'show sessions',
1512
+ });
1513
+ return picked?.item.name ?? null;
1514
+ }
1515
+ export async function filterSessionsByRoutine(sessions, routine, interactive) {
1516
+ const routineNames = [...new Set(sessions.map((session) => session.routineName).filter((name) => !!name))].sort((a, b) => a.localeCompare(b));
1517
+ let selectedRoutine = null;
1518
+ if (typeof routine === 'string') {
1519
+ selectedRoutine = resolveRoutineName(routine, routineNames);
1520
+ if (!selectedRoutine) {
1521
+ console.error(chalk.red(`No routine matches "${routine}".`));
1522
+ if (routineNames.length > 0)
1523
+ console.error(chalk.gray(`Available routines: ${routineNames.join(', ')}`));
1524
+ process.exitCode = 1;
1525
+ return null;
1526
+ }
1527
+ }
1528
+ else if (interactive) {
1529
+ selectedRoutine = await selectRoutineName(sessions);
1530
+ if (!selectedRoutine)
1531
+ return null;
1532
+ }
1533
+ return selectedRoutine
1534
+ ? sessions.filter((session) => session.routineName === selectedRoutine)
1535
+ : sessions;
1536
+ }
1445
1537
  /** The canonical `ag sessions …` command for a set of flags — the twin of the
1446
1538
  * browser's `y` hotkey (see --print-cmd). Normalizes to the stable flag form. */
1447
1539
  function canonicalSessionsCommand(query, options) {
@@ -1468,8 +1560,11 @@ function canonicalSessionsCommand(query, options) {
1468
1560
  a.push('--teams');
1469
1561
  if (options.inTeam)
1470
1562
  a.push('--in-team', options.inTeam);
1471
- if (options.routine)
1563
+ if (options.routine) {
1472
1564
  a.push('--routine');
1565
+ if (typeof options.routine === 'string')
1566
+ a.push(options.routine);
1567
+ }
1473
1568
  if (options.agent)
1474
1569
  a.push('-a', options.agent);
1475
1570
  for (const h of options.host ?? [])
@@ -1910,7 +2005,13 @@ limitSource) {
1910
2005
  // filter the browser can't represent), or --no-interactive keep the existing
1911
2006
  // printed/render paths (agents and scripts unaffected). An explicit --since seeds
1912
2007
  // the browser's window so the flag is honored, not swallowed.
1913
- if (isBareBrowserListing(options, query)) {
2008
+ //
2009
+ // `--teams` diverts to the printed team-grouped report (printTeamsView, below):
2010
+ // grouping teammates under their team is a shape the flat fuzzy picker cannot
2011
+ // represent, so like --tree it is a printed listing, not an interactive pick.
2012
+ // --teams --flat/--tree keep their inline table rendering; a search query keeps
2013
+ // the picker so `<term> --teams` still searches.
2014
+ if (isBareBrowserListing(options, query) && !options.teams) {
1914
2015
  const { runSessionBrowser, bareBrowserSeed } = await import('./sessions-browser.js');
1915
2016
  await runSessionBrowser(bareBrowserSeed({
1916
2017
  teams: options.teams,
@@ -1957,13 +2058,13 @@ limitSource) {
1957
2058
  const wantsRender = !toolEvidenceMode && (mode === 'markdown' || hasAnyFilter(filterOpts));
1958
2059
  // Artifact-list or artifact-read paths: widen scope and resolve session globally.
1959
2060
  if ((options.artifacts || options.artifact !== undefined) && searchQuery) {
1960
- await renderArtifactsGlobal(searchQuery, options.artifacts ?? false, options.artifact, { agent: options.agent, project: options.project });
2061
+ await renderArtifactsGlobal(searchQuery, options.artifacts ?? false, options.artifact, artifactLookupScope(options.agent, options.project, options.routine));
1961
2062
  return;
1962
2063
  }
1963
2064
  // When the user explicitly asks to render (via mode flag), resolve the
1964
2065
  // query globally so sessions outside the default cwd/30d window are found.
1965
2066
  if (wantsRender && searchQuery) {
1966
- await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact, local: options.local, hosts: options.host });
2067
+ await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, routine: options.routine, filter: filterOpts, redact: options.redact, local: options.local, hosts: options.host });
1967
2068
  return;
1968
2069
  }
1969
2070
  // Interactive picker loads a deep pool but shows only recent sessions
@@ -2146,6 +2247,14 @@ limitSource) {
2146
2247
  const hiddenCount = options.teams || options.inTeam
2147
2248
  ? 0
2148
2249
  : countSessionsInScope({ ...scope, onlyTeamOrigin: true });
2250
+ // A typed routine scope must apply before smart ID routing. Otherwise an ID
2251
+ // from another routine resolves and renders before the routine filter below.
2252
+ if (typeof options.routine === 'string') {
2253
+ const filteredByRoutine = await filterSessionsByRoutine(sessions, options.routine, false);
2254
+ if (!filteredByRoutine)
2255
+ return;
2256
+ sessions = filteredByRoutine;
2257
+ }
2149
2258
  // Smart ID routing: a bare query that resolves to one session renders
2150
2259
  // directly. If nothing matches in the scoped window and the query looks
2151
2260
  // like a session ID, widen to global scope (incl. Claude /resume history).
@@ -2163,11 +2272,17 @@ limitSource) {
2163
2272
  return;
2164
2273
  }
2165
2274
  if (idMatches.length === 0 && looksLikeSessionId(searchQuery)) {
2166
- await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact, local: options.local, hosts: options.host });
2275
+ await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, routine: options.routine, filter: filterOpts, redact: options.redact, local: options.local, hosts: options.host });
2167
2276
  return;
2168
2277
  }
2169
2278
  }
2170
2279
  if (options.json) {
2280
+ if (options.routine === true) {
2281
+ const filteredByRoutine = await filterSessionsByRoutine(sessions, options.routine, false);
2282
+ if (!filteredByRoutine)
2283
+ return;
2284
+ sessions = filteredByRoutine;
2285
+ }
2171
2286
  // An id-shaped query resolves by id ONLY — the same rule the render path
2172
2287
  // uses (resolveSessionQuery). Without this a `--json <uuid>` (as issued by
2173
2288
  // the fleet locate sweep, which runs `sessions <uuid> --json --local` on
@@ -2210,6 +2325,12 @@ limitSource) {
2210
2325
  fanSpinner?.stop();
2211
2326
  }
2212
2327
  }
2328
+ if (options.routine) {
2329
+ const filteredByRoutine = await filterSessionsByRoutine(sessions, options.routine, isInteractive);
2330
+ if (!filteredByRoutine)
2331
+ return;
2332
+ sessions = filteredByRoutine;
2333
+ }
2213
2334
  if (sessions.length === 0) {
2214
2335
  if (pathFilter) {
2215
2336
  console.log(chalk.gray(`No sessions found for ${pathFilter}.`));
@@ -2225,11 +2346,24 @@ limitSource) {
2225
2346
  }
2226
2347
  return;
2227
2348
  }
2349
+ // `--teams` prints the team-grouped report: teammates under their team (with
2350
+ // spawner + spawn time), bare SDK spawns in their own bucket. --flat/--tree
2351
+ // keep their inline table rendering; a search query keeps the picker. Placed
2352
+ // before the project overview and picker so it wins the bare `--teams` case.
2353
+ if (options.teams && !options.flat && !options.tree && !searchQuery && !pathFilter) {
2354
+ const liveIndex = await maybeLiveIndex(options);
2355
+ printTeamsView(sessions, liveIndex, hiddenUnmanaged);
2356
+ return;
2357
+ }
2228
2358
  // The grouped project overview is the bare interactive default: a scannable
2229
2359
  // dashboard of the whole fleet grouped by project, newest-active first.
2230
2360
  // Interact/resume via `agents sessions <project>` or `agents sessions resume`.
2231
2361
  if (wantsOverview) {
2232
2362
  const liveIndex = await maybeLiveIndex(options);
2363
+ if (options.routine) {
2364
+ printRoutineRunOverview(sessions, liveIndex, { hiddenCount, hiddenUnmanaged });
2365
+ return;
2366
+ }
2233
2367
  // Per-project row cap is fixed (--limit carries a default of 50 and drives
2234
2368
  // the fetch pool, not the display); `--all` expands every group instead.
2235
2369
  printSessionOverview(sessions, hiddenCount, liveIndex, { perProjectCap: OVERVIEW_ROWS_PER_PROJECT, expand: !!options.all, hiddenUnmanaged });
@@ -2553,6 +2687,25 @@ function printSessionOverview(pool, hiddenCount, liveIndex, opts) {
2553
2687
  if (opts.hiddenUnmanaged)
2554
2688
  console.log(chalk.gray(formatUnmanagedHiddenFooter(opts.hiddenUnmanaged)));
2555
2689
  }
2690
+ export function printRoutineRunOverview(sessions, liveIndex, opts = {}) {
2691
+ const groups = buildRoutineRunGroups(sessions);
2692
+ console.log(chalk.gray(`${sessions.length} session${sessions.length === 1 ? '' : 's'} · ${groups.length} routine run${groups.length === 1 ? '' : 's'} · newest run first\n`));
2693
+ let first = true;
2694
+ for (const group of groups) {
2695
+ if (!first)
2696
+ console.log();
2697
+ first = false;
2698
+ console.log(`${chalk.cyan('▸')} ${chalk.cyan.bold(group.runId)} ` +
2699
+ `${chalk.gray(`${group.sessions.length} session${group.sessions.length === 1 ? '' : 's'} · ${formatRelativeTime(group.timestamp)}`)}`);
2700
+ for (const session of group.sessions)
2701
+ console.log(treeSessionRow(session, liveIndex?.get(session.id)));
2702
+ }
2703
+ console.log(chalk.gray('\nGrouped by routine run id and timestamp.'));
2704
+ if (opts.hiddenCount)
2705
+ console.log(chalk.gray(formatTeamHiddenFooter(opts.hiddenCount)));
2706
+ if (opts.hiddenUnmanaged)
2707
+ console.log(chalk.gray(formatUnmanagedHiddenFooter(opts.hiddenUnmanaged)));
2708
+ }
2556
2709
  function printSessionTable(sessions, hiddenCount = 0, tree = false, liveIndex) {
2557
2710
  if (tree) {
2558
2711
  // Group by directory; drop the id/version columns from view. The short id
@@ -2599,6 +2752,118 @@ function printSessionTable(sessions, hiddenCount = 0, tree = false, liveIndex) {
2599
2752
  console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
2600
2753
  }
2601
2754
  }
2755
+ /** Width of the mode cell (plan/edit/auto/skip) in a team-member row. */
2756
+ const TEAM_MODE_W = 5;
2757
+ /** Longest teammate handle folded into a team-member row before it truncates. */
2758
+ const TEAM_HANDLE_W = 16;
2759
+ /**
2760
+ * One row under a team group: `shortId · agent · mode · handle · doing · time`.
2761
+ * Unlike {@link flatSessionRow}/{@link treeSessionRow} it drops the
2762
+ * `[team/handle]` topic prefix — the group header already names the team — and
2763
+ * promotes the teammate's mode and handle to their own cells, the richer
2764
+ * identity the `--teams` view exists to show (RUSH-1997).
2765
+ */
2766
+ function teamMemberRow(session, live) {
2767
+ const agentColor = colorAgent(session.agent);
2768
+ const origin = session.teamOrigin;
2769
+ const age = sessionAgeParts(session.timestamp, session.lastActivity);
2770
+ const handle = safeTeamText(origin?.handle) ?? session.shortId;
2771
+ const mode = safeTeamText(origin?.mode) ?? '';
2772
+ const { glyph, preview } = liveGlyphAndPreview(live);
2773
+ // Match flatSessionRow: a live preview already folds ActiveSession.todos; a
2774
+ // resting row surfaces SessionMeta.todos when the scan attached it.
2775
+ const restingTodo = !live ? formatTodoCompact(session.todos) : '';
2776
+ const doing = [restingTodo, preview || session.topic || ''].filter(Boolean).join(' · ');
2777
+ const shownHandle = truncateToWidth(handle, TEAM_HANDLE_W);
2778
+ const head = doing ? `${shownHandle} · ${doing}` : shownHandle;
2779
+ const badges = signalBadges(metaSignals(session));
2780
+ const badgeW = badges ? stringWidth(badges) + 1 : 0;
2781
+ const { cell: statusCell, width: statusW } = liveStatusCell(live);
2782
+ const glyphW = glyph ? 2 : 0;
2783
+ const baseTopicW = terminalWidth() - (2 + 9 + 8 + (TEAM_MODE_W + 1)) - glyphW - statusW - badgeW - stringWidth(age.last) - 1;
2784
+ const when = timeCell(age, baseTopicW);
2785
+ const topicW = Math.max(12, baseTopicW - when.extraW);
2786
+ return (' ' +
2787
+ chalk.dim(padToWidth(session.shortId, 9)) +
2788
+ agentColor(padToWidth(truncateToWidth(session.agent, 7), 8)) +
2789
+ chalk.yellow(padToWidth(truncateToWidth(mode || '-', TEAM_MODE_W), TEAM_MODE_W + 1)) +
2790
+ (badges ? badges + ' ' : '') +
2791
+ (glyph ? glyph + ' ' : '') +
2792
+ statusCell +
2793
+ padToWidth(chalk.white(truncateToWidth(head, topicW)), topicW) +
2794
+ ' ' +
2795
+ when.text);
2796
+ }
2797
+ /** The header line for one team group (or the residual no-team bucket). */
2798
+ function teamGroupHeader(g, glyph, labelById) {
2799
+ const count = chalk.gray(`(${g.sessions.length})`);
2800
+ if (g.kind === 'noTeam') {
2801
+ // These carry the isTeamOrigin flag but no teammate meta.json — a Task
2802
+ // sub-agent's transcript is never indexed, so in practice this bucket is
2803
+ // headless `agents run` spawns or teammates whose meta aged out. Named
2804
+ // honestly rather than claimed as real teammates.
2805
+ return (chalk.magenta('▸') + ' ' + chalk.magenta.bold(NO_TEAM_GROUP_KEY) + ' ' + count +
2806
+ ' ' + chalk.gray('team-flagged spawns with no team record (`agents run`, or aged-out teammates)'));
2807
+ }
2808
+ const bits = [chalk.cyan('▸') + ' ' + chalk.cyan.bold(g.key), count];
2809
+ if (glyph)
2810
+ bits.push(glyph);
2811
+ if (g.spawnerSessionId) {
2812
+ const who = labelById.get(g.spawnerSessionId) ?? g.spawnerSessionId.slice(0, 8);
2813
+ bits.push(chalk.gray(`by ${who}`));
2814
+ }
2815
+ bits.push(chalk.gray(`spawned ${formatRelativeTime(g.firstSpawnTs)}`));
2816
+ return bits.join(' ');
2817
+ }
2818
+ /**
2819
+ * The `agents sessions --teams` view: team-origin sessions grouped by team, the
2820
+ * richer display RUSH-1997 asks for. Each named team names its spawner (the
2821
+ * orchestrator session that created it) and spawn time, with its teammates'
2822
+ * mode + handle under it; team-flagged spawns that carry no teammate record fall
2823
+ * into a trailing no-team bucket, so a real teammate and a bare spawn are never
2824
+ * conflated. A printed report like `--tree`, not an interactive pick.
2825
+ */
2826
+ function printTeamsView(pool, liveIndex, hiddenUnmanaged = 0) {
2827
+ const groups = groupSessionsByTeam(pool);
2828
+ if (groups.length === 0) {
2829
+ console.log(chalk.gray('No team sessions found.'));
2830
+ console.log(chalk.gray('Team sessions are spawned by `agents teams` — see `agents teams status`.'));
2831
+ return;
2832
+ }
2833
+ // Resolve a spawner session id to its human label from the pool. The
2834
+ // orchestrator may or may not be in view; fall back to the short id, the same
2835
+ // degradation the --active teams rows use (active.ts resolveOrchestratorLabels).
2836
+ const labelById = new Map();
2837
+ for (const s of pool) {
2838
+ const label = s.label || s.topic;
2839
+ if (label)
2840
+ labelById.set(s.id, cleanPreview(label));
2841
+ }
2842
+ const total = groups.reduce((n, g) => n + g.sessions.length, 0);
2843
+ const teamCount = groups.filter((g) => g.kind === 'team').length;
2844
+ const noTeam = groups.find((g) => g.kind === 'noTeam');
2845
+ const parts = [
2846
+ `${total} team session${total === 1 ? '' : 's'}`,
2847
+ `${teamCount} team${teamCount === 1 ? '' : 's'}`,
2848
+ ];
2849
+ if (noTeam)
2850
+ parts.push(`${noTeam.sessions.length} without a team record`);
2851
+ console.log(chalk.gray(parts.join(' · ') + '\n'));
2852
+ let first = true;
2853
+ for (const g of groups) {
2854
+ if (!first)
2855
+ console.log();
2856
+ first = false;
2857
+ const { glyph } = liveGlyphAndPreview(liveIndex?.get(g.sessions[0].id));
2858
+ console.log(teamGroupHeader(g, glyph, labelById));
2859
+ for (const s of g.sessions)
2860
+ console.log(teamMemberRow(s, liveIndex?.get(s.id)));
2861
+ }
2862
+ console.log();
2863
+ console.log(chalk.gray('newest-active team first · resume any row with `agents sessions resume <id>`'));
2864
+ if (hiddenUnmanaged > 0)
2865
+ console.log(chalk.gray(formatUnmanagedHiddenFooter(hiddenUnmanaged)));
2866
+ }
2602
2867
  function buildFilterOptions(options) {
2603
2868
  const opts = {};
2604
2869
  if (options.include)
@@ -2973,15 +3238,6 @@ export async function pickSessionInteractive(sessions, message = 'Search session
2973
3238
  throw err;
2974
3239
  }
2975
3240
  }
2976
- /**
2977
- * The machine a picked session lives on when its transcript is on that peer's
2978
- * disk (folded in over the live fan-out), else undefined. Keys off `_remote`,
2979
- * NOT `machine !== local`: a synced mirror is machine-tagged too, but its file
2980
- * is a local mirror path, so it must be read/resumed locally like any other.
2981
- */
2982
- function remoteMachineOf(session) {
2983
- return session._remote ? session.machine : undefined;
2984
- }
2985
3241
  /** True when the peer wasn't a dialable device; prints one clear line so a
2986
3242
  * remote pick never dead-ends silently. */
2987
3243
  function warnNoPeerTarget(machine, session) {
@@ -3007,31 +3263,50 @@ export async function handlePickedSession(picked) {
3007
3263
  console.log(chalk.gray(`Watch for it with: agents sessions --active${picked.session.machine ? ` --host ${picked.session.machine}` : ''}`));
3008
3264
  return;
3009
3265
  }
3010
- // A session on another machine is read/resumed ON that machine over SSH — its
3011
- // transcript and agent binary live there. Both actions execute on the peer
3012
- // (not a local `--host` hop, which would discover locally and dead-end for a
3013
- // session that exists only on the peer).
3014
- const remote = remoteMachineOf(picked.session);
3015
- if (remote) {
3016
- if (picked.action === 'view') {
3017
- const rc = await runOnPeer(['sessions', picked.session.shortId, '--markdown'], remote);
3018
- if (rc === 'no-target')
3019
- warnNoPeerTarget(remote, picked.session);
3020
- }
3021
- else {
3022
- console.log(chalk.gray(`Resuming ${picked.session.shortId} on ${remote} over SSH...`));
3023
- const rc = await runOnPeer(['sessions', 'resume', picked.session.shortId], remote, { tty: true });
3266
+ // Reading and resuming are on DIFFERENT machines' terms, and conflating them
3267
+ // is what RUSH-2022 was. Reading follows the FILE: a synced mirror sits on
3268
+ // this disk, so only a live fan-out row (`_remote`) has to be read on the
3269
+ // peer. Resuming follows the HARNESS STATE, which is on the owning machine
3270
+ // whatever the transcript's location — a mirror included.
3271
+ if (picked.action === 'view') {
3272
+ const readFrom = picked.session._remote ? picked.session.machine : undefined;
3273
+ if (readFrom) {
3274
+ const rc = await runOnPeer(['sessions', picked.session.shortId, '--markdown'], readFrom);
3024
3275
  if (rc === 'no-target')
3025
- warnNoPeerTarget(remote, picked.session);
3276
+ warnNoPeerTarget(readFrom, picked.session);
3277
+ return;
3026
3278
  }
3027
- return;
3028
- }
3029
- if (picked.action === 'view') {
3030
3279
  await renderSession(picked.session, 'summary', {});
3031
3280
  return;
3032
3281
  }
3282
+ if (await resumeOnOwnerIfRemote(picked.session))
3283
+ return;
3033
3284
  await resumeSessionInPlace(picked.session);
3034
3285
  }
3286
+ /**
3287
+ * Resume `session` on the machine that owns it, when that is not this one.
3288
+ * Returns true when it handled the resume (hopped, or reported an unreachable
3289
+ * owner), false when the session is local and the caller should take over here.
3290
+ *
3291
+ * The one hop for a foreground, one-session-at-a-time resume — shared by the
3292
+ * `agents sessions` picker and by `sessions resume`'s no-tab-backend path, which
3293
+ * would otherwise reach `resumeSessionInPlace` and be refused (RUSH-2022).
3294
+ */
3295
+ export async function resumeOnOwnerIfRemote(session) {
3296
+ const owner = sessionOwnerDevice(session);
3297
+ if (!owner)
3298
+ return false;
3299
+ console.log(chalk.gray(`Resuming ${session.shortId} on ${owner} over SSH...`));
3300
+ // `agents resume <id>` is the strict single-session path — one pick, one
3301
+ // resume. (`sessions resume <shortId>` would re-open a picker over there.)
3302
+ const rc = await runOnPeer(['resume', session.id], owner, {
3303
+ tty: true,
3304
+ env: { [RESUME_PINNED_ENV]: '1' },
3305
+ });
3306
+ if (rc === 'no-target')
3307
+ warnNoPeerTarget(owner, session);
3308
+ return true;
3309
+ }
3035
3310
  /**
3036
3311
  * Resume a session in the current terminal — a foreground takeover of this
3037
3312
  * process. Used by the single-select picker and by `sessions resume` when the
@@ -3040,6 +3315,20 @@ export async function handlePickedSession(picked) {
3040
3315
  * version-pinned launcher is genuinely missing.
3041
3316
  */
3042
3317
  export async function resumeSessionInPlace(session) {
3318
+ // This function is the LOCAL takeover, and every caller is responsible for
3319
+ // routing a peer-owned session before it gets here (the picker above,
3320
+ // `agents resume`, `sessions attach`). Reaching it with one anyway means a
3321
+ // caller skipped that step, so refuse rather than start the harness against
3322
+ // state this box does not have — the `fs.existsSync(session.cwd)` fallback
3323
+ // just below is exactly how that used to pass unnoticed, quietly swapping in
3324
+ // `process.cwd()` when the recorded directory was a peer's path (RUSH-2022).
3325
+ const owner = sessionOwnerDevice(session);
3326
+ if (owner) {
3327
+ console.error(chalk.red(`Session ${session.shortId} belongs to ${owner} — it cannot resume on this machine.`));
3328
+ console.error(chalk.gray(` Resume it there: agents resume ${session.id}`));
3329
+ process.exitCode = 1;
3330
+ return;
3331
+ }
3043
3332
  const cwd = session.cwd && fs.existsSync(session.cwd)
3044
3333
  ? session.cwd
3045
3334
  : process.cwd();
@@ -3459,7 +3748,7 @@ function scoreSessionQuery(session, terms) {
3459
3748
  * the project you specified AND elsewhere, producing an ambiguity error
3460
3749
  * even though the user already pointed at the correct scope.
3461
3750
  */
3462
- function applyScopeFilters(sessions, scope) {
3751
+ export function applyScopeFilters(sessions, scope) {
3463
3752
  let filtered = sessions;
3464
3753
  if (scope.project) {
3465
3754
  const projectQuery = scope.project.toLowerCase();
@@ -3482,8 +3771,21 @@ function applyScopeFilters(sessions, scope) {
3482
3771
  return true;
3483
3772
  });
3484
3773
  }
3774
+ if (scope.routine) {
3775
+ filtered = filtered.filter((session) => session.origin === 'routine');
3776
+ if (typeof scope.routine === 'string') {
3777
+ const names = [...new Set(filtered.map((session) => session.routineName).filter((name) => !!name))];
3778
+ const selected = resolveRoutineName(scope.routine, names);
3779
+ filtered = selected
3780
+ ? filtered.filter((session) => session.routineName === selected)
3781
+ : [];
3782
+ }
3783
+ }
3485
3784
  return filtered;
3486
3785
  }
3786
+ export function artifactLookupScope(agent, project, routine) {
3787
+ return { agent, project, routine };
3788
+ }
3487
3789
  async function renderArtifactsGlobal(query, listAll, name, scope) {
3488
3790
  const spinner = ora().start();
3489
3791
  const tracker = createScanProgressTracker(FIND_VERBS, 'session', spinner);
@@ -3929,9 +4231,9 @@ export function registerSessionsCommands(program) {
3929
4231
  .option('--opencode', 'Shorthand for --agent opencode')
3930
4232
  .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.')
3931
4233
  .option('--unmanaged', "Also show sessions from your own ~/.<agent> installs (hidden once agents-cli manages that agent)")
3932
- .option('--team, --teams', 'Include team-spawned sessions (hidden by default)')
4234
+ .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')
3933
4235
  .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.")
3934
- .option('--routine', 'Show only sessions archived from routine runs')
4236
+ .option('--routines, --routine [name]', 'Show routine-run sessions; omit the name on a TTY to pick one interactively (fuzzy name matching)')
3935
4237
  .option('-p, --project <name>', 'Filter by project name (searches across all directories)')
3936
4238
  .option('--skill <name>', 'Only sessions that invoked this skill (matches a bare name or a namespaced plugin skill\'s short name, e.g. --skill design finds rush:design)')
3937
4239
  .option('--plugin <name>', 'Only sessions that used a skill/command owned by this plugin')
@@ -4014,12 +4316,18 @@ export function registerSessionsCommands(program) {
4014
4316
  # Search across every directory, not just this project
4015
4317
  agents sessions "topic" --all
4016
4318
 
4319
+ # Team-spawned sessions, grouped by team (spawner + spawn time per team,
4320
+ # teammate mode/handle per row; team-flagged spawns with no team record
4321
+ # in a trailing (no team) bucket)
4322
+ agents sessions --teams
4323
+
4017
4324
  # Who spawned which team: an orchestrator row carries team:<name>, and a
4018
4325
  # teammate row [<team>/<handle>]. --in-team narrows to one team's lineage.
4019
4326
  agents sessions --in-team redesign --teams
4020
4327
 
4021
4328
  # Show routine-run sessions and open one by routine run id
4022
4329
  agents sessions --routine --all
4330
+ agents sessions --routine nightly-review --all
4023
4331
  agents sessions 2026-07-21T10-30-00-000Z
4024
4332
 
4025
4333
  # Export for analysis
@@ -4066,7 +4374,7 @@ export function registerSessionsCommands(program) {
4066
4374
  - --first and --last are mutually exclusive.
4067
4375
  - A filter flag (--include/--exclude/--first/--last) without --markdown/--json defaults to --markdown output.
4068
4376
  - --cloud sources from Rush Cloud captured runs instead of local disk.
4069
- - --routine shows only transcripts archived from routine run directories; routine rows also resolve by run id.
4377
+ - --routine [name] shows transcripts archived from routine runs. On a TTY, omit the name to pick a routine; a name accepts exact, substring, or unambiguous typo matches. --routines is an alias. Routine rows also resolve by run id.
4070
4378
  - Without --teams, team-spawned sessions are hidden by default.
4071
4379
  `,
4072
4380
  });