@phnx-labs/agents-cli 1.22.32 → 1.22.34

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 (76) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +2 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/accounts.js +3 -3
  5. package/dist/commands/browser-sessions-picker.d.ts +16 -0
  6. package/dist/commands/browser-sessions-picker.js +179 -0
  7. package/dist/commands/browser.js +9 -4
  8. package/dist/commands/hosts.js +1 -5
  9. package/dist/commands/inspect.js +174 -41
  10. package/dist/commands/message.d.ts +6 -1
  11. package/dist/commands/message.js +60 -3
  12. package/dist/commands/sessions-browser.js +2 -2
  13. package/dist/commands/sessions.d.ts +54 -4
  14. package/dist/commands/sessions.js +274 -58
  15. package/dist/commands/ssh.js +17 -5
  16. package/dist/commands/teams.d.ts +28 -0
  17. package/dist/commands/teams.js +148 -13
  18. package/dist/commands/upgrade.d.ts +7 -0
  19. package/dist/commands/upgrade.js +10 -0
  20. package/dist/commands/watchdog.d.ts +2 -0
  21. package/dist/commands/watchdog.js +112 -27
  22. package/dist/index.js +51 -59
  23. package/dist/lib/agents.js +6 -0
  24. package/dist/lib/browser/sessions-list.d.ts +81 -0
  25. package/dist/lib/browser/sessions-list.js +179 -4
  26. package/dist/lib/daemon.js +45 -5
  27. package/dist/lib/devices/connect.d.ts +33 -0
  28. package/dist/lib/devices/connect.js +61 -3
  29. package/dist/lib/devices/doctor-findings.d.ts +4 -2
  30. package/dist/lib/devices/doctor-findings.js +4 -2
  31. package/dist/lib/exec.js +63 -6
  32. package/dist/lib/help.d.ts +3 -2
  33. package/dist/lib/help.js +4 -0
  34. package/dist/lib/hosts/dispatch.d.ts +2 -32
  35. package/dist/lib/hosts/dispatch.js +6 -61
  36. package/dist/lib/hosts/tasks.d.ts +7 -0
  37. package/dist/lib/hosts/tasks.js +9 -0
  38. package/dist/lib/mailbox-target.d.ts +27 -0
  39. package/dist/lib/mailbox-target.js +21 -0
  40. package/dist/lib/mcp.d.ts +10 -0
  41. package/dist/lib/mcp.js +21 -2
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  43. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  44. package/dist/lib/migrate.d.ts +11 -0
  45. package/dist/lib/migrate.js +40 -0
  46. package/dist/lib/project-root.d.ts +47 -0
  47. package/dist/lib/project-root.js +68 -0
  48. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  49. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  50. package/dist/lib/secrets/agent.d.ts +9 -2
  51. package/dist/lib/secrets/agent.js +52 -7
  52. package/dist/lib/secrets/reaper.d.ts +24 -3
  53. package/dist/lib/secrets/reaper.js +55 -6
  54. package/dist/lib/session/discover.js +12 -0
  55. package/dist/lib/session/render.d.ts +2 -0
  56. package/dist/lib/session/render.js +1 -1
  57. package/dist/lib/session/team-filter.d.ts +11 -0
  58. package/dist/lib/session/team-filter.js +10 -0
  59. package/dist/lib/shims.js +9 -2
  60. package/dist/lib/startup/command-registry.d.ts +1 -0
  61. package/dist/lib/startup/command-registry.js +8 -3
  62. package/dist/lib/startup/root-command.d.ts +3 -0
  63. package/dist/lib/startup/root-command.js +10 -0
  64. package/dist/lib/teams/agents.d.ts +136 -6
  65. package/dist/lib/teams/agents.js +324 -58
  66. package/dist/lib/teams/worktree.d.ts +39 -2
  67. package/dist/lib/teams/worktree.js +60 -4
  68. package/dist/lib/types.d.ts +11 -0
  69. package/dist/lib/versions.js +2 -2
  70. package/dist/lib/watchdog/history.d.ts +20 -0
  71. package/dist/lib/watchdog/history.js +46 -0
  72. package/dist/lib/watchdog/log.d.ts +16 -1
  73. package/dist/lib/watchdog/log.js +82 -2
  74. package/dist/lib/watchdog/runner.d.ts +12 -0
  75. package/dist/lib/watchdog/runner.js +20 -0
  76. package/package.json +1 -1
@@ -13,7 +13,7 @@ import * as path from 'path';
13
13
  import { spawn } from 'child_process';
14
14
  import { Option } from 'commander';
15
15
  import chalk from 'chalk';
16
- import { truncate, padRight } from '../lib/format.js';
16
+ import { truncate, padRight, humanDuration } from '../lib/format.js';
17
17
  import { sanitizeForTerminal } from '../lib/redact.js';
18
18
  import { resolveProjectKey } from '../lib/project-key.js';
19
19
  import { listProjectDefs, resolveProjectNameForCwd } from '../lib/projects.js';
