@phnx-labs/agents-cli 1.22.12 → 1.22.14

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.14
4
+
5
+ - **`agents secrets view <bundle> --reveal` now resolves a locked keychain bundle
6
+ interactively at a real terminal.** The command hardcoded `agentOnly: true` on
7
+ both reveal call sites (`commands/secrets.ts`), so an explicit human `--reveal`
8
+ on a locked bundle went through the broker-only path and errored with an unlock
9
+ hint instead of raising the one Touch ID sheet the human just asked for. The
10
+ `agentOnly` flag is now `isHeadlessSecretsContext() || !isInteractiveTerminal()`
11
+ — under an agent (`AGENTS_RUNTIME`) or with no TTY it stays broker-only and
12
+ never prompts, but a deliberate `--reveal` typed at an interactive terminal
13
+ resolves the value with a single biometric sheet. This mirrors the existing
14
+ `reveal && !isInteractiveTerminal()` guard a few lines up. `export --plaintext`
15
+ and `exec` are untouched — they stay intentionally silent for release/CI
16
+ scripts. Source: `apps/cli/src/commands/secrets.ts`.
17
+
18
+ - **`agents sessions optimize` — compact the FTS5 session search index.** The scanner delete+inserts a session's docs into the `tool_call_text` / `session_text` full-text indexes on every rescan, and FTS5 never merges the resulting segments on its own — so over thousands of sessions the `%_data` shadow tables bloat with hundreds of thousands of unmerged segments (observed on a real fleet box: 701 MB of index for ~69 MB of content, 196K segments) and `agents sessions` slows to a crawl / hangs. The new command runs FTS5 `'optimize'` (merge all segments, purge tombstones), non-destructively — no searchable content is lost. Reclaimed space frees as reusable pages inside the DB file (VACUUM with the daemon stopped returns it to disk); wireable to a weekly routine so the index never re-bloats. Source: `apps/cli/src/lib/session/db.ts` (`optimizeSessionSearchIndex`), `apps/cli/src/commands/sessions-optimize.ts`.
19
+
20
+ ## 1.22.13
21
+
22
+ - **`agents sessions` accepts direct live-state flags and remains fleet-wide by default.** `--working`, `--idle`, `--waiting`, `--orphan`/`--orphaned`, `--crashed`, `--closed`, `--abandoned`, `--queued`, and `--unknown` each imply the live scan; multiple flags form a union. `--working` is narrower than `--active`: it excludes idle, waiting, and lifecycle-failure rows. Cross-device collection was already the default and stays that way; `--local` opts out, while `--all` continues to widen historical directory and time scope. Source: `apps/cli/src/commands/sessions.ts`, `apps/cli/src/commands/sessions.test.ts`.
23
+
24
+ - **Workflows: `name@source` disambiguation (Phase 5 packaging).** When two plugins (or a plugin and an extra repo) ship the same workflow name, pin the source: `agents run deploy@ship-tools` or `agents run workflow:deploy@social`. Bare names keep layered precedence (project > user > plugin > extra > system); a missing source returns no match instead of silently falling back. Source: `apps/cli/src/lib/workflows.ts`, `apps/cli/src/lib/resources/workflows.ts`.
25
+
3
26
  ## 1.22.12
4
27
 
5
28
  - Store operational events in daily history directories, retain 7 days and at most 50 MiB automatically, and make `agents logs audit` use the `agents events --audit` reader.
package/README.md CHANGED
@@ -292,6 +292,10 @@ Search is the past tense. `--active` is the present -- it infers what each runni
292
292
 
293
293
  ```bash
294
294
  agents sessions --active # every live run across the fleet, with state
295
+ agents sessions --working # actively producing work (fleet-wide)
296
+ agents sessions --idle # stopped between turns (fleet-wide)
297
+ agents sessions --orphan # agent outlived its terminal client
298
+ agents sessions --crashed # terminal and agent disappeared uncleanly
295
299
  agents sessions focus a1b2c3d4 # jump back into one — attach in place, or resume
296
300
  ```
297
301
 
@@ -322,7 +326,7 @@ Filters **stack** (they AND together), the active set shows in the header, and t
322
326
  | --- | --- |
323
327
  | ![sessions browser, preview hidden](assets/demos/sessions-preview-before.png) | ![sessions browser, preview open with a links line](assets/demos/sessions-preview-after.png) |
324
328
 
325
- Each live session resolves to `working`, `waiting_input` (with why -- a question, a plan review, or a permission prompt), or `idle`, alongside badges for the PR it opened, the worktree it sits in, and the ticket it's working. `agents sessions focus [id]` attaches the live pane in place -- the tmux split locally or over SSH, or its Ghostty tab -- and falls back to a fresh tab + resume when the terminal is gone.
329
+ Each live session resolves to `working`, `waiting_input` (with why -- a question, a plan review, or a permission prompt), `idle`, or a lifecycle state such as `orphaned`, `crashed`, `closed`, `abandoned`, `queued`, or `unknown`. Pass the matching flag (`--working`, `--idle`, `--waiting`, `--orphan`, `--crashed`, `--closed`, `--abandoned`, `--queued`, `--unknown`) directly; each implies `--active`, and several flags form a union. The fleet fan-out is already the default; `--local` opts out. `--all` instead widens historical directory and time scope. Rows also carry badges for the PR, worktree, and ticket. `agents sessions focus [id]` attaches the live pane in place -- the tmux split locally or over SSH, or its Ghostty tab -- and falls back to a fresh tab + resume when the terminal is gone.
326
330
 
