@phnx-labs/agents-cli 1.14.6 → 1.15.0

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 (39) hide show
  1. package/README.md +148 -1
  2. package/dist/commands/beta.js +6 -1
  3. package/dist/commands/exec.js +9 -2
  4. package/dist/commands/init.js +10 -0
  5. package/dist/commands/mcp.js +4 -4
  6. package/dist/commands/prune.d.ts +0 -20
  7. package/dist/commands/prune.js +268 -15
  8. package/dist/commands/secrets.js +83 -0
  9. package/dist/commands/teams.js +2 -3
  10. package/dist/commands/usage.js +6 -0
  11. package/dist/commands/versions.js +8 -6
  12. package/dist/lib/browser/chrome.js +1 -1
  13. package/dist/lib/browser/drivers/ssh.d.ts +1 -0
  14. package/dist/lib/browser/drivers/ssh.js +23 -2
  15. package/dist/lib/browser/ipc.js +1 -0
  16. package/dist/lib/browser/service.d.ts +3 -0
  17. package/dist/lib/browser/service.js +114 -6
  18. package/dist/lib/daemon.js +4 -4
  19. package/dist/lib/events.d.ts +159 -0
  20. package/dist/lib/events.js +441 -0
  21. package/dist/lib/exec.js +29 -6
  22. package/dist/lib/permissions.d.ts +6 -3
  23. package/dist/lib/permissions.js +38 -34
  24. package/dist/lib/routines.d.ts +15 -0
  25. package/dist/lib/routines.js +68 -0
  26. package/dist/lib/runner.js +15 -0
  27. package/dist/lib/secrets/bundles.js +7 -1
  28. package/dist/lib/secrets/index.d.ts +14 -11
  29. package/dist/lib/secrets/index.js +49 -21
  30. package/dist/lib/secrets/linux.d.ts +27 -0
  31. package/dist/lib/secrets/linux.js +161 -0
  32. package/dist/lib/session/db.d.ts +4 -0
  33. package/dist/lib/session/db.js +26 -0
  34. package/dist/lib/skills.js +4 -0
  35. package/dist/lib/usage.d.ts +1 -1
  36. package/dist/lib/usage.js +13 -46
  37. package/dist/lib/versions.js +16 -0
  38. package/package.json +1 -1
  39. package/scripts/postinstall.js +37 -9
