@phnx-labs/agents-cli 1.22.12 → 1.22.13

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.13
4
+
5
+ - **`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`.
6
+
7
+ - **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`.
8
+
3
9
  ## 1.22.12
4
10
 
5
11
  - 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
@@ -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
@@ -1091,9 +1091,12 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
1091
1091
  const merged = opts.favoritesOnly
1092
1092
  ? gathered.sessions.filter((s) => !!s.sessionId && listFavorites().has(s.sessionId))
1093
1093
  : 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;
1094
+ // Status flags form a union. --waiting additionally retains its scriptable
1095
+ // gate: exit non-zero when the union contains a session awaiting the user.
1096
+ const statusFiltered = opts.statuses?.length
1097
+ ? merged.filter((session) => opts.statuses.some((status) => matchesLiveStatus(session, status)))
1098
+ : merged;
1099
+ const sessions = statusFiltered;
1097
1100
  if (asJson) {
1098
1101
  // Resolve who is watching each local tmux pane before serializing: `viewingIn`
1099
1102
  // is how a consumer distinguishes a session someone is looking at from one
@@ -1101,7 +1104,7 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
1101
1104
  // scriptable path stays cheap — see enrichTmuxLocators.
1102
1105
  await enrichTmuxLocators(sessions.filter(s => !s.machine || s.machine === self));
1103
1106
  process.stdout.write(JSON.stringify(serializeActiveSessionsForJson(sessions), null, 2) + '\n');
1104
- if (waitingOnly && sessions.length > 0)
1107
+ if (waitingOnly && sessions.some(isAwaitingUser))
1105
1108
  process.exitCode = 1;
1106
1109
  return;
1107
1110
  }
@@ -1136,9 +1139,40 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
1136
1139
  if (!opts.local && !opts.hosts?.length && remoteDeviceCount === 0)
1137
1140
  printCrossMachineTip();
1138
1141
  // Scriptable gate: a non-zero exit when anything is waiting on the user.
1139
- if (waitingOnly && sessions.length > 0)
1142
+ if (waitingOnly && sessions.some(isAwaitingUser))
1140
1143
  process.exitCode = 1;
1141
1144
  }
1145
+ /** Match the status words users see, preserving activity's richer working signal. */
1146
+ export function matchesLiveStatus(session, status) {
1147
+ if (status === 'working')
1148
+ return session.activity === 'working' || (!session.activity && session.status === 'running');
1149
+ if (status === 'waiting')
1150
+ return isAwaitingUser(session);
1151
+ return session.status === status;
1152
+ }
1153
+ /** Resolve convenience flags once. Multiple flags intentionally form a union. */
1154
+ export function requestedLiveStatuses(options) {
1155
+ const statuses = [];
1156
+ if (options.working)
1157
+ statuses.push('working');
1158
+ if (options.idle)
1159
+ statuses.push('idle');
1160
+ if (options.waiting)
1161
+ statuses.push('waiting');
1162
+ if (options.orphan || options.orphaned)
1163
+ statuses.push('orphaned');
1164
+ if (options.crashed)
1165
+ statuses.push('crashed');
1166
+ if (options.closed)
1167
+ statuses.push('closed');
1168
+ if (options.abandoned)
1169
+ statuses.push('abandoned');
1170
+ if (options.queued)
1171
+ statuses.push('queued');
1172
+ if (options.unknown)
1173
+ statuses.push('unknown');
1174
+ return [...new Set(statuses)];
1175
+ }
1142
1176
  /** Nudge shown when `--active` has no other machines to fold in. */
1143
1177
  function printCrossMachineTip() {
1144
1178
  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 +1238,22 @@ function canonicalSessionsCommand(query, options) {
1204
1238
  const a = ['sessions'];
1205
1239
  if (options.active)
1206
1240
  a.push('--active');
1241
+ if (options.working)
1242
+ a.push('--working');
1243
+ if (options.idle)
1244
+ a.push('--idle');
1245
+ if (options.orphan || options.orphaned)
1246
+ a.push('--orphan');
1247
+ if (options.crashed)
1248
+ a.push('--crashed');
1249
+ if (options.closed)
1250
+ a.push('--closed');
1251
+ if (options.abandoned)
1252
+ a.push('--abandoned');
1253
+ if (options.queued)
1254
+ a.push('--queued');
1255
+ if (options.unknown)
1256
+ a.push('--unknown');
1207
1257
  if (options.teams)
1208
1258
  a.push('--teams');
1209
1259
  if (options.inTeam)
@@ -1421,6 +1471,8 @@ async function sessionsAction(query, options,
1421
1471
  */
1422
1472
  limitSource) {
1423
1473
  const queryClauses = options.query ?? [];
1474
+ const liveStatuses = requestedLiveStatuses(options);
1475
+ const liveOnly = options.active === true || liveStatuses.length > 0;
1424
1476
  const toolOnly = options.include?.split(',').map((role) => role.trim()).filter(Boolean).join(',') === 'tools';
1425
1477
  const toolEvidenceMode = toolOnly;
1426
1478
  if (options.count && !toolOnly) {
@@ -1551,7 +1603,7 @@ limitSource) {
1551
1603
  // keeps the legacy per-host stream (each remote's raw stdout under a
1552
1604
  // `── host ──` banner). With --active, the hosts are folded into the merged
1553
1605
  // machine-grouped view instead (handled below).
1554
- if (options.host && options.host.length > 0 && !options.active && !toolEvidenceMode) {
1606
+ if (options.host && options.host.length > 0 && !liveOnly && !toolEvidenceMode) {
1555
1607
  // --local means "skip the SSH fan-out"; --host means "look only over there".
1556
1608
  // Together they ask for a peer's sessions without dialing the peer, which can
1557
1609
  // only ever be empty — so say that instead of rendering a blank list.
@@ -1591,7 +1643,7 @@ limitSource) {
1591
1643
  await renderSessionPreview(query, { agent: options.agent, project: options.project, local: options.local });
1592
1644
  return;
1593
1645
  }
1594
- if (options.active) {
1646
+ if (liveOnly) {
1595
1647
  // The running view is built from the live scan, which carries no team lineage
1596
1648
  // (that comes off the transcript index and the teams meta dir), so --in-team
1597
1649
  // has nothing to match on here. Say so rather than ignoring the flag.
@@ -1606,7 +1658,7 @@ limitSource) {
1606
1658
  // --since seeds the window; --until / --project (no browser field) or a
1607
1659
  // multi-host scope fall through to the static dump that already honors them.
1608
1660
  if (useInteractiveBrowser(options) &&
1609
- !options.waiting &&
1661
+ liveStatuses.length === 0 &&
1610
1662
  !options.until &&
1611
1663
  !options.project &&
1612
1664
  !options.sort &&
@@ -1630,6 +1682,7 @@ limitSource) {
1630
1682
  local: forceLocal,
1631
1683
  hosts: options.host,
1632
1684
  favoritesOnly: options.favorites === true,
1685
+ statuses: liveStatuses,
1633
1686
  });
1634
1687
  return;
1635
1688
  }
@@ -3617,7 +3670,16 @@ export function registerSessionsCommands(program) {
3617
3670
  .option('--active', 'Show only sessions running right now across terminals, teams, cloud, and headless agents')
3618
3671
  .option('--roots', 'With --json: emit the on-disk directories scanned for session transcripts, per agent (for external watchers)')
3619
3672
  .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)')
3673
+ .option('--working', 'Show live sessions currently doing work (implies --active)')
3674
+ .option('--idle', 'Show live sessions that have stopped between turns (implies --active)')
3675
+ .option('--waiting', 'Show live sessions waiting on your input; exits non-zero if any (implies --active)')
3676
+ .option('--orphan', 'Show live sessions whose process outlived its terminal client (implies --active)')
3677
+ .option('--orphaned', 'Alias for --orphan')
3678
+ .option('--crashed', 'Show sessions whose terminal disappeared with the process (implies --active)')
3679
+ .option('--closed', 'Show recently observed sessions whose process exited normally (implies --active)')
3680
+ .option('--abandoned', 'Show sessions with no transcript progress for the abandonment window (implies --active)')
3681
+ .option('--queued', 'Show queued sessions that have not started running (implies --active)')
3682
+ .option('--unknown', 'Show sessions whose live state cannot be determined (implies --active)')
3621
3683
  .option('--favorites', 'Show only favorited (starred) sessions — star them with `*` in the browser or `agents sessions favorite <id>`')
3622
3684
  .option('--tree', 'Group the listing by directory; drops the id/version columns for readability')
3623
3685
  .option('--flat', 'Plain flat table (one row per session) instead of the grouped project overview')
@@ -3645,6 +3707,12 @@ export function registerSessionsCommands(program) {
3645
3707
  # Show only what's running right now (terminals, teams, cloud, headless)
3646
3708
  agents sessions --active
3647
3709
 
3710
+ # Filter the live fleet by the status word shown in the roster
3711
+ agents sessions --working
3712
+ agents sessions --idle
3713
+ agents sessions --orphan
3714
+ agents sessions --crashed
3715
+
3648
3716
  # --- Session lifecycle (one verb per intent) ---
3649
3717
  # Jump to a live session (attach its terminal, or open a tab + resume)
3650
3718
  agents sessions focus a1b2c3d4
@@ -3703,7 +3771,8 @@ export function registerSessionsCommands(program) {
3703
3771
  detach <id> interactive → headless continuation
3704
3772
  attach <id> headless → interactive in this terminal
3705
3773
  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.
3774
+ - 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.
3775
+ - --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
3776
  - --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
3777
  - --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
3778
  - --include and --exclude are mutually exclusive.
@@ -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.
@@ -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.13",
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",