327
331
  Landing on a session cold? `agents sessions <id>` prints a catch-up digest: an inferred title, files changed grouped by directory (created / modified / deleted), a histogram of which tools did the work (including parsed Bash commands -- `git`, `npm`, `ffmpeg`, `ssh`, and so on), and the last test verdict -- the signals to reload a task in seconds.
328
332
 
package/dist/bin/agents CHANGED
Binary file
@@ -18,7 +18,7 @@ import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
18
18
  import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, verifyRemoteKeychainPush, keychainWriteFailureMessage, } from '../lib/secrets/remote.js';
19
19
  import { remoteShellFor, buildWindowsStdinImportCommand } from '../lib/hosts/remote-cmd.js';
20
20
  import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
21
- import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, healKeychainBundleMetadataAclOnce, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, readBundleIfDecryptable, reAclBundleItems, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, SECRET_TYPES, } from '../lib/secrets/bundles.js';
21
+ import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, healKeychainBundleMetadataAclOnce, isHeadlessSecretsContext, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, readBundleIfDecryptable, reAclBundleItems, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, SECRET_TYPES, } from '../lib/secrets/bundles.js';
22
22
  import { parseListFilters, bundleMatchesFilter, bundleExpiry, filterIsActive, describeFilter, parseSortField, sortBundles, SORT_FIELDS, REF_KINDS, DEFAULT_EXPIRING_DAYS, } from '../lib/secrets/list-filter.js';
23
23
  import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
24
24
  import { getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, maybeAutoRekey, } from '../lib/secrets/index.js';