package/README.md CHANGED
@@ -47,11 +47,13 @@ Also available as `ag` -- all commands work with both `agents` and `ag`.
47
47
  - [Sessions across agents](#sessions-across-agents)
48
48
  - [Run open models through Claude Code](#run-open-models-through-claude-code)
49
49
  - [Teams](#teams)
50
+ - [Browser](#browser)
50
51
  - [Secrets](#secrets)
51
52
  - [Routines](#routines)
52
53
  - [PTY](#pty)
53
54
  - [Portable setup](#portable-setup)
54
55
  - [Private skills](#private-skills)
56
+ - [Security & Privacy](#security--privacy)
55
57
  - [Compatibility](#compatibility)
56
58
  - [FAQ](#faq)
57
59
 
@@ -242,6 +244,85 @@ Team state is observable via `agents teams list --json` / `agents teams status -
242
244
 
243
245
  ---
244
246
 
247
+ ## Browser
248
+
249
+ Give agents access to a real browser — no relay extension, no cloud service, no Playwright getting blocked.
250
+
251
+ ```bash
252
+ # Create an isolated profile for automation
253
+ agents browser profiles create work --browser chrome
254
+
255
+ # Start a task, navigate, interact
256
+ agents browser start login-flow --profile work
257
+ agents browser navigate login-flow https://app.example.com
258
+ agents browser refs login-flow # Get interactive element refs
259
+ agents browser click login-flow <tab> 42 # Click element ref 42
260
+ agents browser type login-flow <tab> 15 "hello"
261
+ agents browser screenshot login-flow # Smart resizing, token-efficient
262
+ ```
263
+
264
+ ### Why this works where Playwright fails
265
+
266
+ Playwright and Puppeteer spin up fresh browser instances with automation flags. Sites like LinkedIn, Google, and most finance apps detect and block them immediately.
267
+
268
+ `agents browser` launches your existing residential Chrome (or Brave, Edge, Chromium) on your machine via CDP. Same browser fingerprint, same IP, same everything. Sites can't detect automation because you're using the same browser you'd use manually.
269
+
270
+ ### Token-efficient automation
271
+
272
+ The CLI handles the mechanical work so agents don't burn tokens on low-level browser commands. Screenshots are automatically resized without excessive compression — agents process smaller images while keeping the detail they need to make decisions.
273
+
274
+ ### Profile isolation
275
+
276
+ Multiple agents can run browser tasks simultaneously without stepping on each other. Each profile gets its own user data directory, cookies, and state. One agent logs into your work Slack, another into your personal email — no conflicts, no shared state.
277
+
278
+ ```bash
279
+ agents browser profiles create work-slack --browser chrome
280
+ agents browser profiles create personal-gmail --browser chrome
281
+ # Two agents, two profiles, no interference
282
+ ```
283
+
284
+ ### Safe credential access
285
+
286
+ Attach a [secrets bundle](#secrets) to a profile. The agent can log in without credentials in plaintext, and every secret access is recorded in the session log.
287
+
288
+ ```bash
289
+ agents browser profiles create bank --browser chrome --secrets bank-creds
290
+ ```
291
+
292
+ ### Electron apps
293
+
294
+ Control Electron apps (Slack, Discord, VS Code, your own app) with custom binaries:
295
+
296
+ ```bash
297
+ agents browser profiles create rush \
298
+ --browser custom \
299
+ --binary "/Applications/Rush.app/Contents/MacOS/Rush" \
300
+ --electron
301
+ ```
302
+
303
+ ### Remote browsers
304
+
305
+ Connect to browsers running anywhere — local, SSH tunnels, or cloud services:
306
+
307
+ ```bash
308
+ # Local CDP (discovers WebSocket URL automatically)
309
+ agents browser profiles create local-debug \
310
+ --browser chrome \
311
+ --endpoint "http://localhost:9222"
312
+
313
+ # SSH tunnel to a remote machine
314
+ agents browser profiles create staging \
315
+ --browser chrome \
316
+ --endpoint "ssh://deploy@staging.example.com?port=9222"
317
+
318
+ # Cloud browser services (BrowserBase, Steel, etc.)
319
+ agents browser profiles create cloud \
320
+ --browser chrome \
321
+ --endpoint "wss://connect.browserbase.com?apiKey=..."
322
+ ```
323
+
324
+ ---
325
+
245
326
  ## Secrets
246
327
 
247
328
  ```bash
@@ -373,6 +454,70 @@ Extras clone into `~/.agents-system/.repos/<alias>/` and ship the same layout as
373
454
 
374
455
  ---
375
456
 
457
+ ## Security & Privacy
458
+
459
+ **Everything stays on your machine.** No telemetry, no cloud sync (unless you opt into iCloud Keychain for secrets), no phone-home. Here's exactly what `agents-cli` stores locally and why.
460
+
461
+ ### Event log
462
+
463
+ Every agent run, version install, browser launch, and secrets access is logged to `~/.agents/logs/events-YYYY-MM-DD.jsonl`. This gives you a complete record of what agents did on your machine.
464
+
465
+ ```bash
466
+ # What gets logged (example event):
467
+ {
468
+ "ts": "2026-05-09T10:23:45Z",
469
+ "event": "agent.run.end",
470
+ "agent": "claude",
471
+ "version": "2.1.121",
472
+ "prompt": "Fix the auth bug in...", # truncated to 200 chars
473
+ "durationMs": 45230,
474
+ "exitCode": 0,
475
+ "hostname": "your-mac",
476
+ "platform": "darwin"
477
+ }
478
+ ```
479
+
480
+ **What's logged:** Operation type, agent, version, timing, truncated prompts (first 200 chars), exit codes, errors. **What's NOT logged:** Full prompts, outputs, file contents, secret values (only bundle names).
481
+
482
+ **Permissions:** Logs directory is `0700` (owner-only), files are `0600`. Only you can read them.
483
+
484
+ **Retention:** 30 days by default, then auto-pruned.
485
+
486
+ **Opt out:** Set `AGENTS_DISABLE_EVENT_LOG=1` in your shell to disable completely.
487
+
488
+ ### Session search
489
+
490
+ Conversations with Claude, Codex, Gemini, and other agents scatter across their native storage. Session search indexes them locally so you can find any conversation:
491
+
492
+ ```bash
493
+ agents sessions "auth middleware" # Full-text search across all agents
494
+ agents sessions --agent claude --since 7d
495
+ ```
496
+
497
+ The index lives at `~/.agents/sessions/sessions.db` (SQLite + FTS5). Nothing leaves your machine. See [Sessions](#sessions-across-agents) for full usage.
498
+
499
+ ### Secrets
500
+
501
+ API keys and credentials are stored in macOS Keychain, never in plaintext files. Bundle definitions also live in Keychain.
502
+
503
+ ```bash
504
+ agents secrets create my-keys
505
+ agents secrets add my-keys API_KEY # Prompts for value, stores in Keychain
506
+ ```
507
+
508
+ With `--icloud-sync`, secrets sync via iCloud Keychain to your other Macs. Without it, they stay device-local. See [Secrets](#secrets) for full usage.
509
+
510
+ ### Summary
511
+
512
+ | Data | Location | Who can read | Opt out |
513
+ |------|----------|--------------|---------|
514
+ | Event log | `~/.agents/logs/` | You only (0600) | `AGENTS_DISABLE_EVENT_LOG=1` |
515
+ | Session index | `~/.agents/sessions/` | You only | Delete the directory |
516
+ | Secrets | macOS Keychain | You + apps you authorize | Don't use `agents secrets` |
517
+ | Config | `~/.agents/` | You only | N/A |
518
+
519
+ ---
520
+
376
521
  ## Compatibility
377
522
 
378
523
  | Agent | Versions | MCP | Commands | Skills | Rules | Hooks | Plugins | Permissions | Routines | Teams |
@@ -425,7 +570,9 @@ Your choice. We hand off to the original CLI process — use your existing subsc
425
570
 
426
571
  ### Does it store my API keys or send telemetry?
427
572
 
428
- No. API keys come from your shell environment or each agent CLI's existing auth. No telemetry, no phone-home. User content lives in `~/.agents/`; operational state (versions, shims, sessions, caches) lives in `~/.agents-system/`.
573
+ **No telemetry, no phone-home.** Everything stays local. API keys come from your shell environment or each agent CLI's existing auth.
574
+
575
+ For full transparency: `agents-cli` keeps a local event log at `~/.agents/logs/` so you can see exactly what agents did on your machine. Logs are owner-readable only (0600) and auto-prune after 30 days. Set `AGENTS_DISABLE_EVENT_LOG=1` to disable. See [Security & Privacy](#security--privacy) for details.
429
576
 
430
577
  ### Which platforms?
431
578
 
@@ -1,5 +1,9 @@
1
1
  import chalk from 'chalk';
2
2
  import { ALL_BETA_FEATURES, getBetaConfigLocation, getEnabledBetaFeatures, setBetaEnabled, } from '../lib/beta.js';
3
+ const BETA_DESCRIPTIONS = {
4
+ drive: 'Google Drive integration for reading and writing files',
5
+ factory: 'Cloud-based agent dispatch via Rush Factory',
6
+ };
3
7
  function parseFeatures(values) {
4
8
  const valid = new Set(ALL_BETA_FEATURES);
5
9
  const invalid = values.filter((value) => !valid.has(value));
@@ -29,7 +33,8 @@ Examples:
29
33
  console.log(chalk.bold('Beta Features'));
30
34
  for (const feature of ALL_BETA_FEATURES) {
31
35
  const state = enabled.has(feature) ? chalk.green('enabled') : chalk.gray('disabled');
32
- console.log(` ${feature.padEnd(8)} ${state}`);
36
+ const desc = BETA_DESCRIPTIONS[feature] || '';
37
+ console.log(` ${feature.padEnd(10)} ${state.padEnd(18)} ${chalk.dim(desc)}`);
33
38
  }
34
39
  console.log('');
35
40
  console.log(chalk.gray(`Config: ${location.path}`));
@@ -8,7 +8,7 @@
8
8
  import chalk from 'chalk';
9
9
  import { buildExecCommand, parseExecEnv, execAgent, runWithFallback, AGENT_COMMANDS, } from '../lib/exec.js';
10
10
  import { profileExists, resolveProfileForRun } from '../lib/profiles.js';
11
- import { readBundle, resolveBundleEnv } from '../lib/secrets/bundles.js';
11
+ import { readBundle, resolveBundleEnv, describeBundle } from '../lib/secrets/bundles.js';
12
12
  import { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, RUN_STRATEGIES, } from '../lib/rotate.js';
13
13
  import { resolveVersionAlias } from '../lib/versions.js';
14
14
  const VALID_AGENTS = Object.keys(AGENT_COMMANDS);
@@ -36,7 +36,7 @@ export function registerRunCommand(program) {
36
36
  .option('--cwd <dir>', 'Working directory for the agent (defaults to current directory)')
37
37
  .option('--add-dir <dir>', 'Grant access to an additional directory outside the project (Claude only, repeatable)', (val, prev) => [...prev, val], [])
38
38
  .option('--json', 'Stream events as JSON lines (for parsing by other tools)')
39
- .option('--headless', 'Non-interactive mode (default for run)', true)
39
+ .option('--headless', 'Non-interactive mode (auto-enabled when prompt provided)', false)
40
40
  .option('-i, --interactive', 'Force interactive mode even when a prompt is provided')
41
41
  .option('--session-id <id>', 'Resume a previous conversation (Claude only)')
42
42
  .option('--verbose', 'Show detailed execution logs')
@@ -197,6 +197,13 @@ Examples:
197
197
  for (const bundleName of options.secrets) {
198
198
  try {
199
199
  const bundle = readBundle(bundleName);
200
+ const entries = describeBundle(bundle);
201
+ const counts = {};
202
+ for (const e of entries) {
203
+ counts[e.kind] = (counts[e.kind] || 0) + 1;
204
+ }
205
+ const breakdown = Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', ');
206
+ console.log(chalk.gray(`[secrets] Resolved ${bundleName}: ${entries.length} keys (${breakdown})`));
200
207
  secretsEnv = { ...secretsEnv, ...resolveBundleEnv(bundle) };
201
208
  }
202
209
  catch (err) {
@@ -82,6 +82,16 @@ export async function runInit(program, options = {}) {
82
82
  spinner.succeed(`Updated to ${result.commit}`);
83
83
  }
84
84
  else {
85
+ // Check git is available
86
+ try {
87
+ const { execSync } = await import('child_process');
88
+ execSync('which git', { stdio: 'ignore' });
89
+ }
90
+ catch {
91
+ spinner.fail('git is not installed');
92
+ console.log(chalk.gray('Install git first: https://git-scm.com/downloads'));
93
+ process.exit(1);
94
+ }
85
95
  const result = await cloneIntoExisting(DEFAULT_SYSTEM_REPO, agentsDir);
86
96
  if (!result.success) {
87
97
  spinner.fail(`Clone failed: ${result.error}`);
@@ -6,7 +6,7 @@ import { readManifest, writeManifest, createDefaultManifest } from '../lib/manif
6
6
  import { listMcpServerConfigs } from '../lib/mcp.js';
7
7
  import { getMcpDir } from '../lib/state.js';
8
8
  import { getEffectiveHome, getGlobalDefault, listInstalledVersions, getVersionHomePath, resolveInstalledAgentTargets, resolveConfiguredAgentTargets, resolveVersionAlias, } from '../lib/versions.js';
9
- import { getAgentsDir } from '../lib/state.js';
9
+ import { getUserAgentsDir } from '../lib/state.js';
10
10
  import { isPromptCancelled, isInteractiveTerminal, requireInteractiveSelection } from './utils.js';
11
11
  import { showResourceList, buildTargetsSection, } from './resource-view.js';
12
12
  /** Parse a comma-separated --agents string into validated agent IDs and optional version targets. */
@@ -163,7 +163,7 @@ Examples:
163
163
  console.log(chalk.gray('HTTP: agents mcp add <name> <url> --transport http'));
164
164
  process.exit(1);
165
165
  }
166
- const localPath = getAgentsDir();
166
+ const localPath = getUserAgentsDir();
167
167
  const manifest = readManifest(localPath) || createDefaultManifest();
168
168
  manifest.mcp = manifest.mcp || {};
169
169
  const targetConfig = parseMcpAgentTargets(options.agents);
@@ -400,7 +400,7 @@ Examples:
400
400
  agents mcp register --agents codex@0.116.0
401
401
  `)
402
402
  .action(async (name, options) => {
403
- const localPath = getAgentsDir();
403
+ const localPath = getUserAgentsDir();
404
404
  const manifest = readManifest(localPath);
405
405
  if (!manifest?.mcp) {
406
406
  console.log(chalk.yellow('No MCP servers in manifest'));
@@ -467,7 +467,7 @@ function buildMcpRows(opts) {
467
467
  const centralServers = new Map();
468
468
  for (const s of listMcpServerConfigs())
469
469
  centralServers.set(s.name, s);
470
- const manifest = readManifest(getAgentsDir());
470
+ const manifest = readManifest(getUserAgentsDir());
471
471
  const manifestEntries = manifest?.mcp || {};
472
472
  const targetPairs = iterMcpCapableVersions({
473
473
  agent: opts.filterAgent,
@@ -1,22 +1,2 @@
1
- /**
2
- * Top-level `agents prune` — destructive cleanup across the install.
3
- *
4
- * Two kinds of cleanup, one verb:
5
- * - Resource orphans: command/skill/hook files inside a version home that no
6
- * longer come from any source (deleted from ~/.agents/ but never reconciled
7
- * into the version install).
8
- * - Version duplicates: older installed versions of an agent that share an
9
- * account with a newer installed version of the same agent (the older copy
10
- * is redundant; the newer one is what's signed in and active).
11
- *
12
- * Sync (additive: copy missing/changed files into version homes) is no longer
13
- * a user-facing verb — `syncResourcesToVersion` runs at agent launch and
14
- * applies adds/updates automatically. Pruning, however, is destructive, so it
15
- * stays explicit.
16
- *
17
- * Default scope: each agent's currently-pinned default version for orphan
18
- * cleanup, plus the standard cross-agent version-dedup pass. Pass `--all`
19
- * to widen orphan cleanup to every installed version.
20
- */
21
1
  import type { Command } from 'commander';
22
2
  export declare function registerPruneCommand(program: Command): void;
@@ -1,3 +1,27 @@
1
+ /**
2
+ * Top-level `agents prune` — destructive cleanup across the install.
3
+ *
4
+ * Cleanup targets:
5
+ * - Resource orphans: command/skill/hook files inside a version home that no
6
+ * longer come from any source (deleted from ~/.agents/ but never reconciled
7
+ * into the version install).
8
+ * - Version duplicates: older installed versions of an agent that share an
9
+ * account with a newer installed version of the same agent.
10
+ * - Trash: soft-deleted resources in ~/.agents/.trash/ older than N days.
11
+ * - Sessions: session records in sessions.db older than N days.
12
+ * - Runs: routine execution logs, keeping only the last N per job.
13
+ *
14
+ * Sync (additive: copy missing/changed files into version homes) is no longer
15
+ * a user-facing verb — `syncResourcesToVersion` runs at agent launch and
16
+ * applies adds/updates automatically. Pruning, however, is destructive, so it
17
+ * stays explicit.
18
+ *
19
+ * Default scope: each agent's currently-pinned default version for orphan
20
+ * cleanup, plus the standard cross-agent version-dedup pass. Pass `--all`
21
+ * to widen orphan cleanup to every installed version.
22
+ */
23
+ import * as fs from 'fs';
24
+ import * as path from 'path';
1
25
  import chalk from 'chalk';
2
26
  import { confirm } from '@inquirer/prompts';
3
27
  import { diffVersionCommands, iterCommandsCapableVersions, removeCommandFromVersion, } from '../lib/commands.js';
@@ -7,8 +31,12 @@ import { getGlobalDefault } from '../lib/versions.js';
7
31
  import { resolveAgentName, formatAgentError } from '../lib/agents.js';
8
32
  import { pruneDuplicates } from './view.js';
9
33
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
34
+ import { getTrashDir } from '../lib/state.js';
35
+ import { countSessionsOlderThan, deleteSessionsOlderThan } from '../lib/session/db.js';
36
+ import { previewRunsPrune, pruneRuns, countAllRuns } from '../lib/routines.js';
10
37
  const RESOURCE_TYPES = ['commands', 'skills', 'hooks'];
11
- const ALL_TYPES = [...RESOURCE_TYPES, 'versions'];
38
+ const STATE_TYPES = ['trash', 'sessions', 'runs'];
39
+ const ALL_TYPES = [...RESOURCE_TYPES, 'versions', ...STATE_TYPES];
12
40
  function scopePairs(pairs, all) {
13
41
  if (all)
14
42
  return pairs;
@@ -59,6 +87,9 @@ function parseTarget(arg) {
59
87
  if (RESOURCE_TYPES.includes(arg)) {
60
88
  return { resourceTypes: [arg], includeVersions: false };
61
89
  }
90
+ if (STATE_TYPES.includes(arg)) {
91
+ return { resourceTypes: [], includeVersions: false, stateType: arg };
92
+ }
62
93
  if (arg === 'versions') {
63
94
  return { resourceTypes: [], includeVersions: true };
64
95
  }
@@ -72,6 +103,202 @@ function parseTarget(arg) {
72
103
  console.log(chalk.gray(formatAgentError(arg)));
73
104
  process.exit(1);
74
105
  }
106
+ function parseDays(value, defaultDays) {
107
+ const match = value.match(/^(\d+)d?$/);
108
+ if (match)
109
+ return parseInt(match[1], 10);
110
+ return defaultDays;
111
+ }
112
+ function formatBytes(bytes) {
113
+ if (bytes < 1024)
114
+ return `${bytes} B`;
115
+ if (bytes < 1024 * 1024)
116
+ return `${(bytes / 1024).toFixed(1)} KB`;
117
+ if (bytes < 1024 * 1024 * 1024)
118
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
119
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
120
+ }
121
+ function getDirSize(dirPath) {
122
+ if (!fs.existsSync(dirPath))
123
+ return 0;
124
+ let size = 0;
125
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
126
+ for (const entry of entries) {
127
+ const fullPath = path.join(dirPath, entry.name);
128
+ if (entry.isDirectory()) {
129
+ size += getDirSize(fullPath);
130
+ }
131
+ else {
132
+ try {
133
+ size += fs.statSync(fullPath).size;
134
+ }
135
+ catch { /* ignore */ }
136
+ }
137
+ }
138
+ return size;
139
+ }
140
+ async function runTrashPrune(options) {
141
+ const trashDir = getTrashDir();
142
+ if (!fs.existsSync(trashDir)) {
143
+ console.log(chalk.green('Trash is empty.'));
144
+ return;
145
+ }
146
+ const days = parseDays(options.olderThan || '30d', 30);
147
+ const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1000;
148
+ const toPrune = [];
149
+ function scanDir(dir) {
150
+ if (!fs.existsSync(dir))
151
+ return;
152
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
153
+ const fullPath = path.join(dir, entry.name);
154
+ try {
155
+ const stat = fs.statSync(fullPath);
156
+ if (stat.mtimeMs < cutoffMs) {
157
+ toPrune.push({ path: fullPath, mtime: stat.mtimeMs, size: entry.isDirectory() ? getDirSize(fullPath) : stat.size });
158
+ }
159
+ else if (entry.isDirectory()) {
160
+ scanDir(fullPath);
161
+ }
162
+ }
163
+ catch { /* skip inaccessible */ }
164
+ }
165
+ }
166
+ scanDir(trashDir);
167
+ if (toPrune.length === 0) {
168
+ console.log(chalk.green(`No trash entries older than ${days} days.`));
169
+ return;
170
+ }
171
+ const totalSize = toPrune.reduce((sum, e) => sum + e.size, 0);
172
+ console.log(chalk.bold(`Trash entries older than ${days} days\n`));
173
+ for (const entry of toPrune.slice(0, 20)) {
174
+ const age = Math.floor((Date.now() - entry.mtime) / (24 * 60 * 60 * 1000));
175
+ console.log(` ${chalk.gray(`${age}d ago`)} ${path.relative(trashDir, entry.path)}`);
176
+ }
177
+ if (toPrune.length > 20) {
178
+ console.log(chalk.gray(` ... and ${toPrune.length - 20} more`));
179
+ }
180
+ console.log();
181
+ if (options.dryRun) {
182
+ console.log(chalk.gray(`${toPrune.length} entries (${formatBytes(totalSize)}). Run without --dry-run to delete.`));
183
+ return;
184
+ }
185
+ if (!options.yes) {
186
+ if (!isInteractiveTerminal()) {
187
+ console.log(chalk.yellow('Non-interactive shell: pass -y to confirm, or --dry-run to preview.'));
188
+ process.exit(1);
189
+ }
190
+ let ok = false;
191
+ try {
192
+ ok = await confirm({ message: `Delete ${toPrune.length} entries (${formatBytes(totalSize)})?`, default: false });
193
+ }
194
+ catch (err) {
195
+ if (isPromptCancelled(err)) {
196
+ console.log(chalk.gray('Cancelled'));
197
+ return;
198
+ }
199
+ throw err;
200
+ }
201
+ if (!ok) {
202
+ console.log(chalk.gray('Cancelled'));
203
+ return;
204
+ }
205
+ }
206
+ let deleted = 0;
207
+ for (const entry of toPrune) {
208
+ try {
209
+ fs.rmSync(entry.path, { recursive: true, force: true });
210
+ deleted++;
211
+ }
212
+ catch { /* ignore */ }
213
+ }
214
+ console.log(chalk.green(`Pruned ${deleted} trash entries (${formatBytes(totalSize)}).`));
215
+ }
216
+ async function runSessionsPrune(options) {
217
+ const days = parseDays(options.olderThan || '90d', 90);
218
+ const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1000;
219
+ const count = countSessionsOlderThan(cutoffMs);
220
+ if (count === 0) {
221
+ console.log(chalk.green(`No sessions older than ${days} days.`));
222
+ return;
223
+ }
224
+ console.log(chalk.bold(`Sessions older than ${days} days: ${count}\n`));
225
+ if (options.dryRun) {
226
+ console.log(chalk.gray(`${count} session(s). Run without --dry-run to delete.`));
227
+ return;
228
+ }
229
+ if (!options.yes) {
230
+ if (!isInteractiveTerminal()) {
231
+ console.log(chalk.yellow('Non-interactive shell: pass -y to confirm, or --dry-run to preview.'));
232
+ process.exit(1);
233
+ }
234
+ let ok = false;
235
+ try {
236
+ ok = await confirm({ message: `Delete ${count} session records?`, default: false });
237
+ }
238
+ catch (err) {
239
+ if (isPromptCancelled(err)) {
240
+ console.log(chalk.gray('Cancelled'));
241
+ return;
242
+ }
243
+ throw err;
244
+ }
245
+ if (!ok) {
246
+ console.log(chalk.gray('Cancelled'));
247
+ return;
248
+ }
249
+ }
250
+ const deleted = deleteSessionsOlderThan(cutoffMs);
251
+ console.log(chalk.green(`Pruned ${deleted} session records.`));
252
+ }
253
+ async function runRunsPrune(options) {
254
+ const keep = options.keep ? parseInt(options.keep, 10) : 10;
255
+ if (isNaN(keep) || keep < 0) {
256
+ console.log(chalk.red('--keep must be a non-negative integer'));
257
+ process.exit(1);
258
+ }
259
+ const preview = previewRunsPrune(keep);
260
+ const total = countAllRuns();
261
+ if (preview.length === 0) {
262
+ console.log(chalk.green(`All jobs have ${keep} or fewer runs. Nothing to prune.`));
263
+ return;
264
+ }
265
+ console.log(chalk.bold(`Routine runs to prune (keeping last ${keep} per job)\n`));
266
+ const byJob = new Map();
267
+ for (const run of preview) {
268
+ byJob.set(run.jobName, (byJob.get(run.jobName) || 0) + 1);
269
+ }
270
+ for (const [job, count] of byJob) {
271
+ console.log(` ${chalk.cyan(job)}: ${count} old runs`);
272
+ }
273
+ console.log();
274
+ if (options.dryRun) {
275
+ console.log(chalk.gray(`${preview.length} of ${total} runs would be deleted. Run without --dry-run to delete.`));
276
+ return;
277
+ }
278
+ if (!options.yes) {
279
+ if (!isInteractiveTerminal()) {
280
+ console.log(chalk.yellow('Non-interactive shell: pass -y to confirm, or --dry-run to preview.'));
281
+ process.exit(1);
282
+ }
283
+ let ok = false;
284
+ try {
285
+ ok = await confirm({ message: `Delete ${preview.length} old runs?`, default: false });
286
+ }
287
+ catch (err) {
288
+ if (isPromptCancelled(err)) {
289
+ console.log(chalk.gray('Cancelled'));
290
+ return;
291
+ }
292
+ throw err;
293
+ }
294
+ if (!ok) {
295
+ console.log(chalk.gray('Cancelled'));
296
+ return;
297
+ }
298
+ }
299
+ const { deleted, bytesFreed } = pruneRuns(keep);
300
+ console.log(chalk.green(`Pruned ${deleted} runs (${formatBytes(bytesFreed)}).`));
301
+ }
75
302
  async function runOrphanPrune(resourceTypes, options) {
76
303
  const groups = collectOrphans(resourceTypes, options.all === true);
77
304
  if (groups.length === 0) {
@@ -133,10 +360,12 @@ async function runOrphanPrune(resourceTypes, options) {
133
360
  export function registerPruneCommand(program) {
134
361
  program
135
362
  .command('prune [target]')
136
- .description('Remove orphan resources (commands/skills/hooks) and/or older duplicate version installs (versions soft-deleted to ~/.agents-system/trash/)')
363
+ .description('Remove orphan resources, old versions, trash, sessions, or routine runs')
137
364
  .option('--all', 'For orphan cleanup: sweep every installed version (default: current default version per agent)')
138
- .option('--dry-run', 'Show what would be removed without deleting')
365
+ .option('--dry-run', 'Show what would be removed without deleting (default for state targets)')
139
366
  .option('-y, --yes', 'Skip confirmation prompt')
367
+ .option('--older-than <days>', 'For trash/sessions: delete entries older than N days (default: 30d for trash, 90d for sessions)')
368
+ .option('--keep <n>', 'For runs: keep the last N runs per job (default: 10)')
140
369
  .addHelpText('after', `
141
370
  Targets:
142
371
  (none) Orphans across commands, skills, hooks + duplicate versions
@@ -145,6 +374,9 @@ Targets:
145
374
  hooks Orphan hook scripts only
146
375
  versions Older duplicate version installs only
147
376
  <agent> Older duplicate versions for one agent (e.g. 'claude')
377
+ trash Soft-deleted resources older than --older-than days (default 30)
378
+ sessions Session records in sessions.db older than --older-than days (default 90)
379
+ runs Routine execution logs, keeping only --keep per job (default 10)
148
380
 
149
381
  Examples:
150
382
  # Full sweep: orphan resources + duplicate versions for current defaults
@@ -156,14 +388,26 @@ Examples:
156
388
  # Just version dedup
157
389
  agents prune versions
158
390
 
159
- # Just version dedup for one agent
160
- agents prune claude
161
-
162
391
  # Sweep every installed version's orphans, not only the defaults
163
392
  agents prune --all
164
393
 
165
- # Preview without deleting
166
- agents prune --dry-run
394
+ # Preview trash entries older than 30 days
395
+ agents prune trash --dry-run
396
+
397
+ # Delete trash entries older than 60 days
398
+ agents prune trash --older-than 60 -y
399
+
400
+ # Preview session cleanup (90+ days old)
401
+ agents prune sessions --dry-run
402
+
403
+ # Delete sessions older than 180 days
404
+ agents prune sessions --older-than 180 -y
405
+
406
+ # Preview runs cleanup (keeping last 10)
407
+ agents prune runs --dry-run
408
+
409
+ # Keep only the last 5 runs per job
410
+ agents prune runs --keep 5 -y
167
411
 
168
412
  What's an orphan?
169
413
  A command, skill, or hook present inside a version home but missing from every
@@ -171,18 +415,27 @@ What's an orphan?
171
415
  repos). Usually leftovers from a resource that was deleted or moved but never
172
416
  reconciled into the version install.
173
417
 
174
- What this does NOT do:
175
- Adds and updates flow through the auto-sync that runs when you launch the
176
- agent — there is no manual sync verb.
177
-
178
418
  Soft-delete:
179
419
  Version directories are NEVER hard-deleted. \`prune\` moves them to
180
- ~/.agents-system/trash/versions/<agent>/<version>/<timestamp>/. Recover
181
- with \`agents trash list\` and \`agents trash restore <agent>@<version>\`.
182
- The trash never auto-expires; \`rm -rf\` it manually when you're sure.
420
+ ~/.agents/.trash/versions/<agent>/<version>/<timestamp>/. Use
421
+ \`agents prune trash\` to expire old trash entries.
183
422
  `)
184
423
  .action(async (target, options) => {
185
424
  const parsed = parseTarget(target);
425
+ if (parsed.stateType) {
426
+ switch (parsed.stateType) {
427
+ case 'trash':
428
+ await runTrashPrune(options);
429
+ break;
430
+ case 'sessions':
431
+ await runSessionsPrune(options);
432
+ break;
433
+ case 'runs':
434
+ await runRunsPrune(options);
435
+ break;
436
+ }
437
+ return;
438
+ }
186
439
  if (parsed.resourceTypes.length > 0) {
187
440
  await runOrphanPrune(parsed.resourceTypes, options);
188
441
  if (parsed.includeVersions)