@@ -33,17 +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, groupSessionsByTeam, NO_TEAM_GROUP_KEY, } from '../lib/session/team-filter.js';
36
+ import { filterTeamSessions, shouldShowTeamSessions, 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
- import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList, linkPath, linkUrl, shortenModel } from '../lib/session/render.js';
40
+ import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList, linkPath, linkUrl, shortenModel, formatTokenCount } from '../lib/session/render.js';
41
41
  import { linearIssueUrl } from '../lib/session/linear.js';
42
42
  import { sessionOwnerDevice, RESUME_PINNED_ENV } from '../lib/session/resume-owner.js';
43
43
  import { renderMarkdown } from '../lib/markdown.js';
44
44
  import { AGENTS, colorAgent, resolveAgentName } from '../lib/agents.js';
45
45
  import { getShimsDir } from '../lib/state.js';
46
- import { listJobs, listJobsWithRuns, listRuns } from '../lib/routines.js';
46
+ import { listJobs, listJobsWithRuns, listRuns, getRunDir } from '../lib/routines.js';
47
+ import { formatUsd } from '../lib/pricing/cost.js';
47
48
  import { fuzzyMatch, FUZZY_PRESETS } from '../lib/fuzzy.js';
48
49
  import { itemPicker } from '../lib/picker.js';
49
50
  import { resolveSessionAlias } from '../lib/session/actor-sidecar.js';
@@ -72,7 +73,7 @@ import { registerSessionsBackfillCommand } from './sessions-backfill.js';
72
73
  import { registerSessionsStatsCommand } from './sessions-stats.js';
73
74
  import { registerSessionsInsightsCommand } from './insights.js';
74
75
  import { registerSessionsOptimizeCommand } from './sessions-optimize.js';
75
- import { runBrowserSessions } from '../lib/browser/sessions-list.js';
76
+ import { runBrowserSessionsCommand } from './browser-sessions-picker.js';
76
77
  import { countToolProgramOccurrences, parseToolProgramCountClause, readToolIndexCoverage, searchToolCalls, TOOL_QUERY_MAX_CLAUSE_BYTES, TOOL_QUERY_MAX_CLAUSES, TOOL_QUERY_MAX_RESULT_SESSIONS, serializeToolSearchEnvelope, toolSearchRemoteReceiveBudget, } from '../lib/session/tool-index.js';
77
78
  const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