@@ -1254,7 +1254,10 @@ export function registerSecretsCommands(program) {
1254
1254
  const { env } = readAndResolveBundleEnv(bundle.name, {
1255
1255
  caller: 'view --reveal --json',
1256
1256
  keyMode: 'storage',
1257
- agentOnly: true,
1257
+ // An explicit human `--reveal` at a terminal is a deliberate value
1258
+ // access → resolve interactively (one Touch ID). Under an agent
1259
+ // (AGENTS_RUNTIME) or headless (no TTY) it stays broker-only.
1260
+ agentOnly: isHeadlessSecretsContext() || !isInteractiveTerminal(),
1258
1261
  });
1259
1262
  for (const entry of entries) {
1260
1263
  if (entry.kind === 'keychain' && env[entry.key] !== undefined) {
@@ -1369,15 +1372,16 @@ export function registerSecretsCommands(program) {
1369
1372
  console.error(chalk.red('--reveal in a non-TTY requires --plaintext.'));
1370
1373
  process.exit(1);
1371
1374
  }
1372
- // Resolve through the broker / durable-session path. A locked keychain
1373
- // bundle errors with the explicit unlock hint; viewing never raises a
1374
- // Touch ID sheet itself.
1375
+ // An explicit human `view --reveal` at a terminal is a deliberate value
1376
+ // access: resolve interactively (one Touch ID) so a locked bundle shows
1377
+ // its values. Under an agent (AGENTS_RUNTIME) or headless (no TTY) it
1378
+ // stays broker-only and errors with the unlock hint — never a sheet.
1375
1379
  const revealedValues = new Map();
1376
1380
  if (reveal) {
1377
1381
  const { env } = readAndResolveBundleEnv(bundle.name, {
1378
1382
  caller: 'view --reveal',
1379
1383
  keyMode: 'storage',
1380
- agentOnly: true,
1384
+ agentOnly: isHeadlessSecretsContext() || !isInteractiveTerminal(),
1381
1385
  });
1382
1386
  for (const entry of entries) {
1383
1387
  if (entry.kind === 'keychain' && env[entry.key] !== undefined) {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `agents sessions optimize` — compact the FTS5 session/tool-call search index.
3
+ *
4
+ * The scanner delete+inserts a session's docs into the `tool_call_text` /
5
+ * `session_text` FTS5 indexes on every rescan (and on every extractor-version
6
+ * bump), and FTS5 never merges the resulting segments on its own. Over thousands
7
+ * of sessions and re-index passes the `%_data` shadow tables bloat with hundreds
8
+ * of thousands of unmerged segments — gigabytes of index for tens of MB of
9
+ * actual content — and `agents sessions` queries slow to a crawl. This runs the
10
+ * FTS5 `'optimize'` command to merge every segment into one and purge tombstones.
11
+ * Non-destructive: no searchable content is lost.
12
+ */
13
+ import type { Command } from 'commander';
14
+ export declare function registerSessionsOptimizeCommand(sessionsCmd: Command): void;
@@ -0,0 +1,41 @@
1
+ import chalk from 'chalk';
2
+ import { optimizeSessionSearchIndex } from '../lib/session/db.js';
3
+ import { setHelpSections } from '../lib/help.js';
4
+ export function registerSessionsOptimizeCommand(sessionsCmd) {
5
+ const cmd = sessionsCmd
6
+ .command('optimize')
7
+ .description('Compact the session search index (FTS5), reclaiming bloat from repeated re-indexing')
8
+ .option('--json', 'Emit machine-readable JSON')
9
+ .action((_opts, c) => {
10
+ const opts = c.optsWithGlobals();
11
+ const results = optimizeSessionSearchIndex();
12
+ if (opts.json) {
13
+ console.log(JSON.stringify(results, null, 2));
14
+ return;
15
+ }
16
+ for (const r of results) {
17
+ const merged = Math.max(0, r.segmentsBefore - r.segmentsAfter);
18
+ console.log(` ${chalk.cyan(r.table)}: ${r.segmentsBefore} -> ${r.segmentsAfter} segments ` +
19
+ `(${chalk.green(merged)} merged)`);
20
+ }
21
+ console.log(chalk.gray(' Reclaimed space stays as reusable pages inside the file; VACUUM (daemon stopped) returns it to disk.'));
22
+ });
23
+ setHelpSections(cmd, {
24
+ examples: `
25
+ # Compact the session/tool search index once it has grown fragmented
26
+ agents sessions optimize
27
+
28
+ # Machine-readable segment counts
29
+ agents sessions optimize --json
30
+
31
+ # Wire it to a weekly routine so the index never re-bloats
32
+ agents routines add sessions-optimize --schedule "0 4 * * 0" --agent claude \\
33
+ --prompt "Run: agents sessions optimize"
34
+ `,
35
+ notes: `
36
+ - FTS5 appends a segment on every insert and tombstones every delete; the scanner delete+inserts a session's docs on each rescan and never self-merges, so \`tool_call_text_data\` / \`session_text_data\` bloat with unmerged segments — GBs of index for tens of MB of content, and queries slow down.
37
+ - This runs FTS5 \`'optimize'\`: it merges every segment into one and purges tombstones. Non-destructive — no searchable content is lost.
38
+ - Reclaimed space becomes reusable free pages inside the DB file. To return it to the OS, stop the daemon (\`agents routines stop\`) and run \`VACUUM\` against \`~/.agents/.history/sessions/sessions.db\`.
39
+ `,
40
+ });
41
+ }
@@ -46,6 +46,16 @@ interface SessionsOptions extends SessionFilterOptions {
46
46
  flat?: boolean;
47
47
  /** With --active: show only sessions waiting on user input; exit 1 if any. */
48
48
  waiting?: boolean;
49
+ /** Live-state shorthand filters. Any one implies --active; several compose as OR. */
50
+ working?: boolean;
51
+ idle?: boolean;
52
+ orphan?: boolean;
53
+ orphaned?: boolean;
54
+ crashed?: boolean;
55
+ closed?: boolean;
56
+ abandoned?: boolean;
57
+ queued?: boolean;
58
+ unknown?: boolean;
49
59
  /** Show only favorited (starred) sessions — the `f` key's flag twin. */
50
60
  favorites?: boolean;
51
61
  /** Enrich the listing with live glyphs/preview for running rows. Default on;
@@ -311,6 +321,11 @@ export declare function gatherActiveSessions(opts?: {
311
321
  sessions: ActiveSession[];
312
322
  remoteDeviceCount: number;
313
323
  }>;
324
+ export type LiveStatusFilter = 'working' | 'idle' | 'waiting' | 'orphaned' | 'crashed' | 'closed' | 'abandoned' | 'queued' | 'unknown';
325
+ /** Match the status words users see, preserving activity's richer working signal. */
326
+ export declare function matchesLiveStatus(session: ActiveSession, status: LiveStatusFilter): boolean;
327
+ /** Resolve convenience flags once. Multiple flags intentionally form a union. */
328
+ export declare function requestedLiveStatuses(options: SessionsOptions): LiveStatusFilter[];
314
329
  /**
315
330
  * A bare interactive fleet listing — no query, no render/filter flag — that the
316
331
  * `runSessionBrowser` picker can represent. The single predicate shared by the
@@ -61,6 +61,7 @@ import { registerSessionsImportCommand } from './sessions-import.js';
61
61
  import { registerSessionsMigrateCommand, registerSessionsMigrationsCommand } from './sessions-migrate.js';
62
62
  import { registerSessionsBackfillCommand } from './sessions-backfill.js';
63
63
  import { registerSessionsStatsCommand } from './sessions-stats.js';
64
+ import { registerSessionsOptimizeCommand } from './sessions-optimize.js';
64
65
  import { runBrowserSessions } from '../lib/browser/sessions-list.js';
65
66
  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';
66
67
  const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
@@ -1091,9 +1092,12 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
1091
1092
  const merged = opts.favoritesOnly
1092
1093
  ? gathered.sessions.filter((s) => !!s.sessionId && listFavorites().has(s.sessionId))
1093
1094
  : gathered.sessions;
1094
- // --waiting: only sessions blocked on the user. Exits non-zero when any are
1095
- // present so a supervising agent or hook can poll it as a gate.
1096
- const sessions = waitingOnly ? merged.filter(isAwaitingUser) : merged;
1095
+ // Status flags form a union. --waiting additionally retains its scriptable
1096
+ // gate: exit non-zero when the union contains a session awaiting the user.
1097
+ const statusFiltered = opts.statuses?.length
1098
+ ? merged.filter((session) => opts.statuses.some((status) => matchesLiveStatus(session, status)))
1099
+ : merged;
1100
+ const sessions = statusFiltered;
1097
1101
  if (asJson) {
1098
1102
  // Resolve who is watching each local tmux pane before serializing: `viewingIn`
1099
1103
  // is how a consumer distinguishes a session someone is looking at from one
@@ -1101,7 +1105,7 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
1101
1105
  // scriptable path stays cheap — see enrichTmuxLocators.
1102
1106
  await enrichTmuxLocators(sessions.filter(s => !s.machine || s.machine === self));
1103
1107
  process.stdout.write(JSON.stringify(serializeActiveSessionsForJson(sessions), null, 2) + '\n');
1104
- if (waitingOnly && sessions.length > 0)
1108
+ if (waitingOnly && sessions.some(isAwaitingUser))
1105
1109
  process.exitCode = 1;
1106
1110
  return;
1107
1111
  }
@@ -1136,9 +1140,40 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
1136
1140
  if (!opts.local && !opts.hosts?.length && remoteDeviceCount === 0)
1137
1141
  printCrossMachineTip();
1138
1142
  // Scriptable gate: a non-zero exit when anything is waiting on the user.
1139
- if (waitingOnly && sessions.length > 0)
1143
+ if (waitingOnly && sessions.some(isAwaitingUser))
1140
1144
  process.exitCode = 1;
1141
1145
  }
1146
+ /** Match the status words users see, preserving activity's richer working signal. */
1147
+ export function matchesLiveStatus(session, status) {
1148
+ if (status === 'working')
1149
+ return session.activity === 'working' || (!session.activity && session.status === 'running');
1150
+ if (status === 'waiting')
1151
+ return isAwaitingUser(session);
1152
+ return session.status === status;
1153
+ }
1154
+ /** Resolve convenience flags once. Multiple flags intentionally form a union. */
1155
+ export function requestedLiveStatuses(options) {
1156
+ const statuses = [];
1157
+ if (options.working)
1158
+ statuses.push('working');
1159
+ if (options.idle)
1160
+ statuses.push('idle');
1161
+ if (options.waiting)
1162
+ statuses.push('waiting');
1163
+ if (options.orphan || options.orphaned)
1164
+ statuses.push('orphaned');
1165
+ if (options.crashed)
1166
+ statuses.push('crashed');
1167
+ if (options.closed)
1168
+ statuses.push('closed');
1169
+ if (options.abandoned)
1170
+ statuses.push('abandoned');
1171
+ if (options.queued)
1172
+ statuses.push('queued');
1173
+ if (options.unknown)
1174
+ statuses.push('unknown');
1175
+ return [...new Set(statuses)];
1176
+ }
1142
1177
  /** Nudge shown when `--active` has no other machines to fold in. */
1143
1178
  function printCrossMachineTip() {
1144
1179
  console.log(chalk.gray("\nTip: include sessions from your other machines — register them with 'ag devices sync', then rerun. Use --local to skip."));
@@ -1204,6 +1239,22 @@ function canonicalSessionsCommand(query, options) {
1204
1239
  const a = ['sessions'];
1205
1240
  if (options.active)
1206
1241
  a.push('--active');
1242
+ if (options.working)
1243
+ a.push('--working');
1244
+ if (options.idle)
1245
+ a.push('--idle');
1246
+ if (options.orphan || options.orphaned)
1247
+ a.push('--orphan');
1248
+ if (options.crashed)
1249
+ a.push('--crashed');
1250
+ if (options.closed)
1251
+ a.push('--closed');
1252
+ if (options.abandoned)
1253
+ a.push('--abandoned');
1254
+ if (options.queued)
1255
+ a.push('--queued');
1256
+ if (options.unknown)
1257
+ a.push('--unknown');
1207
1258
  if (options.teams)
1208
1259
  a.push('--teams');
1209
1260
  if (options.inTeam)
@@ -1421,6 +1472,8 @@ async function sessionsAction(query, options,
1421
1472
  */
1422
1473
  limitSource) {
1423
1474
  const queryClauses = options.query ?? [];
1475
+ const liveStatuses = requestedLiveStatuses(options);
1476
+ const liveOnly = options.active === true || liveStatuses.length > 0;
1424
1477
  const toolOnly = options.include?.split(',').map((role) => role.trim()).filter(Boolean).join(',') === 'tools';
1425
1478
  const toolEvidenceMode = toolOnly;
1426
1479
  if (options.count && !toolOnly) {
@@ -1551,7 +1604,7 @@ limitSource) {
1551
1604
  // keeps the legacy per-host stream (each remote's raw stdout under a
1552
1605
  // `── host ──` banner). With --active, the hosts are folded into the merged
1553
1606
  // machine-grouped view instead (handled below).
1554
- if (options.host && options.host.length > 0 && !options.active && !toolEvidenceMode) {
1607
+ if (options.host && options.host.length > 0 && !liveOnly && !toolEvidenceMode) {
1555
1608
  // --local means "skip the SSH fan-out"; --host means "look only over there".
1556
1609
  // Together they ask for a peer's sessions without dialing the peer, which can
1557
1610
  // only ever be empty — so say that instead of rendering a blank list.
@@ -1591,7 +1644,7 @@ limitSource) {
1591
1644
  await renderSessionPreview(query, { agent: options.agent, project: options.project, local: options.local });
1592
1645
  return;
1593
1646
  }
1594
- if (options.active) {
1647
+ if (liveOnly) {
1595
1648
  // The running view is built from the live scan, which carries no team lineage
1596
1649
  // (that comes off the transcript index and the teams meta dir), so --in-team
1597
1650
  // has nothing to match on here. Say so rather than ignoring the flag.
@@ -1606,7 +1659,7 @@ limitSource) {
1606
1659
  // --since seeds the window; --until / --project (no browser field) or a
1607
1660
  // multi-host scope fall through to the static dump that already honors them.
1608
1661
  if (useInteractiveBrowser(options) &&
1609
- !options.waiting &&
1662
+ liveStatuses.length === 0 &&
1610
1663
  !options.until &&
1611
1664
  !options.project &&
1612
1665
  !options.sort &&
@@ -1630,6 +1683,7 @@ limitSource) {
1630
1683
  local: forceLocal,
1631
1684
  hosts: options.host,
1632
1685
  favoritesOnly: options.favorites === true,
1686
+ statuses: liveStatuses,
1633
1687
  });
1634
1688
  return;
1635
1689
  }
@@ -3617,7 +3671,16 @@ export function registerSessionsCommands(program) {
3617
3671
  .option('--active', 'Show only sessions running right now across terminals, teams, cloud, and headless agents')
3618
3672
  .option('--roots', 'With --json: emit the on-disk directories scanned for session transcripts, per agent (for external watchers)')
3619
3673
  .option('--local', 'Only this machine — skip the cross-machine SSH fan-out (default listing and --active)')
3620
- .option('--waiting', 'With --active: show only sessions waiting on your input (exits non-zero if any)')
3674
+ .option('--working', 'Show live sessions currently doing work (implies --active)')
3675
+ .option('--idle', 'Show live sessions that have stopped between turns (implies --active)')
3676
+ .option('--waiting', 'Show live sessions waiting on your input; exits non-zero if any (implies --active)')
3677
+ .option('--orphan', 'Show live sessions whose process outlived its terminal client (implies --active)')
3678
+ .option('--orphaned', 'Alias for --orphan')
3679
+ .option('--crashed', 'Show sessions whose terminal disappeared with the process (implies --active)')
3680
+ .option('--closed', 'Show recently observed sessions whose process exited normally (implies --active)')
3681
+ .option('--abandoned', 'Show sessions with no transcript progress for the abandonment window (implies --active)')
3682
+ .option('--queued', 'Show queued sessions that have not started running (implies --active)')
3683
+ .option('--unknown', 'Show sessions whose live state cannot be determined (implies --active)')
3621
3684
  .option('--favorites', 'Show only favorited (starred) sessions — star them with `*` in the browser or `agents sessions favorite <id>`')
3622
3685
  .option('--tree', 'Group the listing by directory; drops the id/version columns for readability')
3623
3686
  .option('--flat', 'Plain flat table (one row per session) instead of the grouped project overview')
@@ -3645,6 +3708,12 @@ export function registerSessionsCommands(program) {
3645
3708
  # Show only what's running right now (terminals, teams, cloud, headless)
3646
3709
  agents sessions --active
3647
3710
 
3711
+ # Filter the live fleet by the status word shown in the roster
3712
+ agents sessions --working
3713
+ agents sessions --idle
3714
+ agents sessions --orphan
3715
+ agents sessions --crashed
3716
+
3648
3717
  # --- Session lifecycle (one verb per intent) ---
3649
3718
  # Jump to a live session (attach its terminal, or open a tab + resume)
3650
3719
  agents sessions focus a1b2c3d4
@@ -3703,7 +3772,8 @@ export function registerSessionsCommands(program) {
3703
3772
  detach <id> interactive → headless continuation
3704
3773
  attach <id> headless → interactive in this terminal
3705
3774
  resume [query] multi-select history → open tabs (or run --resume <id>)
3706
- - The interactive listing folds in your other online machines automatically (live over SSH, no sync) — each row is labelled by host, this machine first. Use --local to skip the fan-out; --json and single-id lookups stay local.
3775
+ - The interactive listing and every live-status flag fold in your other online machines automatically (live over SSH, no sync) — each row is labelled by host, this machine first. Use --local to skip the fan-out; single-id lookups stay local.
3776
+ - --all is not a device flag: it widens historical directory and time filters. Fleet collection is already the default. A status flag (--working/--idle/--waiting/--orphan/--crashed/--closed/--abandoned/--queued/--unknown) implies --active; combine status flags for a union.
3707
3777
  - --host runs the query on the remote's own index over SSH (host alias or user@host); repeat or pass several to fan out. SSH access is the only auth.
3708
3778
  - --in-team matches both ends of the lineage: the session that ran 'agents teams create/add', and (with --teams) that team's teammates. In the interactive list, 't' cycles the same filter over the teams in view.
3709
3779
  - --include and --exclude are mutually exclusive.
@@ -3742,6 +3812,7 @@ export function registerSessionsCommands(program) {
3742
3812
  registerSessionsMigrationsCommand(sessionsCmd);
3743
3813
  registerSessionsBackfillCommand(sessionsCmd);
3744
3814
  registerSessionsStatsCommand(sessionsCmd);
3815
+ registerSessionsOptimizeCommand(sessionsCmd);
3745
3816
  // Observe-umbrella alias (Phase 3): roster → sessions --active.
3746
3817
  registerSessionsObserveAliases(program);
3747
3818
  }
@@ -30,6 +30,7 @@ Structure:
30
30
  plugins/ optional: plugin bundles scoped to this workflow
31
31
 
32
32
  Resolution: project > user > plugin (plugins/*/workflows/) > extra > system.
33
+ Pin a source with name@plugin or name@extra-alias (optional workflow: prefix).
33
34
 
34
35
  Note: agents run defaults to --mode plan (read-only). For workflows that
35
36
  write files, post comments, or otherwise mutate state, pass --mode edit or
@@ -8,7 +8,7 @@
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import { getProjectAgentsDir, getUserWorkflowsDir, getSystemWorkflowsDir, getEnabledExtraRepos, } from '../state.js';
11
- import { parseWorkflowFrontmatter, countWorkflowSubagents, listPluginWorkflowDirs, isBareWorkflowName, } from '../workflows.js';
11
+ import { parseWorkflowFrontmatter, countWorkflowSubagents, listPluginWorkflowDirs, resolveWorkflowRef, parseWorkflowRef, } from '../workflows.js';
12
12
  function getLayerDirs(cwd) {
13
13
  const projectDir = getProjectAgentsDir(cwd);
14
14
  const extraRepos = getEnabledExtraRepos();
@@ -47,6 +47,27 @@ function orderedWorkflowSearchDirs(cwd) {
47
47
  out.push({ dir: dirs.system, layer: 'system' });
48
48
  return out;
49
49
  }
50
+ /** Map a resolved absolute workflow dir to its origin layer (best-effort). */
51
+ function layerForWorkflowPath(workflowPath, cwd) {
52
+ const abs = path.resolve(workflowPath);
53
+ const under = (root) => {
54
+ const r = path.resolve(root);
55
+ return abs === r || abs.startsWith(r + path.sep);
56
+ };
57
+ const projectDir = getProjectAgentsDir(cwd);
58
+ if (projectDir && under(path.join(projectDir, 'workflows')))
59
+ return 'project';
60
+ if (under(getUserWorkflowsDir()))
61
+ return 'user';
62
+ if (under(getSystemWorkflowsDir()))
63
+ return 'system';
64
+ for (const extra of getEnabledExtraRepos()) {
65
+ if (under(path.join(extra.dir, 'workflows')))
66
+ return 'system';
67
+ }
68
+ // Plugin marketplaces (and anything else plugin-shaped).
69
+ return 'plugin';
70
+ }
50
71
  class WorkflowsHandlerImpl {
51
72
  kind = 'workflow';
52
73
  listAll(_agent, cwd) {
@@ -76,27 +97,26 @@ class WorkflowsHandlerImpl {
76
97
  return results.sort((a, b) => a.name.localeCompare(b.name));
77
98
  }
78
99
  resolve(_agent, name, cwd) {
79
- // Same bare-name gate as resolveWorkflowRef — never path-join traversal refs.
80
- if (!isBareWorkflowName(name))
100
+ // Delegate to resolveWorkflowRef so bare, workflow:, and name@source share one path.
101
+ const workflowPath = resolveWorkflowRef(name, cwd ?? process.cwd());
102
+ if (!workflowPath)
81
103
  return null;
82
- for (const { dir, layer } of orderedWorkflowSearchDirs(cwd)) {
83
- const workflowPath = path.join(dir, name);
84
- const fm = parseWorkflowFrontmatter(workflowPath);
85
- if (fm) {
86
- return {
87
- name,
88
- item: {
89
- name: fm.name || name,
90
- description: fm.description,
91
- model: fm.model,
92
- subagentCount: countWorkflowSubagents(workflowPath),
93
- },
94
- layer,
95
- path: workflowPath,
96
- };
97
- }
98
- }
99
- return null;
104
+ const fm = parseWorkflowFrontmatter(workflowPath);
105
+ if (!fm)
106
+ return null;
107
+ const parsed = parseWorkflowRef(name);
108
+ const displayName = parsed?.name ?? path.basename(workflowPath);
109
+ return {
110
+ name: displayName,
111
+ item: {
112
+ name: fm.name || displayName,
113
+ description: fm.description,
114
+ model: fm.model,
115
+ subagentCount: countWorkflowSubagents(workflowPath),
116
+ },
117
+ layer: layerForWorkflowPath(workflowPath, cwd),
118
+ path: workflowPath,
119
+ };
100
120
  }
101
121
  sync(_agent, _versionHome, _cwd) {
102
122
  // Version-home copies are written by syncResourcesToVersion in versions.ts.
@@ -120,6 +120,25 @@ export interface QueryOptions {
120
120
  export declare function getDB(): Database.Database;
121
121
  /** Close the cached database connection. */
122
122
  export declare function closeDB(): void;
123
+ export interface FtsOptimizeResult {
124
+ table: string;
125
+ segmentsBefore: number;
126
+ segmentsAfter: number;
127
+ }
128
+ /**
129
+ * Compact the session + tool-call full-text-search indexes.
130
+ *
131
+ * FTS5 appends a new segment on every insert and leaves a tombstone on every
132
+ * delete. The scanner delete+inserts a session's docs on each rescan (and on
133
+ * every extractor-version bump), and FTS5 never merges those segments on its
134
+ * own — so `tool_call_text_data` / `session_text_data` accumulate hundreds of
135
+ * thousands of unmerged segments, gigabytes of index for tens of MB of content,
136
+ * and `agents sessions` queries slow to a crawl. The FTS5 `'optimize'` command
137
+ * merges every segment into one and purges tombstones. Non-destructive: no
138
+ * searchable content is lost. Reclaimed space becomes reusable free pages inside
139
+ * the DB file; run VACUUM (with the daemon stopped) to return it to the OS.
140
+ */
141
+ export declare function optimizeSessionSearchIndex(): FtsOptimizeResult[];
123
142
  /**
124
143
  * Try to claim the right to run the incremental scan. Returns true if this
125
144
  * process should proceed with scanning, false if another live process is
@@ -820,6 +820,30 @@ export function closeDB() {
820
820
  cachedStmts = {};
821
821
  }
822
822
  }
823
+ /**
824
+ * Compact the session + tool-call full-text-search indexes.
825
+ *
826
+ * FTS5 appends a new segment on every insert and leaves a tombstone on every
827
+ * delete. The scanner delete+inserts a session's docs on each rescan (and on
828
+ * every extractor-version bump), and FTS5 never merges those segments on its
829
+ * own — so `tool_call_text_data` / `session_text_data` accumulate hundreds of
830
+ * thousands of unmerged segments, gigabytes of index for tens of MB of content,
831
+ * and `agents sessions` queries slow to a crawl. The FTS5 `'optimize'` command
832
+ * merges every segment into one and purges tombstones. Non-destructive: no
833
+ * searchable content is lost. Reclaimed space becomes reusable free pages inside
834
+ * the DB file; run VACUUM (with the daemon stopped) to return it to the OS.
835
+ */
836
+ export function optimizeSessionSearchIndex() {
837
+ const db = getDB();
838
+ // Hardcoded literals — never interpolate caller input into an identifier.
839
+ const tables = ['tool_call_text', 'session_text'];
840
+ const segments = (table) => db.prepare(`SELECT count(*) AS n FROM ${table}_data`).get().n;
841
+ return tables.map((table) => {
842
+ const segmentsBefore = segments(table);
843
+ db.prepare(`INSERT INTO ${table}(${table}) VALUES('optimize')`).run();
844
+ return { table, segmentsBefore, segmentsAfter: segments(table) };
845
+ });
846
+ }
823
847
  // ---------------------------------------------------------------------------
824
848
  // Scan coordinator — prevents concurrent full scans across processes
825
849
  // ---------------------------------------------------------------------------
@@ -318,19 +318,37 @@ export declare function grokWorkflowMarker(filePath: string): string | null;
318
318
  * runnable via `agents run <name>` without a separate install into
319
319
  * ~/.agents/workflows/ (Phase 5 packaging). Within the plugin band, project
320
320
  * plugins beat user/system plugins (same first-hit-wins as other layers).
321
+ *
322
+ * Pass `pluginName` to restrict to one plugin (for `name@plugin` resolution).
321
323
  */
322
- export declare function listPluginWorkflowDirs(cwd?: string): string[];
324
+ export declare function listPluginWorkflowDirs(cwd?: string, pluginName?: string): string[];
323
325
  /**
324
- * True when `ref` is a single bare workflow name (no path separators, no `..`).
325
- * Name lookup must not path-join multi-segment or traversal refs into search roots.
326
+ * True when `ref` is a single bare workflow / source identifier (no path
327
+ * separators, no `..`, no `@`). Name lookup must not path-join multi-segment
328
+ * or traversal refs into search roots. `name@source` is parsed separately.
326
329
  */
327
330
  export declare function isBareWorkflowName(ref: string): boolean;
331
+ /** Parsed `agents run` workflow reference (docs/07-entrypoints). */
332
+ export interface ParsedWorkflowRef {
333
+ /** Workflow directory name (WORKFLOW.md parent). */
334
+ name: string;
335
+ /**
336
+ * When set (`name@source`), pin resolution to that source only:
337
+ * a plugin name, or an enabled extra-repo alias.
338
+ */
339
+ source?: string;
340
+ }
341
+ /**
342
+ * Parse a workflow run target: optional `workflow:` type prefix and optional
343
+ * `@source` pin. Returns null when the form is not a valid name lookup.
344
+ */
345
+ export declare function parseWorkflowRef(ref: string): ParsedWorkflowRef | null;
328
346
  /**
329
347
  * Resolve an `agents run <workflow>` reference.
330
348
  *
331
349
  * Directories are accepted anywhere on disk when they contain WORKFLOW.md.
332
350
  * Name lookup precedence (docs/07-entrypoints): project > user > plugin > extra > system.
333
- * Bare name only; `name@plugin` disambiguation is a follow-up.
351
+ * Pin a source with `name@plugin` or `name@extra-alias` (optional `workflow:` prefix).
334
352
  */
335
353
  export declare function resolveWorkflowRef(ref: string, cwd?: string): string | null;
336
354
  /**
@@ -522,15 +522,8 @@ function resolveWorkflowPath(ref, cwd) {
522
522
  const candidate = path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded);
523
523
  return isWorkflowDir(candidate) ? candidate : null;
524
524
  }
525
- /**
526
- * Plugin `workflows/` directories in discovery order (project → user → system →
527
- * extra). Used by name resolution and listing so a plugin-packaged workflow is
528
- * runnable via `agents run <name>` without a separate install into
529
- * ~/.agents/workflows/ (Phase 5 packaging). Within the plugin band, project
530
- * plugins beat user/system plugins (same first-hit-wins as other layers).
531
- */
532
- export function listPluginWorkflowDirs(cwd = process.cwd()) {
533
- const pluginRoots = [];
525
+ /** Plugin-root marketplace dirs in project → user → system → extra order. */
526
+ function pluginMarketplaceDirs(cwd) {
534
527
  const pluginsDirs = [];
535
528
  const projectPlugins = getProjectPluginsDir(cwd);
536
529
  if (projectPlugins)
@@ -539,7 +532,34 @@ export function listPluginWorkflowDirs(cwd = process.cwd()) {
539
532
  for (const extra of getEnabledExtraRepos()) {
540
533
  pluginsDirs.push(path.join(extra.dir, 'plugins'));
541
534
  }
542
- for (const pluginsDir of pluginsDirs) {
535
+ return pluginsDirs;
536
+ }
537
+ function isPluginDirectory(pluginRoot, entry) {
538
+ if (entry.name.startsWith('.'))
539
+ return false;
540
+ let isDir = entry.isDirectory();
541
+ if (!isDir && entry.isSymbolicLink()) {
542
+ try {
543
+ isDir = fs.statSync(pluginRoot).isDirectory();
544
+ }
545
+ catch {
546
+ isDir = false;
547
+ }
548
+ }
549
+ return isDir;
550
+ }
551
+ /**
552
+ * Plugin `workflows/` directories in discovery order (project → user → system →
553
+ * extra). Used by name resolution and listing so a plugin-packaged workflow is
554
+ * runnable via `agents run <name>` without a separate install into
555
+ * ~/.agents/workflows/ (Phase 5 packaging). Within the plugin band, project
556
+ * plugins beat user/system plugins (same first-hit-wins as other layers).
557
+ *
558
+ * Pass `pluginName` to restrict to one plugin (for `name@plugin` resolution).
559
+ */
560
+ export function listPluginWorkflowDirs(cwd = process.cwd(), pluginName) {
561
+ const pluginRoots = [];
562
+ for (const pluginsDir of pluginMarketplaceDirs(cwd)) {
543
563
  if (!fs.existsSync(pluginsDir))
544
564
  continue;
545
565
  let entries;
@@ -550,20 +570,10 @@ export function listPluginWorkflowDirs(cwd = process.cwd()) {
550
570
  continue;
551
571
  }
552
572
  for (const entry of entries) {
553
- if (entry.name.startsWith('.'))
573
+ if (pluginName !== undefined && entry.name !== pluginName)
554
574
  continue;
555
- // Directories and symlinks-to-directories (plugin marketplaces often symlink).
556
575
  const pluginRoot = path.join(pluginsDir, entry.name);
557
- let isDir = entry.isDirectory();
558
- if (!isDir && entry.isSymbolicLink()) {
559
- try {
560
- isDir = fs.statSync(pluginRoot).isDirectory();
561
- }
562
- catch {
563
- isDir = false;
564
- }
565
- }
566
- if (!isDir)
576
+ if (!isPluginDirectory(pluginRoot, entry))
567
577
  continue;
568
578
  const workflowsDir = path.join(pluginRoot, 'workflows');
569
579
  if (fs.existsSync(workflowsDir))
@@ -573,8 +583,9 @@ export function listPluginWorkflowDirs(cwd = process.cwd()) {
573
583
  return pluginRoots;
574
584
  }
575
585
  /**
576
- * True when `ref` is a single bare workflow name (no path separators, no `..`).
577
- * Name lookup must not path-join multi-segment or traversal refs into search roots.
586
+ * True when `ref` is a single bare workflow / source identifier (no path
587
+ * separators, no `..`, no `@`). Name lookup must not path-join multi-segment
588
+ * or traversal refs into search roots. `name@source` is parsed separately.
578
589
  */
579
590
  export function isBareWorkflowName(ref) {
580
591
  if (!ref || ref === '.' || ref === '..')
@@ -583,26 +594,65 @@ export function isBareWorkflowName(ref) {
583
594
  return false;
584
595
  if (ref.includes('..'))
585
596
  return false;
597
+ if (ref.includes('@'))
598
+ return false;
586
599
  // Reject absolute paths (posix or Windows).
587
600
  if (path.isAbsolute(ref))
588
601
  return false;
589
602
  return path.basename(ref) === ref;
590
603
  }
604
+ /**
605
+ * Parse a workflow run target: optional `workflow:` type prefix and optional
606
+ * `@source` pin. Returns null when the form is not a valid name lookup.
607
+ */
608
+ export function parseWorkflowRef(ref) {
609
+ let r = ref.trim();
610
+ if (r.startsWith('workflow:'))
611
+ r = r.slice('workflow:'.length);
612
+ if (!r)
613
+ return null;
614
+ const at = r.lastIndexOf('@');
615
+ if (at > 0) {
616
+ const name = r.slice(0, at);
617
+ const source = r.slice(at + 1);
618
+ if (!isBareWorkflowName(name) || !isBareWorkflowName(source))
619
+ return null;
620
+ return { name, source };
621
+ }
622
+ if (!isBareWorkflowName(r))
623
+ return null;
624
+ return { name: r };
625
+ }
591
626
  /**
592
627
  * Resolve an `agents run <workflow>` reference.
593
628
  *
594
629
  * Directories are accepted anywhere on disk when they contain WORKFLOW.md.
595
630
  * Name lookup precedence (docs/07-entrypoints): project > user > plugin > extra > system.
596
- * Bare name only; `name@plugin` disambiguation is a follow-up.
631
+ * Pin a source with `name@plugin` or `name@extra-alias` (optional `workflow:` prefix).
597
632
  */
598
633
  export function resolveWorkflowRef(ref, cwd = process.cwd()) {
599
634
  const direct = resolveWorkflowPath(ref, cwd);
600
635
  if (direct)
601
636
  return direct;
602
- // Name lookup only — reject traversal / multi-segment so path.join(dir, ref)
603
- // cannot escape a workflows root (absolute paths already handled above).
604
- if (!isBareWorkflowName(ref))
637
+ const parsed = parseWorkflowRef(ref);
638
+ if (!parsed)
605
639
  return null;
640
+ // Source-qualified: only that plugin or extra-repo workflows/ (no layered fallback).
641
+ if (parsed.source) {
642
+ for (const dir of listPluginWorkflowDirs(cwd, parsed.source)) {
643
+ const workflowPath = path.join(dir, parsed.name);
644
+ if (isWorkflowDir(workflowPath))
645
+ return workflowPath;
646
+ }
647
+ for (const extra of getEnabledExtraRepos()) {
648
+ if (extra.alias !== parsed.source)
649
+ continue;
650
+ const workflowPath = path.join(extra.dir, 'workflows', parsed.name);
651
+ if (isWorkflowDir(workflowPath))
652
+ return workflowPath;
653
+ }
654
+ return null;
655
+ }
606
656
  const projectAgentsDir = getProjectAgentsDir(cwd);
607
657
  const searchDirs = [
608
658
  ...(projectAgentsDir ? [path.join(projectAgentsDir, 'workflows')] : []),
@@ -612,7 +662,7 @@ export function resolveWorkflowRef(ref, cwd = process.cwd()) {
612
662
  getSystemWorkflowsDir(),
613
663
  ];
614
664
  for (const dir of searchDirs) {
615
- const workflowPath = path.join(dir, ref);
665
+ const workflowPath = path.join(dir, parsed.name);
616
666
  if (isWorkflowDir(workflowPath))
617
667
  return workflowPath;
618
668
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.12",
3
+ "version": "1.22.14",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",