@phnx-labs/agents-cli 1.22.33 → 1.22.35

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 (81) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +9 -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.d.ts +54 -4
  13. package/dist/commands/sessions.js +252 -46
  14. package/dist/commands/share.d.ts +18 -0
  15. package/dist/commands/share.js +108 -0
  16. package/dist/commands/ssh.js +17 -5
  17. package/dist/commands/teams.d.ts +28 -0
  18. package/dist/commands/teams.js +148 -13
  19. package/dist/commands/upgrade.d.ts +7 -0
  20. package/dist/commands/upgrade.js +10 -0
  21. package/dist/commands/watchdog.d.ts +2 -0
  22. package/dist/commands/watchdog.js +112 -27
  23. package/dist/index.js +51 -59
  24. package/dist/lib/agents.js +6 -0
  25. package/dist/lib/browser/sessions-list.d.ts +81 -0
  26. package/dist/lib/browser/sessions-list.js +179 -4
  27. package/dist/lib/codex-policy.d.ts +9 -1
  28. package/dist/lib/codex-policy.js +17 -2
  29. package/dist/lib/daemon.js +45 -5
  30. package/dist/lib/devices/connect.d.ts +33 -0
  31. package/dist/lib/devices/connect.js +61 -3
  32. package/dist/lib/devices/doctor-findings.d.ts +4 -2
  33. package/dist/lib/devices/doctor-findings.js +4 -2
  34. package/dist/lib/exec.js +65 -8
  35. package/dist/lib/help.d.ts +3 -2
  36. package/dist/lib/help.js +4 -0
  37. package/dist/lib/hosts/dispatch.d.ts +2 -32
  38. package/dist/lib/hosts/dispatch.js +6 -61
  39. package/dist/lib/hosts/tasks.d.ts +7 -0
  40. package/dist/lib/hosts/tasks.js +9 -0
  41. package/dist/lib/mailbox-target.d.ts +27 -0
  42. package/dist/lib/mailbox-target.js +21 -0
  43. package/dist/lib/mcp.d.ts +10 -0
  44. package/dist/lib/mcp.js +21 -2
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  47. package/dist/lib/migrate.d.ts +11 -0
  48. package/dist/lib/migrate.js +40 -0
  49. package/dist/lib/project-key.d.ts +17 -0
  50. package/dist/lib/project-key.js +26 -0
  51. package/dist/lib/project-root.d.ts +47 -0
  52. package/dist/lib/project-root.js +68 -0
  53. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  54. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  55. package/dist/lib/secrets/agent.d.ts +9 -2
  56. package/dist/lib/secrets/agent.js +52 -7
  57. package/dist/lib/secrets/reaper.d.ts +24 -3
  58. package/dist/lib/secrets/reaper.js +55 -6
  59. package/dist/lib/session/discover.js +12 -0
  60. package/dist/lib/session/render.d.ts +2 -0
  61. package/dist/lib/session/render.js +1 -1
  62. package/dist/lib/share/delete.d.ts +93 -0
  63. package/dist/lib/share/delete.js +127 -0
  64. package/dist/lib/shims.js +48 -4
  65. package/dist/lib/startup/command-registry.d.ts +1 -0
  66. package/dist/lib/startup/command-registry.js +11 -3
  67. package/dist/lib/startup/root-command.d.ts +3 -0
  68. package/dist/lib/startup/root-command.js +10 -0
  69. package/dist/lib/teams/agents.d.ts +136 -6
  70. package/dist/lib/teams/agents.js +324 -58
  71. package/dist/lib/teams/worktree.d.ts +39 -2
  72. package/dist/lib/teams/worktree.js +60 -4
  73. package/dist/lib/types.d.ts +11 -0
  74. package/dist/lib/versions.js +2 -2
  75. package/dist/lib/watchdog/history.d.ts +20 -0
  76. package/dist/lib/watchdog/history.js +46 -0
  77. package/dist/lib/watchdog/log.d.ts +16 -1
  78. package/dist/lib/watchdog/log.js +82 -2
  79. package/dist/lib/watchdog/runner.d.ts +12 -0
  80. package/dist/lib/watchdog/runner.js +20 -0
  81. 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';