78
79
  function collectQueryClause(value, previous) {
@@ -1520,6 +1521,11 @@ export function hasNoBrowserDisqualifyingFlags(options, query) {
1520
1521
  !options.skill &&
1521
1522
  !options.plugin &&
1522
1523
  !options.sort &&
1524
+ // --routine without a name owns a two-stage picker (routine -> run ->
1525
+ // session), and a named routine owns the run-grouped overview. The generic
1526
+ // browser can only flatten routine sessions into its ordinary row list, so
1527
+ // routing this flag there silently bypasses both routine-specific views.
1528
+ !options.routine &&
1523
1529
  !options.artifacts &&
1524
1530
  options.artifact === undefined &&
1525
1531
  // --cloud lists a provider's tasks, not the transcript index, and
@@ -1626,33 +1632,238 @@ function safeListJobsWithRuns() {
1626
1632
  return [];
1627
1633
  }
1628
1634
  }
1629
- export async function filterSessionsByRoutine(sessions, routine, interactive) {
1630
- // The name universe is transcripts UNION routines that have any run record
1631
- // so a routine whose attempts never produced a session still resolves.
1635
+ /**
1636
+ * Resolve which routine the user is targeting for `--routine`. Returns `null` to
1637
+ * ABORT the command (a bad `--routine <name>` printed an error, or the user
1638
+ * cancelled the interactive pick). `{ name: null }` means "no routine was
1639
+ * selected, don't filter" — the degenerate bare `--routine` piped to a
1640
+ * non-interactive sink. `{ name }` is a chosen routine.
1641
+ *
1642
+ * The name universe is transcripts UNION every routine with a definition or a
1643
+ * run record (`safeListJobsWithRuns`), so a command-only or never-ran routine —
1644
+ * which produces no `SessionMeta` — still resolves and drills down.
1645
+ */
1646
+ export async function selectRoutineTarget(sessions, routine, interactive) {
1632
1647
  const routineNames = [...new Set([
1633
1648
  ...sessions.map((session) => session.routineName).filter((name) => !!name),
1634
1649
  ...safeListJobsWithRuns(),
1635
1650
  ])].sort((a, b) => a.localeCompare(b));
1636
- let selectedRoutine = null;
1637
1651
  if (typeof routine === 'string') {
1638
- selectedRoutine = resolveRoutineName(routine, routineNames);
1639
- if (!selectedRoutine) {
1652
+ const resolved = resolveRoutineName(routine, routineNames);
1653
+ if (!resolved) {
1640
1654
  console.error(chalk.red(`No routine matches "${routine}".`));
1641
1655
  if (routineNames.length > 0)
1642
1656
  console.error(chalk.gray(`Available routines: ${routineNames.join(', ')}`));
1643
1657
  process.exitCode = 1;
1644
1658
  return null;
1645
1659
  }
1660
+ return { name: resolved };
1646
1661
  }
1647
- else if (interactive) {
1648
- selectedRoutine = await selectRoutineName(sessions);
1649
- if (!selectedRoutine)
1662
+ if (interactive) {
1663
+ const picked = await selectRoutineName(sessions);
1664
+ if (!picked)
1650
1665
  return null;
1666
+ return { name: picked };
1651
1667
  }
1652
- if (!selectedRoutine)
1653
- return sessions;
1654
- const matched = sessions.filter((session) => session.routineName === selectedRoutine);
1655
- return matched;
1668
+ return { name: null };
1669
+ }
1670
+ export async function filterSessionsByRoutine(sessions, routine, interactive) {
1671
+ const target = await selectRoutineTarget(sessions, routine, interactive);
1672
+ if (!target)
1673
+ return null;
1674
+ return target.name
1675
+ ? sessions.filter((session) => session.routineName === target.name)
1676
+ : sessions;
1677
+ }
1678
+ export function executionKind(meta) {
1679
+ if (meta.workflow)
1680
+ return 'workflow';
1681
+ if (meta.agent)
1682
+ return 'agent';
1683
+ return 'command';
1684
+ }
1685
+ /**
1686
+ * Reconcile a routine's canonical run records (`listRuns`, local disk) with the
1687
+ * indexed sessions for that routine (fleet-wide, keyed by `routineRunId`).
1688
+ */
1689
+ export function buildRoutineDrilldown(name, sessions) {
1690
+ const runs = listRuns(name);
1691
+ const forRoutine = sessions.filter((s) => s.routineName === name);
1692
+ const byRun = new Map();
1693
+ for (const s of forRoutine) {
1694
+ const rid = s.routineRunId ?? s.timestamp;
1695
+ (byRun.get(rid) ?? byRun.set(rid, []).get(rid)).push(s);
1696
+ }
1697
+ const runIds = new Set(runs.map((r) => r.runId));
1698
+ const entries = runs
1699
+ .map((meta) => ({
1700
+ meta,
1701
+ sessions: (byRun.get(meta.runId) ?? []).sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1)),
1702
+ }))
1703
+ // Newest run first: `startedAt`, then run id (an ISO-derived slot) as a tie-break.
1704
+ .sort((a, b) => {
1705
+ const at = a.meta.startedAt || a.meta.runId;
1706
+ const bt = b.meta.startedAt || b.meta.runId;
1707
+ return at < bt ? 1 : at > bt ? -1 : b.meta.runId.localeCompare(a.meta.runId);
1708
+ });
1709
+ const orphanSessions = buildRoutineRunGroups(forRoutine.filter((s) => !runIds.has(s.routineRunId ?? s.timestamp)));
1710
+ return {
1711
+ name,
1712
+ runs: entries,
1713
+ orphanSessions,
1714
+ runRecordCount: runs.length,
1715
+ linkedSessionCount: forRoutine.length,
1716
+ // "Agent routine" = anything that isn't purely command runs. A workflow run
1717
+ // carries no `agent` field but still expects a session, so key off
1718
+ // executionKind, not `agent` alone.
1719
+ isAgentRoutine: runs.some((r) => executionKind(r) !== 'command') || forRoutine.length > 0,
1720
+ };
1721
+ }
1722
+ /** Glyph + word for a run's terminal status. */
1723
+ function runStatusCell(status) {
1724
+ switch (status) {
1725
+ case 'completed': return `${chalk.green('✓')} ${chalk.green('completed')}`;
1726
+ case 'running': return `${chalk.cyan('◍')} ${chalk.cyan('running')}`;
1727
+ case 'failed': return `${chalk.red('✗')} ${chalk.red('failed')}`;
1728
+ case 'timeout': return `${chalk.red('✗')} ${chalk.red('timeout')}`;
1729
+ case 'blocked': return `${chalk.yellow('⚠')} ${chalk.yellow('blocked')}`;
1730
+ case 'skipped': return `${chalk.gray('↷')} ${chalk.gray('skipped')}`;
1731
+ case 'missed': return `${chalk.gray('·')} ${chalk.gray('missed')}`;
1732
+ default: return chalk.gray(status);
1733
+ }
1734
+ }
1735
+ /** Where a run's body executed: a host, a cloud provider, or this machine. */
1736
+ function runPlacement(meta) {
1737
+ if (meta.host)
1738
+ return `host:${meta.host}`;
1739
+ if (meta.cloudProvider)
1740
+ return `cloud:${meta.cloudProvider}`;
1741
+ return 'local';
1742
+ }
1743
+ /** The trigger clause: kind + who triggered it (dropping the noisy UNRESOLVED). */
1744
+ function runTrigger(meta) {
1745
+ const kind = meta.triggerKind ?? 'schedule';
1746
+ const who = meta.triggeredBy && !meta.triggeredBy.startsWith('UNRESOLVED') ? ` by ${meta.triggeredBy}` : '';
1747
+ return `${kind}${who}`;
1748
+ }
1749
+ /** The tail clause explaining a run's outcome (exit code / error / skip reason). */
1750
+ function runOutcomeDetail(meta) {
1751
+ if (meta.status === 'skipped' && meta.skipReason) {
1752
+ const ref = meta.activeRunId ? ` → ${meta.activeRunId}` : '';
1753
+ return `${meta.skipReason.replace(/_/g, ' ')}${ref}`;
1754
+ }
1755
+ if (meta.status === 'blocked') {
1756
+ return meta.readiness ? `${meta.readiness.code}: ${meta.readiness.message}` : (meta.errorMessage ?? 'not ready');
1757
+ }
1758
+ if (meta.status === 'missed')
1759
+ return 'daemon down at fire time';
1760
+ if (meta.errorMessage)
1761
+ return meta.errorMessage;
1762
+ // A clean `exit 0` is already implied by the green "completed" status — only a
1763
+ // non-zero code carries information worth a tail clause.
1764
+ if (typeof meta.exitCode === 'number' && meta.exitCode !== 0)
1765
+ return `exit ${meta.exitCode}`;
1766
+ return '';
1767
+ }
1768
+ /** Compact per-session metadata line under a linked session row. */
1769
+ function linkedSessionMeta(session) {
1770
+ const parts = [];
1771
+ parts.push(session.version ? `${session.agent} v${session.version}` : session.agent);
1772
+ if (session.account)
1773
+ parts.push(session.account);
1774
+ if (session.model)
1775
+ parts.push(shortenModel(session.model));
1776
+ if (typeof session.outputTokens === 'number')
1777
+ parts.push(`${formatTokenCount(session.outputTokens)} out`);
1778
+ else if (typeof session.tokenCount === 'number')
1779
+ parts.push(`${formatTokenCount(session.tokenCount)} tok`);
1780
+ if (typeof session.costUsd === 'number' && session.costUsd > 0)
1781
+ parts.push(formatUsd(session.costUsd));
1782
+ if (typeof session.durationMs === 'number' && session.durationMs > 0)
1783
+ parts.push(humanDuration(session.durationMs));
1784
+ if (typeof session.toolCallCount === 'number')
1785
+ parts.push(`${session.toolCallCount} tools`);
1786
+ return chalk.gray(' ' + parts.join(' · '));
1787
+ }
1788
+ /**
1789
+ * Render the routine drilldown: canonical run history first, each run linked to
1790
+ * its indexed agent session(s). This is the run/session seam made visible — a
1791
+ * command routine shows its runs and states plainly that no session is expected;
1792
+ * an agent routine shows the same runs plus each run's session metadata.
1793
+ */
1794
+ export function printRoutineDrilldown(drill, liveIndex, opts = {}) {
1795
+ const runWord = drill.runRecordCount === 1 ? 'run record' : 'run records';
1796
+ const sessWord = drill.linkedSessionCount === 1 ? 'linked session' : 'linked sessions';
1797
+ console.log(`${chalk.cyan.bold(drill.name)} ` +
1798
+ chalk.gray(`${drill.runRecordCount} ${runWord} · ${drill.linkedSessionCount} ${sessWord}`));
1799
+ // A routine with no session ever produced is a command routine — say so up
1800
+ // front so the empty session column reads as expected, not as missing data.
1801
+ if (!drill.isAgentRoutine) {
1802
+ console.log(chalk.gray('Command routine — runs execute a shell command; no agent session is produced.'));
1803
+ }
1804
+ console.log();
1805
+ if (drill.runs.length === 0 && drill.orphanSessions.length === 0) {
1806
+ console.log(chalk.gray('No run records yet for this routine.'));
1807
+ return;
1808
+ }
1809
+ for (const entry of drill.runs) {
1810
+ const m = entry.meta;
1811
+ console.log(`${chalk.cyan('▸')} ${chalk.cyan.bold(m.runId)} ` +
1812
+ `${runStatusCell(m.status)} ` +
1813
+ chalk.gray(`${runTrigger(m)} · ${formatRelativeTime(m.startedAt)}`));
1814
+ const kind = executionKind(m);
1815
+ const detailParts = [kind];
1816
+ if (typeof m.duration === 'number' && m.duration > 0)
1817
+ detailParts.push(humanDuration(m.duration));
1818
+ detailParts.push(runPlacement(m));
1819
+ const outcome = runOutcomeDetail(m);
1820
+ if (outcome)
1821
+ detailParts.push(outcome);
1822
+ console.log(chalk.gray(' ' + detailParts.join(' · ')));
1823
+ // Log/report access — both are children of the run dir that a pre-execution
1824
+ // terminal (blocked/skipped/missed) never wrote, so gate each on existence
1825
+ // rather than pointing at a file that isn't there.
1826
+ const runDir = getRunDir(drill.name, m.runId);
1827
+ const logHints = [];
1828
+ if (fs.existsSync(path.join(runDir, 'stdout.log')))
1829
+ logHints.push(`log: ${path.join(runDir, 'stdout.log')}`);
1830
+ if (fs.existsSync(path.join(runDir, 'report.md')))
1831
+ logHints.push(`report: ${path.join(runDir, 'report.md')}`);
1832
+ if (logHints.length > 0)
1833
+ console.log(chalk.gray(' ' + logHints.join(' · ')));
1834
+ if (entry.sessions.length > 0) {
1835
+ for (const session of entry.sessions) {
1836
+ console.log(treeSessionRow(session, liveIndex?.get(session.id)));
1837
+ console.log(linkedSessionMeta(session));
1838
+ }
1839
+ }
1840
+ else if (kind !== 'command') {
1841
+ // An agent run that produced no session — say why (the run terminal
1842
+ // explains it), never fake a session row.
1843
+ console.log(chalk.gray(' no agent session archived for this run'));
1844
+ }
1845
+ console.log();
1846
+ }
1847
+ if (drill.orphanSessions.length > 0) {
1848
+ console.log(chalk.gray('Sessions with no local run record (run archived on another host):'));
1849
+ for (const group of drill.orphanSessions) {
1850
+ console.log(`${chalk.cyan('▸')} ${chalk.cyan.bold(group.runId)} ` +
1851
+ chalk.gray(`${group.sessions.length} session${group.sessions.length === 1 ? '' : 's'} · ${formatRelativeTime(group.timestamp)}`));
1852
+ for (const session of group.sessions) {
1853
+ console.log(treeSessionRow(session, liveIndex?.get(session.id)));
1854
+ console.log(linkedSessionMeta(session));
1855
+ }
1856
+ console.log();
1857
+ }
1858
+ }
1859
+ console.log(chalk.gray(`Run history from .history/runs/${drill.name}/ · resume a session with agents sessions resume <id>.`));
1860
+ // Every listing path must say what it dropped (sessions.ts invariant). Under
1861
+ // --routine team-origin sessions are shown (hiddenCount is 0), but unmanaged
1862
+ // installs may still be hidden — surface both footers when non-zero.
1863
+ if (opts.hiddenCount && opts.hiddenCount > 0)
1864
+ console.log(chalk.gray(formatTeamHiddenFooter(opts.hiddenCount)));
1865
+ if (opts.hiddenUnmanaged && opts.hiddenUnmanaged > 0)
1866
+ console.log(chalk.gray(formatUnmanagedHiddenFooter(opts.hiddenUnmanaged)));
1656
1867
  }
1657
1868
  /** The canonical `ag sessions …` command for a set of flags — the twin of the
1658
1869
  * browser's `y` hotkey (see --print-cmd). Normalizes to the stable flag form. */
@@ -2293,6 +2504,11 @@ limitSource) {
2293
2504
  // silently returns nothing for any team older than that — so the flag widens its
2294
2505
  // own scope, the way --all does, unless the caller set an explicit --limit.
2295
2506
  const wantsWholeTeam = !!options.inTeam;
2507
+ // A routine is not tied to the shell's cwd: its archived sessions retain the
2508
+ // working directory in which the scheduled agent actually ran. Scope the
2509
+ // routine catalog across directories so invoking it from ~/.agents/.system,
2510
+ // a project repo, or $HOME yields the same routine history.
2511
+ const wantsWholeRoutine = !!options.routine;
2296
2512
  // `--limit` has a commander default, so an untouched flag still arrives as a
2297
2513
  // string and truthiness can't tell it from a typed one. Commander records where
2298
2514
  // each value came from, which is the only signal that distinguishes an explicit
@@ -2301,7 +2517,7 @@ limitSource) {
2301
2517
  const userSetLimit = limitSource === 'cli' || limitSource === 'env';
2302
2518
  const limit = wantsOverview
2303
2519
  ? OVERVIEW_POOL_LIMIT
2304
- : parseInt(userSetLimit ? options.limit : wantsWholeTeam ? String(WHOLE_TEAM_POOL_LIMIT) : DEFAULT_LIMIT, 10);
2520
+ : parseInt(userSetLimit ? options.limit : wantsWholeTeam || wantsWholeRoutine ? String(WHOLE_TEAM_POOL_LIMIT) : DEFAULT_LIMIT, 10);
2305
2521
  if (toolEvidenceMode && (!Number.isSafeInteger(limit) || limit < 1 || limit > TOOL_QUERY_MAX_RESULT_SESSIONS)) {
2306
2522
  console.error(chalk.red(`Tool search --limit must be from 1 to ${TOOL_QUERY_MAX_RESULT_SESSIONS}.`));
2307
2523
  process.exitCode = 1;
@@ -2311,7 +2527,7 @@ limitSource) {
2311
2527
  // --since still narrows. Non-overview keeps the prior interactive-30d default.
2312
2528
  const since = wantsOverview
2313
2529
  ? options.since
2314
- : (options.since ?? (isInteractive && !options.all && !wantsWholeTeam ? '30d' : undefined));
2530
+ : (options.since ?? (isInteractive && !options.all && !wantsWholeTeam && !wantsWholeRoutine ? '30d' : undefined));
2315
2531
  const toolSpansDevices = toolEvidenceMode
2316
2532
  && (options.fleet || (options.host?.length ?? 0) > 0);
2317
2533
  const toolSortError = toolSearchFleetSortError(options.sort, toolSpansDevices);
@@ -2335,12 +2551,12 @@ limitSource) {
2335
2551
  // --in-team spans directories by construction: a team's teammates run in
2336
2552
  // their own worktrees, so scoping to the current cwd hides most of the
2337
2553
  // lineage the flag exists to show.
2338
- all: pathFilter ? undefined : options.all || wantsWholeTeam || toolSpansDevices,
2554
+ all: pathFilter ? undefined : options.all || wantsWholeTeam || wantsWholeRoutine || toolSpansDevices,
2339
2555
  cwd: process.cwd(),
2340
2556
  // Default overview scopes to the current repo SUBTREE (prefix match), so a
2341
2557
  // monorepo shows its sub-projects grouped instead of collapsing to the one
2342
2558
  // exact-cwd project. `--all` clears the prefix and spans the whole index.
2343
- cwdPrefix: pathFilter ?? (wantsOverview && !options.all && !wantsWholeTeam && !toolSpansDevices ? process.cwd() : undefined),
2559
+ cwdPrefix: pathFilter ?? (wantsOverview && !options.all && !wantsWholeTeam && !wantsWholeRoutine && !toolSpansDevices ? process.cwd() : undefined),
2344
2560
  project: options.project,
2345
2561
  since,
2346
2562
  until: options.until,
@@ -2373,7 +2589,7 @@ limitSource) {
2373
2589
  const readOptions = {
2374
2590
  ...scope,
2375
2591
  limit,
2376
- excludeTeamOrigin: !options.teams,
2592
+ excludeTeamOrigin: !shouldShowTeamSessions(options),
2377
2593
  onProgress: tracker.onProgress,
2378
2594
  includeUnmanaged: options.unmanaged,
2379
2595
  onHiddenUnmanaged: (n) => { hiddenUnmanaged = n; },
@@ -2390,7 +2606,7 @@ limitSource) {
2390
2606
  // meta.json in ~/.agents/teams/agents whose is_team_origin flag was
2391
2607
  // never set (legacy rows). Keep the in-memory pass so those are still
2392
2608
  // enriched/hidden.
2393
- const { visible: visibleSessions } = filterTeamSessions(sessions, !!options.teams);
2609
+ const { visible: visibleSessions } = filterTeamSessions(sessions, shouldShowTeamSessions(options));
2394
2610
  sessions = visibleSessions;
2395
2611
  // --in-team spans both ends of the lineage, so it can't be one SQL predicate:
2396
2612
  // the orchestrator matches on the scan-derived `spawnedTeam` column, while a
@@ -2458,7 +2674,7 @@ limitSource) {
2458
2674
  }
2459
2675
  // Under --in-team the visible list is one team, so the whole-index team-origin
2460
2676
  // count would be a non-sequitur next to it.
2461
- const hiddenCount = options.teams || options.inTeam
2677
+ const hiddenCount = shouldShowTeamSessions(options) || options.inTeam
2462
2678
  ? 0
2463
2679
  : countSessionsInScope({ ...scope, onlyTeamOrigin: true });
2464
2680
  // A typed routine scope must apply before smart ID routing. Otherwise an ID
@@ -2540,15 +2756,36 @@ limitSource) {
2540
2756
  }
2541
2757
  }
2542
2758
  if (options.routine) {
2543
- const filteredByRoutine = await filterSessionsByRoutine(sessions, options.routine, isInteractive);
2544
- if (!filteredByRoutine)
2545
- return;
2546
- sessions = filteredByRoutine;
2759
+ const target = await selectRoutineTarget(sessions, options.routine, isInteractive);
2760
+ if (!target)
2761
+ return; // bad --routine <name> or cancelled pick (message printed)
2762
+ if (target.name) {
2763
+ sessions = sessions.filter((s) => s.routineName === target.name);
2764
+ // A routine browse renders the run-first drilldown — canonical run history
2765
+ // plus each run's linked session — instead of dead-ending a
2766
+ // command/pre-session routine into the generic empty copy. An explicit
2767
+ // --flat/--tree or a search query keeps the scoped session listing/picker,
2768
+ // so `--routine x --flat` prints the plain table and `--routine x <id>`
2769
+ // resolves a specific session within the routine.
2770
+ if (!searchQuery && !options.flat && !options.tree) {
2771
+ const liveIndex = await maybeLiveIndex(options);
2772
+ printRoutineDrilldown(buildRoutineDrilldown(target.name, sessions), liveIndex, { hiddenCount, hiddenUnmanaged });
2773
+ return;
2774
+ }
2775
+ }
2776
+ // target.name === null: bare `--routine` piped to a non-interactive sink —
2777
+ // no routine chosen, fall through to the normal listing unfiltered.
2547
2778
  }
2548
2779
  if (sessions.length === 0) {
2549
2780
  if (pathFilter) {
2550
2781
  console.log(chalk.gray(`No sessions found for ${pathFilter}.`));
2551
2782
  }
2783
+ else if (options.routine) {
2784
+ // A routine scoped to a flat/tree/query listing with no matching session
2785
+ // (e.g. a command routine has none) — say so in routine terms, not the
2786
+ // generic cwd/--all copy, and point at the run-history drilldown.
2787
+ console.log(chalk.gray('No indexed agent sessions for this routine. Drop --flat/--tree to see its run history.'));
2788
+ }
2552
2789
  else {
2553
2790
  console.log(chalk.gray(formatNoSessionsMessage(options.all, options.project)));
2554
2791
  }
@@ -2574,10 +2811,6 @@ limitSource) {
2574
2811
  // Interact/resume via `agents sessions <project>` or `agents sessions resume`.
2575
2812
  if (wantsOverview) {
2576
2813
  const liveIndex = await maybeLiveIndex(options);
2577
- if (options.routine) {
2578
- printRoutineRunOverview(sessions, liveIndex, { hiddenCount, hiddenUnmanaged });
2579
- return;
2580
- }
2581
2814
  // Per-project row cap is fixed (--limit carries a default of 50 and drives
2582
2815
  // the fetch pool, not the display); `--all` expands every group instead.
2583
2816
  printSessionOverview(sessions, hiddenCount, liveIndex, { perProjectCap: OVERVIEW_ROWS_PER_PROJECT, expand: !!options.all, hiddenUnmanaged });
@@ -2901,25 +3134,6 @@ function printSessionOverview(pool, hiddenCount, liveIndex, opts) {
2901
3134
  if (opts.hiddenUnmanaged)
2902
3135
  console.log(chalk.gray(formatUnmanagedHiddenFooter(opts.hiddenUnmanaged)));
2903
3136
  }
2904
- export function printRoutineRunOverview(sessions, liveIndex, opts = {}) {
2905
- const groups = buildRoutineRunGroups(sessions);
2906
- console.log(chalk.gray(`${sessions.length} session${sessions.length === 1 ? '' : 's'} · ${groups.length} routine run${groups.length === 1 ? '' : 's'} · newest run first\n`));
2907
- let first = true;
2908
- for (const group of groups) {
2909
- if (!first)
2910
- console.log();
2911
- first = false;
2912
- console.log(`${chalk.cyan('▸')} ${chalk.cyan.bold(group.runId)} ` +
2913
- `${chalk.gray(`${group.sessions.length} session${group.sessions.length === 1 ? '' : 's'} · ${formatRelativeTime(group.timestamp)}`)}`);
2914
- for (const session of group.sessions)
2915
- console.log(treeSessionRow(session, liveIndex?.get(session.id)));
2916
- }
2917
- console.log(chalk.gray('\nGrouped by routine run id and timestamp.'));
2918
- if (opts.hiddenCount)
2919
- console.log(chalk.gray(formatTeamHiddenFooter(opts.hiddenCount)));
2920
- if (opts.hiddenUnmanaged)
2921
- console.log(chalk.gray(formatUnmanagedHiddenFooter(opts.hiddenUnmanaged)));
2922
- }
2923
3137
  function printSessionTable(sessions, hiddenCount = 0, tree = false, liveIndex) {
2924
3138
  if (tree) {
2925
3139
  // Group by directory; drop the id/version columns from view. The short id
@@ -4607,9 +4821,9 @@ export function registerSessionsCommands(program) {
4607
4821
  # teammate row [<team>/<handle>]. --in-team narrows to one team's lineage.
4608
4822
  agents sessions --in-team redesign --teams
4609
4823
 
4610
- # Show routine-run sessions and open one by routine run id
4611
- agents sessions --routine --all
4612
- agents sessions --routine nightly-review --all
4824
+ # Pick a routine across every directory, then open one of its run sessions
4825
+ agents sessions --routine
4826
+ agents sessions --routine nightly-review
4613
4827
  agents sessions 2026-07-21T10-30-00-000Z
4614
4828
 
4615
4829
  # Export for analysis
@@ -4657,14 +4871,16 @@ export function registerSessionsCommands(program) {
4657
4871
  - --first and --last are mutually exclusive.
4658
4872
  - A filter flag (--include/--exclude/--first/--last) without --markdown/--json defaults to --markdown output.
4659
4873
  - --cloud sources from Rush Cloud captured runs instead of local disk.
4660
- - --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.
4874
+ - --routine [name] spans every directory and 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.
4661
4875
  - Without --teams, team-spawned sessions are hidden by default.
4662
4876
  `,
4663
4877
  });
4664
4878
  sessionsCmd.action(async (query, options, command) => {
4665
4879
  if (options.browser) {
4666
- // Alias for `agents browser sessions`: a profile positional narrows to one profile.
4667
- runBrowserSessions({ profile: query, json: options.json });
4880
+ // Alias for `agents browser sessions`: a profile positional narrows to one
4881
+ // profile. `--no-interactive` is already a top-level sessionsCmd option
4882
+ // (options.interactive), so it carries through for free.
4883
+ await runBrowserSessionsCommand({ profile: query, json: options.json, interactive: options.interactive });
4668
4884
  return;
4669
4885
  }
4670
4886
  await sessionsAction(query, options, command.getOptionValueSource('limit'));
@@ -28,6 +28,7 @@ import { DEFAULT_SERVE_PORT } from '../lib/serve/server.js';
28
28
  import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from '../lib/devices/tailscale.js';
29
29
  import { defaultPickerChecked, localLoginUser, planDeviceReconciliation, runDeviceSync, withDefaultUser } from '../lib/devices/sync.js';
30
30
  import { resolveDeviceTarget, splitUserHost } from '../lib/devices/resolve-target.js';
31
+ import { deriveMirroredCwd } from '../lib/project-root.js';
31
32
  import { clearPendingSentinel } from '../lib/devices/pending.js';
32
33
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
33
34
  import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
@@ -1598,15 +1599,21 @@ function registerSshWrapper(program) {
1598
1599
  .allowUnknownOption()
1599
1600
  .addHelpText('after', `
1600
1601
  Examples:
1601
- agents ssh win-mini # interactive login
1602
- agents ssh win-mini hostname # run a command (PowerShell on Windows)
1603
- agents ssh yosemite-s0 uptime # run a command (POSIX)
1604
- agents ssh auto # affinity-pick a device (same engine as 'agents run --device auto')
1602
+ agents ssh yosemite-s0 # interactive login (mirrors your project dir)
1603
+ agents ssh win-mini # interactive login
1604
+ agents ssh win-mini hostname # run a command (PowerShell on Windows)
1605
+ agents ssh yosemite-s0 uptime # run a command (POSIX)
1606
+ agents ssh auto # affinity-pick a device (same engine as 'agents run --device auto')
1605
1607
 
1606
1608
  Devices come from 'agents devices'. Password auth pulls the secret from a
1607
1609
  secrets bundle via an askpass shim — the password never touches argv.
1608
1610
  'auto' picks a remote device by 14-day usage; a pick landing on this machine
1609
1611
  is refused with a clear message instead of self-dialing.
1612
+
1613
+ An interactive login with no command mirrors the home-relative directory you
1614
+ launched from — 'agents ssh yosemite-s0' from ~/src/app lands in ~/src/app on
1615
+ the target when it exists, else the remote home. Same portable-cwd rule as
1616
+ 'agents run --host'. Passing a command keeps the remote home.
1610
1617
  `)
1611
1618
  .action(async (name, cmd) => {
1612
1619
  // Hidden askpass bridge: ssh execs the shim, which re-invokes us here.
@@ -1664,7 +1671,12 @@ is refused with a clear message instead of self-dialing.
1664
1671
  ensureManagedKnownHostsDir();
1665
1672
  const addr = hostNameFor(device);
1666
1673
  const pinned = addr ? isHostPinned(addr) : false;
1667
- const { args, env } = buildSshInvocation(device, cmd, shim, { pinned });
1674
+ // Interactive login (no cmd): mirror the caller's project directory on
1675
+ // the target when the same home-relative checkout exists there, matching
1676
+ // `agents run --host` (deriveMirroredCwd). Best-effort — a missing dir
1677
+ // falls back to the remote home. An explicit `cmd` keeps its cwd (RUSH-2412).
1678
+ const mirrorCwd = cmd.length === 0 ? deriveMirroredCwd(process.cwd()) : undefined;
1679
+ const { args, env } = buildSshInvocation(device, cmd, shim, { pinned }, { interactiveCwd: mirrorCwd });
1668
1680
  // Interactive login: make the local terminal's terminfo (e.g.
1669
1681
  // xterm-ghostty) available on the remote so backspace/colors/clear work.
1670
1682
  // Best-effort + cached per host — never blocks the login (see terminfo.ts).
@@ -59,6 +59,34 @@ export type TeamMessageRoute = {
59
59
  * - any actionable + no message -> need-message
60
60
  */
61
61
  export declare function decideTeamMessageRoute(status: AgentStatus, hasMessage: boolean): TeamMessageRoute;
62
+ /**
63
+ * Tell the user a worktree from a failed `teams add` is still on disk, and give
64
+ * them the exact commands to remove it. Reached only when we could not remove it
65
+ * ourselves — a leftover `agents/<name>` branch is what makes the retry fail with
66
+ * `fatal: a branch ... already exists`, so it must never be silent (RUSH-2356).
67
+ */
68
+ /**
69
+ * Tear down a worktree a failed operation left behind — but ONLY if it is a
70
+ * genuine orphan.
71
+ *
72
+ * Extracted because this guard was hand-duplicated at two call sites (`teams
73
+ * add` and the pr-watch fixer) and the copy at the fixer path was written
74
+ * WITHOUT it, which is RUSH-2356 reoccurring at the one site that skipped the
75
+ * check. One implementation, one place to get it right.
76
+ *
77
+ * The two errors here are not symmetric, so the guard deliberately fails
78
+ * CLOSED: a spurious "claimed" strands a branch a human can delete, while a
79
+ * spurious "unclaimed" runs `git worktree remove --force` over a live
80
+ * teammate's checkout and destroys uncommitted work. So an unreadable record
81
+ * means "leave it alone and tell the user", never "assume it is free".
82
+ *
83
+ * A staged teammate is exactly the case that makes this necessary: `spawn()`
84
+ * persists its meta.json and only THEN runs the retention pass, which refreshes
85
+ * every sibling and can throw on a distributed one — so a failure can land here
86
+ * with a live, durably-recorded, merely-pending `--after` teammate already
87
+ * owning this worktree.
88
+ */
89
+ export declare function tearDownOrphanWorktree(mgr: AgentManager, baseCwd: string, name: string): Promise<void>;
62
90
  /**
63
91
  * Register the generic cloud dispatcher — staged cloud teammates get
64
92
  * dispatched when their --after deps resolve, using repo/branch stored on