@phnx-labs/agents-cli 1.22.11 → 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.
Files changed (38) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +5 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/cli.js +12 -12
  5. package/dist/commands/events.d.ts +20 -1
  6. package/dist/commands/events.js +71 -55
  7. package/dist/commands/logs.js +14 -138
  8. package/dist/commands/repo.d.ts +1 -1
  9. package/dist/commands/repo.js +3 -3
  10. package/dist/commands/sessions.d.ts +15 -0
  11. package/dist/commands/sessions.js +79 -10
  12. package/dist/commands/view.d.ts +1 -1
  13. package/dist/commands/view.js +7 -7
  14. package/dist/commands/workflows.js +1 -0
  15. package/dist/lib/cli-resources.js +2 -2
  16. package/dist/lib/event-stream.d.ts +1 -1
  17. package/dist/lib/event-stream.js +1 -1
  18. package/dist/lib/events.d.ts +21 -12
  19. package/dist/lib/events.js +312 -65
  20. package/dist/lib/exec.js +1 -2
  21. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  22. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  23. package/dist/lib/migrate.d.ts +8 -0
  24. package/dist/lib/migrate.js +26 -62
  25. package/dist/lib/resources/workflows.js +41 -21
  26. package/dist/lib/resources.d.ts +1 -1
  27. package/dist/lib/runner.js +1 -2
  28. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  29. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  30. package/dist/lib/secrets/audit.js +1 -1
  31. package/dist/lib/startup/command-registry.js +1 -1
  32. package/dist/lib/state.d.ts +1 -1
  33. package/dist/lib/state.js +60 -2
  34. package/dist/lib/watchdog/runner.d.ts +13 -0
  35. package/dist/lib/watchdog/runner.js +75 -6
  36. package/dist/lib/workflows.d.ts +22 -4
  37. package/dist/lib/workflows.js +79 -29
  38. package/package.json +1 -1
@@ -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.
@@ -5,7 +5,7 @@
5
5
  import type { AgentId } from './types.js';
6
6
  import { type SkillParseError } from './skills.js';
7
7
  /** Resource kind — matches the subdirectory name under each repo root. */
8
- export type ResourceKind = 'commands' | 'skills' | 'hooks' | 'rules' | 'mcp' | 'cli' | 'permissions' | 'subagents' | 'workflows' | 'profiles' | 'secrets';
8
+ export type ResourceKind = 'commands' | 'skills' | 'hooks' | 'rules' | 'mcp' | 'clis' | 'permissions' | 'subagents' | 'workflows' | 'profiles' | 'secrets';
9
9
  /** A resource resolved with its origin. */