@@ -37,13 +37,14 @@ import { filterTeamSessions, shouldShowTeamSessions, safeTeamText, groupSessions
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) {
@@ -1631,33 +1632,238 @@ function safeListJobsWithRuns() {
1631
1632
  return [];
1632
1633
  }
1633
1634
  }
1634
- export async function filterSessionsByRoutine(sessions, routine, interactive) {
1635
- // The name universe is transcripts UNION routines that have any run record
1636
- // 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) {
1637
1647
  const routineNames = [...new Set([
1638
1648
  ...sessions.map((session) => session.routineName).filter((name) => !!name),
1639
1649
  ...safeListJobsWithRuns(),
1640
1650
  ])].sort((a, b) => a.localeCompare(b));
1641
- let selectedRoutine = null;
1642
1651
  if (typeof routine === 'string') {
1643
- selectedRoutine = resolveRoutineName(routine, routineNames);
1644
- if (!selectedRoutine) {
1652
+ const resolved = resolveRoutineName(routine, routineNames);
1653
+ if (!resolved) {
1645
1654
  console.error(chalk.red(`No routine matches "${routine}".`));
1646
1655
  if (routineNames.length > 0)
1647
1656
  console.error(chalk.gray(`Available routines: ${routineNames.join(', ')}`));
1648
1657
  process.exitCode = 1;
1649
1658
  return null;
1650
1659
  }
1660
+ return { name: resolved };
1651
1661
  }
1652
- else if (interactive) {
1653
- selectedRoutine = await selectRoutineName(sessions);
1654
- if (!selectedRoutine)
1662
+ if (interactive) {
1663
+ const picked = await selectRoutineName(sessions);
1664
+ if (!picked)
1655
1665
  return null;
1666
+ return { name: picked };
1656
1667
  }
1657
- if (!selectedRoutine)
1658
- return sessions;
1659
- const matched = sessions.filter((session) => session.routineName === selectedRoutine);
1660
- 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)));
1661
1867
  }
1662
1868
  /** The canonical `ag sessions …` command for a set of flags — the twin of the
1663
1869
  * browser's `y` hotkey (see --print-cmd). Normalizes to the stable flag form. */
@@ -2550,15 +2756,36 @@ limitSource) {
2550
2756
  }
2551
2757
  }
2552
2758
  if (options.routine) {
2553
- const filteredByRoutine = await filterSessionsByRoutine(sessions, options.routine, isInteractive);
2554
- if (!filteredByRoutine)
2555
- return;
2556
- 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.
2557
2778
  }