10
10
  export interface ResolvedResource {
11
11
  name: string;
@@ -21,7 +21,7 @@ import { resolveJobPrompt, parseTimeout, writeRunMeta, listRuns, getJobRunsDir,
21
21
  import { getRunsDir } from './state.js';
22
22
  import { prepareJobHome, buildSpawnEnv, getJobHomePath } from './sandbox.js';
23
23
  import { resolveModel, buildReasoningFlags } from './models.js';
24
- import { createTimer, maybeRotate, redactPrompt } from './events.js';
24
+ import { createTimer, redactPrompt } from './events.js';
25
25
  import { normalizeMode, resolveHeadlessMode, buildExecEnv, detectRateLimit, detectAuthFailure, isAuthFailureFromLog, authFailureReason, } from './exec.js';
26
26
  import { resolveActor } from './actor.js';
27
27
  import { loadTask as loadHostTask } from './hosts/tasks.js';
@@ -691,7 +691,6 @@ export async function executeJob(config, deps) {
691
691
  if (config.command) {
692
692
  return executeCommandJobForeground(config);
693
693
  }
694
- maybeRotate();
695
694
  const launch = await resolveRoutineLaunch(config);
696
695
  const primaryVersion = launch.chain[0]?.version ?? config.version;
697
696
  const timer = createTimer('agent.run', {
@@ -4,7 +4,7 @@
4
4
  * This is the ONE write path for secret events. Every path that creates,
5
5
  * imports, exports, views, reads a VALUE from, or unlocks a bundle funnels its
6
6
  * audit through here, so the operational event stream — `agents events`, backed
7
- * by the append-only `~/.agents/.history/events/events.jsonl` audit log — carries a uniform,
7
+ * by the dated append-only `~/.agents/.history/events/` audit log — carries a uniform,
8
8
  * value-free provenance record: bundle, key NAMES, the resolving agent/harness
9
9
  * identity, operation, source, status. The ts / host / session / caller fields
10
10
  * are filled in by `emit()` itself. The secret VALUE is never part of the
@@ -124,7 +124,7 @@ export const COMMAND_LOADERS = {
124
124
  memory: [loadMemory],
125
125
  permissions: [loadPermissions],
126
126
  mcp: [loadMcp],
127
- cli: [loadCli],
127
+ clis: [loadCli],
128
128
  subagents: [loadSubagents],
129
129
  plugins: [loadPlugins],
130
130
  workflows: [loadWorkflows],
@@ -113,7 +113,7 @@ export declare function getUserSecretsDir(): string;
113
113
  * value-free usage telemetry (which bundle was created/imported/exported/viewed/
114
114
  * accessed/unlocked, when, by whom), never a secret value. It is a derived index
115
115
  * fed FROM the emitSecretAudit chokepoint alongside the append-only
116
- * ~/.agents/.history/events/events.jsonl audit log — the same way sessions.db indexes session
116
+ * ~/.agents/.history/events/YYYY-MM-DD audit log — the same way sessions.db indexes session
117
117
  * metadata off the real session flow — not a second write path.
118
118
  */
119
119
  export declare function getSecretsDbPath(): string;
package/dist/lib/state.js CHANGED
@@ -372,7 +372,7 @@ export function getUserSecretsDir() { return USER_SECRETS_DIR; }
372
372
  * value-free usage telemetry (which bundle was created/imported/exported/viewed/
373
373
  * accessed/unlocked, when, by whom), never a secret value. It is a derived index
374
374
  * fed FROM the emitSecretAudit chokepoint alongside the append-only
375
- * ~/.agents/.history/events/events.jsonl audit log — the same way sessions.db indexes session
375
+ * ~/.agents/.history/events/YYYY-MM-DD audit log — the same way sessions.db indexes session
376
376
  * metadata off the real session flow — not a second write path.
377
377
  */
378
378
  export function getSecretsDbPath() {
@@ -794,6 +794,59 @@ function writeIfChanged(filePath, content) {
794
794
  * All callers funnel through writeMeta → here, so nothing else changes. Empty
795
795
  * `agents:` / `versions:` are not written (no empty committed files).
796
796
  */
797
+ /**
798
+ * Every `Meta` key, classified as `central` (synced via agents.yaml) or `device`
799
+ * (routed to the per-machine device file by {@link writeMetaUnlocked}). The
800
+ * `Record<keyof Meta, …>` type makes this EXHAUSTIVE: adding a field to `Meta`
801
+ * without classifying it here is a compile error. That guarantee is what lets
802
+ * `serializeCentral` delete only keys THIS version models — a key a newer CLI
803
+ * version added (absent from this map) is preserved verbatim instead of being
804
+ * dropped and synced fleet-wide as data loss. This is the fix for the recurring
805
+ * agents.yaml key-loss (beta flags, `notify.owner`, and `feed:` all vanished
806
+ * this way when an older-versioned CLI on the fleet rewrote the file).
807
+ */
808
+ const META_KEY_SCOPE = {
809
+ // Device-local — writeMetaUnlocked destructures these out; never in central.
810
+ agents: 'device',
811
+ isolatedAgents: 'device',
812
+ versions: 'device',
813
+ defaultBrowserProfile: 'device',
814
+ deviceConfig: 'device',
815
+ // Central — synced via agents.yaml.
816
+ run: 'central',
817
+ model: 'central',
818
+ watchdog: 'central',
819
+ lease: 'central',
820
+ secrets: 'central',
821
+ budget: 'central',
822
+ feed: 'central',
823
+ beta: 'central',
824
+ registries: 'central',
825
+ profiles: 'central',
826
+ source: 'central',
827
+ projectRoot: 'central',
828
+ extraRepos: 'central',
829
+ brands: 'central',
830
+ actors: 'central',
831
+ seededPresets: 'central',
832
+ hooks: 'central',
833
+ browser: 'central',
834
+ config: 'central',
835
+ hosts: 'central',
836
+ fleet: 'central',
837
+ share: 'central',
838
+ notify: 'central',
839
+ routines: 'central',
840
+ };
841
+ /**
842
+ * Every key this version models (central + device). serializeCentral deletes an
843
+ * on-disk key only when it is KNOWN and absent from the write's in-memory object:
844
+ * a central key the caller cleared, OR a device key that is legacy cruft in the
845
+ * synced file (device keys are routed to the per-machine file, so one lingering
846
+ * in central is stale and must be migrated out). A key NOT listed here — e.g. one
847
+ * a newer CLI version added — is preserved verbatim, never dropped + synced away.
848
+ */
849
+ const KNOWN_META_KEYS = new Set(Object.keys(META_KEY_SCOPE));
797
850
  /**
798
851
  * Serialize the central (synced) meta to `agents.yaml` WITHOUT destroying the
799
852
  * hand-written comments in the committed file.
@@ -834,7 +887,12 @@ function serializeCentral(central) {
834
887
  }
835
888
  }
836
889
  for (const k of Object.keys(current)) {
837
- if (!(k in central)) {
890
+ // Only delete a key THIS version knows about. A key not in KNOWN_META_KEYS
891
+ // (e.g. one a newer CLI version added) is preserved verbatim — deleting it
892
+ // here would drop it and sync the deletion fleet-wide (the agents.yaml
893
+ // config data-loss bug). A known device key lingering in the synced file is
894
+ // still removed — it belongs in the per-machine file, not here.
895
+ if (!(k in central) && KNOWN_META_KEYS.has(k)) {
838
896
  doc.delete(k);
839
897
  changed = true;
840
898
  }
@@ -97,6 +97,11 @@ export interface WatchdogTickOptions {
97
97
  dryRun?: boolean;
98
98
  enter?: boolean;
99
99
  }) => Promise<InjectResult>;
100
+ /**
101
+ * Publish a declared block on the owner's feed. Default publishBlock() — tests
102
+ * inject a collector so no real feed dir is touched.
103
+ */
104
+ publishBlockFn?: (block: OpenBlock) => void;
100
105
  /** Override the canonical watchdog.log path (tests point at a tmp file). */
101
106
  logPath?: string;
102
107
  /**
@@ -195,6 +200,14 @@ export interface NudgeDecision {
195
200
  nudge: boolean;
196
201
  reason: string;
197
202
  text?: string;
203
+ /**
204
+ * Set to `true` when the brain (smart decider) explicitly concluded the session
205
+ * needs the human — as opposed to a cheap deterministic skip (session is done,
206
+ * not stalled, etc.). Only `true` when the brain escalation path ran and returned
207
+ * `nudge: false`. The watchdog uses this to gate the self-file reminder: a
208
+ * "session is done" skip does NOT trigger a reminder.
209
+ */
210
+ needsHuman?: boolean;
198
211
  }
199
212
  /**
200
213
  * The smart brain. Resolves a `watchdog` workflow for the session's cwd
@@ -44,7 +44,7 @@ import { withFileLock, atomicWriteFileSync, ensureLockTarget } from '../fs-atomi
44
44
  import { resolveAnswerRoute, isOpenQuestionBlock } from '../answer-router.js';
45
45
  import { enqueue, mailboxDir } from '../mailbox.js';
46
46
  import { mailboxIdForActiveSession } from '../mailbox-target.js';
47
- import { readBlock, blockIdForSession } from '../feed.js';
47
+ import { readBlock, blockIdForSession, buildDeclaredBlock, publishBlock } from '../feed.js';
48
48
  import { summarizeWatchdogTail } from './watchdogTail.js';
49
49
  import { appendWatchdogEvents } from './log.js';
50
50
  import { buildRotateLaunchCommand, buildRotateReplayText, classifyTailForRotate, defaultRotateGate, defaultTuiLiveFor, exitSequenceFor, isInflightPhase, isWatchdogRotateEnabled, listInflightRotates, readRotateState, recordRotateSkip, shouldLogRotateSkip, writeRotateState, DEFAULT_ROTATE_READINESS_MS, DEFAULT_ROTATE_SKIP_COOLDOWN_MS, DEFAULT_ROTATE_FAILED_COOLDOWN_MS, } from './rotate.js';
@@ -353,6 +353,7 @@ export async function runWatchdogTick(opts = {}) {
353
353
  const smartDecider = opts.smartDecider ?? makeDefaultSmartDecider(opts.smartAgent ?? 'claude');
354
354
  const openBlockFor = opts.openBlockFor ?? defaultOpenBlockFor;
355
355
  const injectFn = opts.injectFn ?? injectIntoTerminal;
356
+ const publishBlockFn = opts.publishBlockFn ?? publishBlock;
356
357
  // Rotate seams (watchdog/rotate.ts). The config is read fresh per tick
357
358
  // (readMeta is mtime-cached), so a `watchdog.rotate` flip is honored on the
358
359
  // next pass. The default readiness probe (defaultTuiLiveFor) treats the
@@ -645,13 +646,21 @@ export async function runWatchdogTick(opts = {}) {
645
646
  // forces every stalled candidate through the brain.
646
647
  let decision;
647
648
  if (opts.smart) {
648
- decision = await smartDecider(session, candidate);
649
+ const d = await smartDecider(session, candidate);
650
+ // Mark needsHuman when the brain explicitly concluded "leave for human".
651
+ decision = d.nudge ? d : { ...d, needsHuman: true };
649
652
  }
650
653
  else {
651
654
  const det = deterministicDecision(session, candidate);
652
- decision = det.kind === 'escalate'
653
- ? await smartDecider(session, candidate)
654
- : { nudge: det.kind === 'nudge', reason: det.reason };
655
+ if (det.kind === 'escalate') {
656
+ const d = await smartDecider(session, candidate);
657
+ // Mark needsHuman when the brain escalation path concluded "leave for human".
658
+ decision = d.nudge ? d : { ...d, needsHuman: true };
659
+ }
660
+ else {
661
+ // Cheap deterministic path — completion or clear stall. Never "needs human".
662
+ decision = { nudge: det.kind === 'nudge', reason: det.reason };
663
+ }
655
664
  }
656
665
  const chosenText = decision.text ?? nudgeText;
657
666
  // Log the decision for the Factory watchdog card.
@@ -670,6 +679,53 @@ export async function runWatchdogTick(opts = {}) {
670
679
  lastAssistantMessage: summary.lastAssistantMessage,
671
680
  });
672
681
  if (!decision.nudge) {
682
+ // Brain explicitly concluded "leave for human" (needsHuman === true). Inject a
683
+ // self-file reminder into the agent's terminal so it knows to post a feed block.
684
+ // Cheap deterministic skips (session done, no stall) are NOT reminder-worthy —
685
+ // guard on needsHuman so a finished session is never poked.
686
+ // Gated by the same cooldown as a nudge to prevent re-firing every 2-minute tick.
687
+ if (decision.needsHuman) {
688
+ const lastNudgeMs = ledger[session.sessionId ?? ''] ?? 0;
689
+ const cooldownMs = thresholds.cooldownMs;
690
+ const withinCooldown = nowMs - lastNudgeMs < cooldownMs;
691
+ if (opts.nudge && session.sessionId && !withinCooldown) {
692
+ const existingBlock = openBlockFor(session);
693
+ if (existingBlock === null) {
694
+ // Determine addressability without planning the full nudge (no text needed).
695
+ const resolution = resolveInjectTargetForSession(session, { allowGhosttyFocus: opts.allowGhosttyFocus });
696
+ if (resolution.addressable) {
697
+ // Addressable → inject a reminder asking the agent to self-file a feed block.
698
+ const reminderText = 'You appear stuck. If you genuinely need Muqsit, file it: ' +
699
+ 'agents feed post "<one-line ask>" --blocked --default "<safe default>". ' +
700
+ 'Otherwise keep going.';
701
+ try {
702
+ await injectFn(resolution.target, reminderText, { dryRun: opts.injectDryRun });
703
+ }
704
+ catch {
705
+ // Swallow inject errors — reminder is best-effort; flag set below.
706
+ }
707
+ ledgerUpdates[session.sessionId] = nowMs;
708
+ }
709
+ else {
710
+ // Un-addressable → we can't reach the terminal to remind it, so the ONLY
711
+ // way to reach Muqsit is to file a declared block on the agent's behalf.
712
+ // This is the most important case: the session genuinely needs the human
713
+ // AND the watchdog can't even nudge it. Never let it silently vanish.
714
+ const mailboxId = mailboxIdForActiveSession(session) ?? session.sessionId;
715
+ const machineHost = session.provenance?.host ?? 'unknown';
716
+ const runtime = session.kind;
717
+ try {
718
+ const declaredBlock = buildDeclaredBlock({ sessionId: session.sessionId, mailboxId, host: machineHost, runtime, cwd: session.cwd }, { text: `Session genuinely needs Muqsit and is un-addressable — ${decision.reason}. Needs attention.` });
719
+ publishBlockFn(declaredBlock);
720
+ ledgerUpdates[session.sessionId] = nowMs;
721
+ }
722
+ catch {
723
+ // publishBlock failure is non-fatal — best-effort owner page.
724
+ }
725
+ }
726
+ }
727
+ }
728
+ }
673
729
  outcomes.push({ ...base, decision: 'skip', reason: decision.reason });
674
730
  continue;
675
731
  }
@@ -682,6 +738,12 @@ export async function runWatchdogTick(opts = {}) {
682
738
  const addressable = plan.via === 'inject' ? true : undefined;
683
739
  if (plan.via === 'refuse') {
684
740
  // No addressable rail and not headless-resumable — flag, NEVER guess.
741
+ // This branch is reached ONLY for a nudge-worthy (decision.nudge === true)
742
+ // drive-forward poke, which is NEVER needsHuman (needsHuman is set only when
743
+ // decision.nudge === false). So we do NOT page the owner here — a short "just
744
+ // needs a poke" stall must not text Muqsit's phone. Owner-paging for an
745
+ // un-addressable session happens only on the confirmed needsHuman skip path
746
+ // above. Here we only flag it for the tray.
685
747
  flags[session.sessionId] = { reason: plan.reason, host: session.host, atMs: nowMs };
686
748
  outcomes.push({
687
749
  ...base, decision: 'skip', addressable: false,
@@ -690,7 +752,14 @@ export async function runWatchdogTick(opts = {}) {
690
752
  });
691
753
  continue;
692
754
  }
693
- // handsoff = detect + flag, but never deliver.
755
+ // handsoff = detect + flag, but never deliver via inject/mailbox.
756
+ // This branch is reached ONLY for a nudge-worthy (decision.nudge === true)
757
+ // drive-forward poke, which is NEVER needsHuman. A hands-off policy means "don't
758
+ // nudge it forward" — it must NOT translate into paging Muqsit for a poke. So we
759
+ // only flag it for the tray, no owner page. A genuinely needs-human session (even
760
+ // under hands-off) is paged by the confirmed-needsHuman skip path above, which
761
+ // does not consult policy — hands-off silences the forward nudge, not the
762
+ // "it's actually stuck" signal.
694
763
  if (policy === 'handsoff') {
695
764
  flags[session.sessionId] = {
696
765
  reason: `handsoff: would nudge via ${viaLabel(plan)} but policy is hands-off`,
@@ -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.11",
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",