2558
2779
  if (sessions.length === 0) {
2559
2780
  if (pathFilter) {
2560
2781
  console.log(chalk.gray(`No sessions found for ${pathFilter}.`));
2561
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
+ }
2562
2789
  else {
2563
2790
  console.log(chalk.gray(formatNoSessionsMessage(options.all, options.project)));
2564
2791
  }
@@ -2584,10 +2811,6 @@ limitSource) {
2584
2811
  // Interact/resume via `agents sessions <project>` or `agents sessions resume`.
2585
2812
  if (wantsOverview) {
2586
2813
  const liveIndex = await maybeLiveIndex(options);
2587
- if (options.routine) {
2588
- printRoutineRunOverview(sessions, liveIndex, { hiddenCount, hiddenUnmanaged });
2589
- return;
2590
- }
2591
2814
  // Per-project row cap is fixed (--limit carries a default of 50 and drives
2592
2815
  // the fetch pool, not the display); `--all` expands every group instead.
2593
2816
  printSessionOverview(sessions, hiddenCount, liveIndex, { perProjectCap: OVERVIEW_ROWS_PER_PROJECT, expand: !!options.all, hiddenUnmanaged });
@@ -2911,25 +3134,6 @@ function printSessionOverview(pool, hiddenCount, liveIndex, opts) {
2911
3134
  if (opts.hiddenUnmanaged)
2912
3135
  console.log(chalk.gray(formatUnmanagedHiddenFooter(opts.hiddenUnmanaged)));
2913
3136
  }
2914
- export function printRoutineRunOverview(sessions, liveIndex, opts = {}) {
2915
- const groups = buildRoutineRunGroups(sessions);
2916
- console.log(chalk.gray(`${sessions.length} session${sessions.length === 1 ? '' : 's'} · ${groups.length} routine run${groups.length === 1 ? '' : 's'} · newest run first\n`));
2917
- let first = true;
2918
- for (const group of groups) {
2919
- if (!first)
2920
- console.log();
2921
- first = false;
2922
- console.log(`${chalk.cyan('▸')} ${chalk.cyan.bold(group.runId)} ` +
2923
- `${chalk.gray(`${group.sessions.length} session${group.sessions.length === 1 ? '' : 's'} · ${formatRelativeTime(group.timestamp)}`)}`);
2924
- for (const session of group.sessions)
2925
- console.log(treeSessionRow(session, liveIndex?.get(session.id)));
2926
- }
2927
- console.log(chalk.gray('\nGrouped by routine run id and timestamp.'));
2928
- if (opts.hiddenCount)
2929
- console.log(chalk.gray(formatTeamHiddenFooter(opts.hiddenCount)));
2930
- if (opts.hiddenUnmanaged)
2931
- console.log(chalk.gray(formatUnmanagedHiddenFooter(opts.hiddenUnmanaged)));
2932
- }
2933
3137
  function printSessionTable(sessions, hiddenCount = 0, tree = false, liveIndex) {
2934
3138
  if (tree) {
2935
3139
  // Group by directory; drop the id/version columns from view. The short id
@@ -4673,8 +4877,10 @@ export function registerSessionsCommands(program) {
4673
4877
  });
4674
4878
  sessionsCmd.action(async (query, options, command) => {
4675
4879
  if (options.browser) {
4676
- // Alias for `agents browser sessions`: a profile positional narrows to one profile.
4677
- 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 });
4678
4884
  return;
4679
4885
  }
4680
4886
  await sessionsAction(query, options, command.getOptionValueSource('limit'));
@@ -1,7 +1,24 @@
1
1
  import type { Command } from 'commander';
2
2
  import { type CloudflareRequester } from '../lib/share/provision.js';
3
3
  import { type PublishResult } from '../lib/share/publish.js';
4
+ import { deleteShare, type DeleteShareResult } from '../lib/share/delete.js';
4
5
  export declare function formatSharePublishResult(result: PublishResult, json?: boolean): string;
6
+ export declare function formatShareDeleteResult(result: DeleteShareResult, json?: boolean): string;
7
+ interface ShareDeleteCliOpts {
8
+ keepCover?: boolean;
9
+ ifExists?: boolean;
10
+ githubUser?: string;
11
+ json?: boolean;
12
+ }
13
+ /** Shared handler for `agents share delete <targets...>` and the top-level
14
+ * `agents unshare <targets...>` alias. Deletes each target independently and
15
+ * continues past a failed one (rm-style), reporting all results and exiting
16
+ * non-zero if any target failed to verify as gone.
17
+ *
18
+ * `deleteFn` is a DI seam for tests (defaults to the real `deleteShare`) — it is
19
+ * never exposed as a CLI flag, only used to inject a fake config/checker/deleter
20
+ * without touching the keychain or a live endpoint. */
21
+ export declare function runShareDelete(targets: string[], opts: ShareDeleteCliOpts, deleteFn?: typeof deleteShare): Promise<void>;
5
22
  export declare function registerShareCommands(program: Command): void;
6
23
  /** Provision a fresh R2 bucket + Worker on the user's Cloudflare and persist the
7
24
  * endpoint config + write token. Shared by `agents share setup` and the unified
@@ -22,3 +39,4 @@ export declare function runShareProvision(opts: {
22
39
  export declare function runShareJoin(baseUrl?: string, opts?: {
23
40
  token?: string;
24
41
  }): Promise<void>;
42
+ export {};
@@ -5,9 +5,11 @@ import chalk from 'chalk';
5
5
  import { DEFAULT_BUCKET_NAME, DEFAULT_CF_BUNDLE, DEFAULT_SHARE_DOMAIN, DEFAULT_WORKER_NAME, generateWriteToken, readCloudflareCreds, readShareConfig, readWriteTokenEnv, readWriteTokenFromBundle, storeWriteToken, writeShareConfig, } from '../lib/share/config.js';
6
6
  import { addCustomDomain, configureBucketLifecycle, createBucket, deployWorker, enableWorkersDev, findZoneId, setWorkerSecret, } from '../lib/share/provision.js';
7
7
  import { publishFile } from '../lib/share/publish.js';
8
+ import { deleteShare } from '../lib/share/delete.js';
8
9
  import { renderWorkerScript } from '../lib/share/worker-template.js';
9
10
  import { analyticsEnabled } from '../lib/share/analytics.js';
10
11
  import { resolveGitHubUsername } from '../lib/git.js';
12
+ import { setHelpSections } from '../lib/help.js';
11
13
  export function formatSharePublishResult(result, json = false) {
12
14
  if (json)
13
15
  return JSON.stringify(result, null, 2);
@@ -18,6 +20,84 @@ export function formatSharePublishResult(result, json = false) {
18
20
  lines.push(chalk.dim(` expires ${new Date(result.expiresAt).toLocaleString()}`));
19
21
  return lines.join('\n');
20
22
  }
23
+ export function formatShareDeleteResult(result, json = false) {
24
+ if (json)
25
+ return JSON.stringify(result, null, 2);
26
+ if (result.skipped)
27
+ return chalk.dim(`skipped — ${result.url} was already gone`);
28
+ const lines = [chalk.green(`deleted ${result.url}`)];
29
+ if (result.cover) {
30
+ lines.push(result.cover.existedBefore
31
+ ? chalk.dim(` cover deleted ${result.cover.url}`)
32
+ : chalk.dim(` cover (none) ${result.cover.url}`));
33
+ }
34
+ return lines.join('\n');
35
+ }
36
+ /** Shared handler for `agents share delete <targets...>` and the top-level
37
+ * `agents unshare <targets...>` alias. Deletes each target independently and
38
+ * continues past a failed one (rm-style), reporting all results and exiting
39
+ * non-zero if any target failed to verify as gone.
40
+ *
41
+ * `deleteFn` is a DI seam for tests (defaults to the real `deleteShare`) — it is
42
+ * never exposed as a CLI flag, only used to inject a fake config/checker/deleter
43
+ * without touching the keychain or a live endpoint. */
44
+ export async function runShareDelete(targets, opts, deleteFn = deleteShare) {
45
+ const results = [];
46
+ for (const target of targets) {
47
+ try {
48
+ const result = await deleteFn(target, {
49
+ keepCover: opts.keepCover === true,
50
+ ifExists: opts.ifExists === true,
51
+ githubUser: opts.githubUser,
52
+ });
53
+ results.push({ target, result });
54
+ if (!opts.json)
55
+ console.log(formatShareDeleteResult(result));
56
+ }
57
+ catch (e) {
58
+ results.push({ target, error: e.message });
59
+ if (!opts.json)
60
+ console.error(chalk.red(`${target}: ${e.message}`));
61
+ }
62
+ }
63
+ if (opts.json) {
64
+ console.log(JSON.stringify(results, null, 2));
65
+ }
66
+ if (results.some((r) => r.error)) {
67
+ process.exitCode = 1;
68
+ }
69
+ }
70
+ function registerShareDeleteOptions(cmd) {
71
+ return cmd
72
+ .option('--keep-cover', 'leave the sibling <slug>.png OG cover in place (default: delete it too)')
73
+ .option('--if-exists', 'treat an already-missing target as a no-op success instead of an error')
74
+ .option('--github-user <user>', 'GitHub username for resolving a bare-slug target (default: resolved from gh/git config)')
75
+ .option('--json', 'emit machine-readable results');
76
+ }
77
+ const SHARE_DELETE_EXAMPLES = `
78
+ # Delete by full URL — also takes down the sibling OG cover
79
+ agents share delete https://share.agents-cli.sh/octocat/my-plan-a1b2
80
+
81
+ # Delete by <user>/<slug>, or a bare slug in your own namespace
82
+ agents share delete octocat/my-plan-a1b2
83
+ agents unshare my-plan-a1b2
84
+
85
+ # Several at once
86
+ agents unshare my-plan-a1b2 old-report-9f3c
87
+
88
+ # Keep the cover image up (rare — you usually want both gone)
89
+ agents unshare my-plan-a1b2 --keep-cover
90
+
91
+ # Don't error if it's already gone
92
+ agents unshare my-plan-a1b2 --if-exists
93
+ `;
94
+ const SHARE_DELETE_NOTES = `
95
+ A follow-up GET is required to resolve 404 before this reports success — the
96
+ Worker's DELETE is idempotent and returns {"ok":true} even for a key that was
97
+ never there, so that response alone is never proof of a takedown.
98
+
99
+ agents share delete === agents unshare (same command, different name).
100
+ `;
21
101
  export function registerShareCommands(program) {
22
102
  const shareCmd = program
23
103
  .command('share')
@@ -54,6 +134,34 @@ export function registerShareCommands(program) {
54
134
  process.exitCode = 1;
55
135
  }
56
136
  });
137
+ setHelpSections(shareCmd, {
138
+ examples: `
139
+ # Publish an HTML file — gets an auto OG cover + a shareable link
140
+ agents share ./out/plan.html
141
+
142
+ # Custom slug, expiring in 30 days
143
+ agents share ./out/report.html --slug q3-report --expire 30d
144
+ ${SHARE_DELETE_EXAMPLES}
145
+ # One-time setup (or join an existing endpoint)
146
+ agents share setup
147
+ agents share join https://share.agents-cli.sh
148
+ `,
149
+ notes: SHARE_DELETE_NOTES,
150
+ });
151
+ const shareDeleteCmd = registerShareDeleteOptions(shareCmd
152
+ .command('delete <targets...>')
153
+ .description('Take down a published page (and by default its OG cover). Verifies the page 404s before reporting success. Top-level alias: agents unshare.'));
154
+ setHelpSections(shareDeleteCmd, { examples: SHARE_DELETE_EXAMPLES, notes: SHARE_DELETE_NOTES });
155
+ shareDeleteCmd.action(async (targets, opts) => {
156
+ await runShareDelete(targets, opts);
157
+ });
158
+ const unshareCmd = registerShareDeleteOptions(program
159
+ .command('unshare <targets...>')
160
+ .description('Alias of `agents share delete` — take down a published page (and by default its OG cover).'));
161
+ setHelpSections(unshareCmd, { examples: SHARE_DELETE_EXAMPLES, notes: SHARE_DELETE_NOTES });
162
+ unshareCmd.action(async (targets, opts) => {
163
+ await runShareDelete(targets, opts);
164
+ });
57
165
  shareCmd
58
166
  .command('setup')
59
167
  .description('One-time: provision an R2 bucket + Worker on your Cloudflare and save the config.')
@@ -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