@phnx-labs/agents-cli 1.22.9 → 1.22.11

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 (47) hide show
  1. package/CHANGELOG.md +37 -4
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/beta.js +21 -9
  4. package/dist/commands/browser.js +2 -2
  5. package/dist/commands/events.d.ts +1 -1
  6. package/dist/commands/events.js +1 -1
  7. package/dist/commands/projects.d.ts +1 -2
  8. package/dist/commands/projects.js +39 -47
  9. package/dist/commands/secrets.js +38 -39
  10. package/dist/commands/ssh.js +4 -5
  11. package/dist/commands/workflows.js +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/lib/beta.d.ts +1 -1
  14. package/dist/lib/beta.js +1 -1
  15. package/dist/lib/event-stream.d.ts +1 -1
  16. package/dist/lib/event-stream.js +1 -1
  17. package/dist/lib/events.d.ts +1 -1
  18. package/dist/lib/events.js +3 -3
  19. package/dist/lib/feed-broadcast.d.ts +19 -5
  20. package/dist/lib/feed-broadcast.js +41 -8
  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.js +61 -0
  24. package/dist/lib/plugins.d.ts +14 -5
  25. package/dist/lib/plugins.js +29 -5
  26. package/dist/lib/project-probe.d.ts +1 -1
  27. package/dist/lib/project-probe.js +1 -1
  28. package/dist/lib/projects.d.ts +1 -1
  29. package/dist/lib/resources/types.d.ts +2 -1
  30. package/dist/lib/resources/workflows.d.ts +1 -1
  31. package/dist/lib/resources/workflows.js +22 -20
  32. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  33. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  34. package/dist/lib/secrets/audit.js +1 -1
  35. package/dist/lib/secrets/bundles.d.ts +9 -0
  36. package/dist/lib/secrets/bundles.js +9 -23
  37. package/dist/lib/secrets/headless.d.ts +3 -15
  38. package/dist/lib/secrets/headless.js +5 -33
  39. package/dist/lib/secrets/index.d.ts +8 -0
  40. package/dist/lib/secrets/index.js +16 -36
  41. package/dist/lib/share/config.js +5 -7
  42. package/dist/lib/state.d.ts +1 -1
  43. package/dist/lib/state.js +1 -1
  44. package/dist/lib/types.d.ts +7 -1
  45. package/dist/lib/workflows.d.ts +19 -4
  46. package/dist/lib/workflows.js +81 -7
  47. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Centralized event logging for agents-cli.
3
3
  *
4
- * Structured JSONL audit log at ~/.agents/events.jsonl with lossless numbered
4
+ * Structured JSONL audit log at ~/.agents/.history/events/events.jsonl with lossless numbered
5
5
  * gzip rotation at 10 MB and rich metadata for debugging/auditing.
6
6
  *
7
7
  * Features:
@@ -17,7 +17,7 @@ import * as os from 'os';
17
17
  import { createHash } from 'node:crypto';
18
18
  import { gzipSync, gunzipSync } from 'node:zlib';
19
19
  import { ensureLockTarget, withFileLock } from './fs-atomic.js';
20
- import { getUserAgentsDir } from './state.js';
20
+ import { getHistoryDir } from './state.js';
21
21
  import { stampProvenance, resetEventProvenanceForTest } from './event-provenance.js';
22
22
  /** Lazy perf warehouse write — avoids a hard cycle at module load. */
23
23
  function recordPerfTiming(payload) {
@@ -48,7 +48,7 @@ function recordPerfTiming(payload) {
48
48
  // a test spawns, so fixture events can never land in the user's real log (#910).
49
49
  let _eventsPath;
50
50
  function eventsPath() {
51
- return (_eventsPath ??= process.env.AGENTS_EVENTS_PATH || path.join(getUserAgentsDir(), 'events.jsonl'));
51
+ return (_eventsPath ??= process.env.AGENTS_EVENTS_PATH || path.join(getHistoryDir(), 'events', 'events.jsonl'));
52
52
  }
53
53
  function eventsDir() {
54
54
  return path.dirname(eventsPath());
@@ -48,8 +48,14 @@ export interface FeedBroadcastContext {
48
48
  class?: string;
49
49
  /** Block-only: cost-of-delay tag used by the urgency filter. */
50
50
  cost?: string;
51
- /** Block-only: the literal `agents focus <id>` command that unblocks it. */
51
+ /** Block-only: the literal `agents focus <id>` command (for a `{focus}` sink). */
52
52
  focus?: string;
53
+ /** Block-only: the answer choices the operator can pick, in order. */
54
+ options?: string[];
55
+ /** Block-only: the fallback applied if nobody answers in time. */
56
+ safeDefault?: string;
57
+ /** Block-only: minutes before `safeDefault` applies. */
58
+ timeoutMinutes?: number;
53
59
  }
54
60
  /**
55
61
  * Map an open block onto the broadcast context, so a block reaches the same sinks
@@ -69,9 +75,14 @@ export declare function blockBroadcastContext(block: {
69
75
  host?: string;
70
76
  questions?: Array<{
71
77
  text?: string;
78
+ options?: Array<{
79
+ label?: string;
80
+ }>;
72
81
  }>;
73
82
  blockClass?: string;
74
83
  costOfDelay?: string;
84
+ safeDefault?: string;
85
+ timeoutMinutes?: number;
75
86
  ticket?: string;
76
87
  pr?: string;
77
88
  }, extras?: {
@@ -137,14 +148,17 @@ export declare function composeBroadcastFooter(ctx: FeedBroadcastContext): strin
137
148
  * Title in a few words
138
149
  *
139
150
  * Body of what happened or the ask.
151
+ * Options: publish / wait (blocks with choices)
152
+ * Default in 15 min: wait (blocks with a safe default)
140
153
  *
141
154
  * Sent from grok/a02da0e2 on mac-mini
142
- * agents focus a02da0e2 (blocks only)
143
- * https://… (optional attach URL)
155
+ * https://… (optional attach URL)
144
156
  * ```
145
157
  *
146
- * Title first (scannable subject). Blank line. Body. Footer provenance so a
147
- * fleet of agents is attributable without crowding the ask. Prefer `{message}`
158
+ * Title first (scannable subject). Blank line. Body. Then the phone-actionable
159
+ * choices + default (a block that has stopped for the human), then footer
160
+ * provenance. No `agents focus <id>` line: a CLI command is unusable from a phone,
161
+ * so the safe default is the fallback and the message carries it. Prefer `{message}`
148
162
  * over bare `{text}` in messaging sinks.
149
163
  */
150
164
  export declare function composeBroadcastMessage(ctx: FeedBroadcastContext): string;
@@ -62,6 +62,12 @@ export function parseFeedPostLevel(raw) {
62
62
  export function blockBroadcastContext(block, extras = {}) {
63
63
  const ask = block.questions?.[0]?.text?.trim() || 'agent is blocked';
64
64
  const links = [block.pr].filter((l) => !!l && /^https?:\/\//i.test(l));
65
+ // The answer choices + the safe default are what make the phone message
66
+ // actionable: the operator sees the options and what happens if they do not
67
+ // reply, instead of a `agents focus <id>` CLI command they cannot run from a phone.
68
+ const options = (block.questions?.[0]?.options ?? [])
69
+ .map((o) => o?.label?.trim())
70
+ .filter((l) => !!l);
65
71
  // Prefer explicit title/body from the feed post; fall back to the ask as body.
66
72
  const title = extras.title?.trim() || undefined;
67
73
  const text = extras.body?.trim() || ask;
@@ -78,8 +84,12 @@ export function blockBroadcastContext(block, extras = {}) {
78
84
  class: block.blockClass,
79
85
  cost: block.costOfDelay,
80
86
  // Short id: `agents focus` matches on a prefix, and a full uuid in a phone
81
- // message is noise the operator has to skip past to reach the verb.
87
+ // message is noise. Kept for a `{focus}` sink; the human message no longer
88
+ // shows it (a CLI command is unusable from a phone).
82
89
  focus: `agents focus ${block.sessionId.slice(0, 8)}`,
90
+ ...(options.length ? { options } : {}),
91
+ ...(block.safeDefault ? { safeDefault: block.safeDefault } : {}),
92
+ ...(block.timeoutMinutes ? { timeoutMinutes: block.timeoutMinutes } : {}),
83
93
  ...(links.length ? { links } : {}),
84
94
  };
85
95
  }
@@ -179,14 +189,17 @@ export function composeBroadcastFooter(ctx) {
179
189
  * Title in a few words
180
190
  *
181
191
  * Body of what happened or the ask.
192
+ * Options: publish / wait (blocks with choices)
193
+ * Default in 15 min: wait (blocks with a safe default)
182
194
  *
183
195
  * Sent from grok/a02da0e2 on mac-mini
184
- * agents focus a02da0e2 (blocks only)
185
- * https://… (optional attach URL)
196
+ * https://… (optional attach URL)
186
197
  * ```
187
198
  *
188
- * Title first (scannable subject). Blank line. Body. Footer provenance so a
189
- * fleet of agents is attributable without crowding the ask. Prefer `{message}`
199
+ * Title first (scannable subject). Blank line. Body. Then the phone-actionable
200
+ * choices + default (a block that has stopped for the human), then footer
201
+ * provenance. No `agents focus <id>` line: a CLI command is unusable from a phone,
202
+ * so the safe default is the fallback and the message carries it. Prefer `{message}`
190
203
  * over bare `{text}` in messaging sinks.
191
204
  */
192
205
  export function composeBroadcastMessage(ctx) {
@@ -197,9 +210,21 @@ export function composeBroadcastMessage(ctx) {
197
210
  const mid = title && body && title !== body ? body : undefined;
198
211
  const footer = composeBroadcastFooter(ctx);
199
212
  const link = ctx.links?.find((l) => /^https?:\/\//i.test(l));
200
- // Block focus and link trail after the "Sent from" footer so the human
201
- // sentence stays at the top and the action/link are still one glance away.
202
- const trail = [footer, ctx.focus, link].filter(Boolean);
213
+ // The action block: the one thing the operator can act on from a phone. Show the
214
+ // choices, then what happens if they do not answer. Deliberately NOT a CLI command
215
+ // (`agents focus <id>` is unusable from a phone) -- the safe default is the real
216
+ // fallback, so a block meant for a phone should always carry one.
217
+ const choices = ctx.options?.length
218
+ ? `Options: ${ctx.options.map((o) => scrubOutboundDashes(o)).join(' / ')}`
219
+ : undefined;
220
+ const fallback = ctx.safeDefault
221
+ ? (ctx.timeoutMinutes && ctx.timeoutMinutes > 0
222
+ ? `Default in ${ctx.timeoutMinutes} min: ${scrubOutboundDashes(ctx.safeDefault)}`
223
+ : `Default: ${scrubOutboundDashes(ctx.safeDefault)}`)
224
+ : undefined;
225
+ const action = [choices, fallback].filter(Boolean).join('\n') || undefined;
226
+ // Link trail after the "Sent from" footer so the human sentence stays at the top.
227
+ const trail = [footer, link].filter(Boolean);
203
228
  const parts = [];
204
229
  if (head)
205
230
  parts.push(head);
@@ -208,6 +233,12 @@ export function composeBroadcastMessage(ctx) {
208
233
  parts.push('');
209
234
  parts.push(mid);
210
235
  }
236
+ if (action) {
237
+ // The choices/default hug the ask under a blank line so they read as the reply.
238
+ if (parts.length)
239
+ parts.push('');
240
+ parts.push(action);
241
+ }
211
242
  if (trail.length) {
212
243
  // Blank line before the footer block (iPhone "Sent from my iPhone" spacing).
213
244
  if (parts.length)
@@ -233,6 +264,8 @@ function templateVars(ctx) {
233
264
  class: ctx.class,
234
265
  cost: ctx.cost,
235
266
  focus: ctx.focus,
267
+ options: ctx.options?.length ? ctx.options.join(' / ') : undefined,
268
+ default: ctx.safeDefault,
236
269
  };
237
270
  }
238
271
  /**
@@ -998,6 +998,7 @@ function migrateRuntimeToHistory() {
998
998
  moveDirOnce(path.join(USER_DIR, '.backups'), path.join(HISTORY_DIR, 'backups'));
999
999
  moveDirOnce(path.join(USER_DIR, 'routines', 'runs'), path.join(HISTORY_DIR, 'runs'));
1000
1000
  moveDirOnce(path.join(USER_DIR, 'teams', 'agents'), path.join(HISTORY_DIR, 'teams', 'agents'));
1001
+ migrateEventLogsToHistory();
1001
1002
  // Drop any empty leftover skeletons created mid-rename (e.g. `versions/<agent>/<v>/home/`
1002
1003
  // recreated by a concurrent process). The real data is already under .history/.
1003
1004
  rmEmptyDirTree(path.join(USER_DIR, 'versions'));
@@ -1013,6 +1014,66 @@ function migrateRuntimeToHistory() {
1013
1014
  catch { /* best-effort */ }
1014
1015
  }
1015
1016
  }
1017
+ /** Move the operational event stream out of the git-backed user-repo root. */
1018
+ function migrateEventLogsToHistory() {
1019
+ const destination = path.join(HISTORY_DIR, 'events');
1020
+ let files = [];
1021
+ try {
1022
+ files = fs.readdirSync(USER_DIR).filter((file) => file === 'events.jsonl' || /^events\.\d+\.jsonl\.gz$/.test(file));
1023
+ }
1024
+ catch {
1025
+ return;
1026
+ }
1027
+ try {
1028
+ fs.mkdirSync(destination, { recursive: true, mode: 0o700 });
1029
+ }
1030
+ catch {
1031
+ return;
1032
+ }
1033
+ const active = files.find((file) => file === 'events.jsonl');
1034
+ if (active) {
1035
+ const src = path.join(USER_DIR, active);
1036
+ const dest = path.join(destination, active);
1037
+ if (!fs.existsSync(dest)) {
1038
+ moveFileOnce(src, dest);
1039
+ }
1040
+ else {
1041
+ try {
1042
+ const lines = [fs.readFileSync(src, 'utf-8'), fs.readFileSync(dest, 'utf-8')]
1043
+ .flatMap((content) => content.split('\n').filter(Boolean))
1044
+ .map((line, index) => {
1045
+ try {
1046
+ const timestamp = Date.parse(JSON.parse(line).ts ?? '');
1047
+ return { line, index, timestamp: Number.isNaN(timestamp) ? Number.MAX_SAFE_INTEGER : timestamp };
1048
+ }
1049
+ catch {
1050
+ return { line, index, timestamp: Number.MAX_SAFE_INTEGER };
1051
+ }
1052
+ })
1053
+ .sort((a, b) => a.timestamp - b.timestamp || a.index - b.index)
1054
+ .map(({ line }) => line);
1055
+ atomicWriteFileSync(dest, `${lines.join('\n')}\n`, { mode: 0o600 });
1056
+ fs.unlinkSync(src);
1057
+ }
1058
+ catch { /* preserve the source for a later retry */ }
1059
+ }
1060
+ }
1061
+ const archives = files
1062
+ .map((file) => ({ file, match: file.match(/^events\.(\d+)\.jsonl\.gz$/) }))
1063
+ .filter((entry) => entry.match !== null)
1064
+ .sort((a, b) => Number(a.match[1]) - Number(b.match[1]));
1065
+ let nextArchive = fs.readdirSync(destination).reduce((max, file) => {
1066
+ const match = file.match(/^events\.(\d+)\.jsonl\.gz$/);
1067
+ return match ? Math.max(max, Number(match[1])) : max;
1068
+ }, 0) + 1;
1069
+ for (const archive of archives) {
1070
+ const preferred = path.join(destination, archive.file);
1071
+ const dest = fs.existsSync(preferred)
1072
+ ? path.join(destination, `events.${nextArchive++}.jsonl.gz`)
1073
+ : preferred;
1074
+ moveFileOnce(path.join(USER_DIR, archive.file), dest);
1075
+ }
1076
+ }
1016
1077
  /**
1017
1078
  * Restore plugins from the cache bucket back to the user-root.
1018
1079
  *
@@ -2,11 +2,12 @@
2
2
  * Plugin discovery, validation, and syncing.
3
3
  *
4
4
  * Plugins are bundles in ~/.agents/plugins/ that package skills, hooks,
5
- * commands, agents, bin scripts, MCP servers, and settings under a single
6
- * manifest (plugin.json). They are user-authored resources, sitting alongside
7
- * skills/, commands/, hooks/, etc. — git-tracked as source of truth. This
8
- * module discovers plugins, validates their manifests, and syncs their
9
- * contents into agent version homes.
5
+ * commands, agents, workflows, bin scripts, MCP servers, and settings under a
6
+ * single manifest (plugin.json). They are user-authored resources, sitting
7
+ * alongside skills/, commands/, hooks/, etc. — git-tracked as source of truth.
8
+ * This module discovers plugins, validates their manifests, and syncs their
9
+ * contents into agent version homes. Workflows under a plugin’s workflows/
10
+ * are resolved at run time by resolveWorkflowRef (Phase 5 packaging).
10
11
  */
11
12
  import type { AgentId, DiscoveredPlugin, PluginManifest, MarketplaceSpec } from './types.js';
12
13
  export interface PluginCapabilities {
@@ -95,6 +96,14 @@ export declare function discoverPluginHooks(pluginRoot: string): string[];
95
96
  export declare function discoverPluginCommands(pluginRoot: string): string[];
96
97
  /** Discover agent definition .md files inside a plugin's agents/ directory. */
97
98
  export declare function discoverPluginAgentDefs(pluginRoot: string): string[];
99
+ /**
100
+ * Discover workflow directories inside a plugin's `workflows/` folder.
101
+ * A valid workflow is a directory containing WORKFLOW.md (same contract as
102
+ * project/user/system workflows). Phase 5: plugins package workflows as
103
+ * entrypoints so `agents run <name>` can resolve them without a separate
104
+ * install into ~/.agents/workflows/.
105
+ */
106
+ export declare function discoverPluginWorkflows(pluginRoot: string): string[];
98
107
  /** Discover executable files in a plugin's bin/ directory. */
99
108
  export declare function discoverPluginBin(pluginRoot: string): string[];
100
109
  /** Discover MCP server names from .mcp.json at the plugin root. */
@@ -2,11 +2,12 @@
2
2
  * Plugin discovery, validation, and syncing.
3
3
  *
4
4
  * Plugins are bundles in ~/.agents/plugins/ that package skills, hooks,
5
- * commands, agents, bin scripts, MCP servers, and settings under a single
6
- * manifest (plugin.json). They are user-authored resources, sitting alongside
7
- * skills/, commands/, hooks/, etc. — git-tracked as source of truth. This
8
- * module discovers plugins, validates their manifests, and syncs their
9
- * contents into agent version homes.
5
+ * commands, agents, workflows, bin scripts, MCP servers, and settings under a
6
+ * single manifest (plugin.json). They are user-authored resources, sitting
7
+ * alongside skills/, commands/, hooks/, etc. — git-tracked as source of truth.
8
+ * This module discovers plugins, validates their manifests, and syncs their
9
+ * contents into agent version homes. Workflows under a plugin’s workflows/
10
+ * are resolved at run time by resolveWorkflowRef (Phase 5 packaging).
10
11
  */
11
12
  import * as fs from 'fs';
12
13
  import * as path from 'path';
@@ -107,6 +108,7 @@ export function buildDiscoveredPlugin(pluginRoot, manifest, spec = { kind: 'user
107
108
  scripts: discoverPluginScripts(pluginRoot),
108
109
  commands: discoverPluginCommands(pluginRoot),
109
110
  agentDefs: discoverPluginAgentDefs(pluginRoot),
111
+ workflows: discoverPluginWorkflows(pluginRoot),
110
112
  memory: discoverPluginMemory(pluginRoot),
111
113
  bin: discoverPluginBin(pluginRoot),
112
114
  mcpServers: discoverPluginMcpServers(pluginRoot),
@@ -131,6 +133,7 @@ export function pluginResourceGroups(plugin) {
131
133
  { label: 'skills', items: plugin.skills.map((s) => `/${plugin.name}:${s}`) },
132
134
  { label: 'commands', items: plugin.commands.map((c) => `/${plugin.name}:${c}`) },
133
135
  { label: 'subagents', items: plugin.agentDefs },
136
+ { label: 'workflows', items: plugin.workflows },
134
137
  { label: 'hooks', items: plugin.hooks },
135
138
  { label: 'memory', items: plugin.memory },
136
139
  { label: 'mcp', items: plugin.mcpServers },
@@ -334,6 +337,27 @@ export function discoverPluginAgentDefs(pluginRoot) {
334
337
  .filter(f => f.endsWith('.md') && !f.startsWith('.'))
335
338
  .map(f => f.slice(0, -3));
336
339
  }
340
+ /**
341
+ * Discover workflow directories inside a plugin's `workflows/` folder.
342
+ * A valid workflow is a directory containing WORKFLOW.md (same contract as
343
+ * project/user/system workflows). Phase 5: plugins package workflows as
344
+ * entrypoints so `agents run <name>` can resolve them without a separate
345
+ * install into ~/.agents/workflows/.
346
+ */
347
+ export function discoverPluginWorkflows(pluginRoot) {
348
+ const workflowsDir = path.join(pluginRoot, 'workflows');
349
+ if (!fs.existsSync(workflowsDir))
350
+ return [];
351
+ try {
352
+ return fs.readdirSync(workflowsDir, { withFileTypes: true })
353
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') &&
354
+ fs.existsSync(path.join(workflowsDir, e.name, 'WORKFLOW.md')))
355
+ .map((e) => e.name);
356
+ }
357
+ catch {
358
+ return [];
359
+ }
360
+ }
337
361
  /** Discover executable files in a plugin's bin/ directory. */
338
362
  export function discoverPluginBin(pluginRoot) {
339
363
  const binDir = path.join(pluginRoot, 'bin');
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Project workspace probing — the drift signal behind `projects status --fleet`.
2
+ * Project workspace probing — the drift signal behind `projects status`.
3
3
  *
4
4
  * Projects are natively multi-device: the same definition (home-relative paths)
5
5
  * re-roots on every fleet machine, and the question is whether the project's
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Project workspace probing — the drift signal behind `projects status --fleet`.
2
+ * Project workspace probing — the drift signal behind `projects status`.
3
3
  *
4
4
  * Projects are natively multi-device: the same definition (home-relative paths)
5
5
  * re-roots on every fleet machine, and the question is whether the project's
@@ -36,7 +36,7 @@ export interface ProjectRepo {
36
36
  /**
37
37
  * Optional home-relative local checkout of this repo. The def's `root` only
38
38
  * knows the primary repo on disk; `path` opts an additional repo into
39
- * workspace probing (`projects status --fleet`).
39
+ * workspace probing (`projects status`).
40
40
  */
41
41
  path?: string;
42
42
  }
@@ -6,7 +6,8 @@
6
6
  * - Override on name conflict: Higher layer wins (project > user > system)
7
7
  */
8
8
  export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'copilot' | 'kiro' | 'goose' | 'antigravity' | 'grok' | 'kimi' | 'droid' | 'hermes' | 'pi';
9
- export type Layer = 'system' | 'user' | 'project';
9
+ /** Resource origin. Precedence (highest first): project > user > plugin > system. */
10
+ export type Layer = 'system' | 'user' | 'project' | 'plugin';
10
11
  export type ResourceKind = 'command' | 'hook' | 'skill' | 'rule' | 'mcp' | 'permission' | 'subagent' | 'workflow' | 'memory';
11
12
  /** A resolved resource with its origin layer. */
12
13
  export interface ResolvedItem<T> {
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Workflows are directory bundles with a WORKFLOW.md containing YAML frontmatter.
5
5
  * They optionally contain subagents/, skills/, and plugins/ subdirectories.
6
- * Resolution order: project > user > system.
6
+ * Resolution order (docs/07-entrypoints): project > user > plugin > extra > system.
7
7
  */
8
8
  import type { AgentId, ResolvedItem, ResourceHandler } from './types.js';
9
9
  export interface WorkflowItem {
@@ -3,12 +3,12 @@
3
3
  *
4
4
  * Workflows are directory bundles with a WORKFLOW.md containing YAML frontmatter.
5
5
  * They optionally contain subagents/, skills/, and plugins/ subdirectories.
6
- * Resolution order: project > user > system.
6
+ * Resolution order (docs/07-entrypoints): project > user > plugin > extra > system.
7
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 } from '../workflows.js';
11
+ import { parseWorkflowFrontmatter, countWorkflowSubagents, listPluginWorkflowDirs, isBareWorkflowName, } from '../workflows.js';
12
12
  function getLayerDirs(cwd) {
13
13
  const projectDir = getProjectAgentsDir(cwd);
14
14
  const extraRepos = getEnabledExtraRepos();
@@ -32,20 +32,27 @@ function listWorkflowsInDir(dir) {
32
32
  return [];
33
33
  }
34
34
  }
35
+ /** Precedence-ordered (dir, layer) pairs for name lookup / listing. */
36
+ function orderedWorkflowSearchDirs(cwd) {
37
+ const dirs = getLayerDirs(cwd);
38
+ const out = [];
39
+ if (dirs.project)
40
+ out.push({ dir: dirs.project, layer: 'project' });
41
+ out.push({ dir: dirs.user, layer: 'user' });
42
+ for (const pluginDir of listPluginWorkflowDirs(cwd ?? process.cwd())) {
43
+ out.push({ dir: pluginDir, layer: 'plugin' });
44
+ }
45
+ for (const extraDir of dirs.extra)
46
+ out.push({ dir: extraDir, layer: 'system' });
47
+ out.push({ dir: dirs.system, layer: 'system' });
48
+ return out;
49
+ }
35
50
  class WorkflowsHandlerImpl {
36
51
  kind = 'workflow';
37
52
  listAll(_agent, cwd) {
38
- const dirs = getLayerDirs(cwd);
39
53
  const seen = new Set();
40
54
  const results = [];
41
- const layerDirs = [];
42
- if (dirs.project)
43
- layerDirs.push({ dir: dirs.project, layer: 'project' });
44
- layerDirs.push({ dir: dirs.user, layer: 'user' });
45
- layerDirs.push({ dir: dirs.system, layer: 'system' });
46
- for (const extraDir of dirs.extra)
47
- layerDirs.push({ dir: extraDir, layer: 'system' });
48
- for (const { dir, layer } of layerDirs) {
55
+ for (const { dir, layer } of orderedWorkflowSearchDirs(cwd)) {
49
56
  for (const { name, path: workflowPath } of listWorkflowsInDir(dir)) {
50
57
  if (seen.has(name))
51
58
  continue;
@@ -69,15 +76,10 @@ class WorkflowsHandlerImpl {
69
76
  return results.sort((a, b) => a.name.localeCompare(b.name));
70
77
  }
71
78
  resolve(_agent, name, cwd) {
72
- const dirs = getLayerDirs(cwd);
73
- const searchDirs = [];
74
- if (dirs.project)
75
- searchDirs.push({ dir: dirs.project, layer: 'project' });
76
- searchDirs.push({ dir: dirs.user, layer: 'user' });
77
- searchDirs.push({ dir: dirs.system, layer: 'system' });
78
- for (const extraDir of dirs.extra)
79
- searchDirs.push({ dir: extraDir, layer: 'system' });
80
- for (const { dir, layer } of searchDirs) {
79
+ // Same bare-name gate as resolveWorkflowRef — never path-join traversal refs.
80
+ if (!isBareWorkflowName(name))
81
+ return null;
82
+ for (const { dir, layer } of orderedWorkflowSearchDirs(cwd)) {
81
83
  const workflowPath = path.join(dir, name);
82
84
  const fm = parseWorkflowFrontmatter(workflowPath);
83
85
  if (fm) {
@@ -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/events.jsonl` audit log — carries a uniform,
7
+ * by the append-only `~/.agents/.history/events/events.jsonl` 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
@@ -23,6 +23,8 @@
23
23
  import { type BundleValue, type SecretRef } from './index.js';
24
24
  /** Which store carries a bundle's items. */
25
25
  export type SecretsBackend = 'keychain' | 'file' | 'vault';
26
+ /** Disable the broker-only guard for in-memory keychain tests. */
27
+ export declare function setKeychainAgentOnlyBypassForTest(bypass: boolean): void;
26
28
  /**
27
29
  * Discover a bundle's backend by location: a file-backed bundle's metadata
28
30
  * item exists in the encrypted-file store. This is a plain file-existence
@@ -184,6 +186,13 @@ export declare function deleteBundle(name: string): boolean;
184
186
  * extra keychain read is issued. Exported for tests. Returns the count healed.
185
187
  */
186
188
  export declare function healKeychainBundleMetadata(metaJsonByName: Map<string, string>): number;
189
+ /**
190
+ * One-time driver around healKeychainBundleMetadata (RUSH-1759). macOS + real
191
+ * keychain only — libsecret/CredMan have no biometry ACL to shed, and a test
192
+ * backend has no real keychain — and gated by a sentinel so it runs at most
193
+ * once. Best-effort: a heal failure never breaks bundle listing.
194
+ */
195
+ export declare function healKeychainBundleMetadataAclOnce(metaJsonByName: Map<string, string>): void;
187
196
  export declare function listBundles(): SecretsBundle[];
188
197
  export interface BundleEntryInfo {
189
198
  key: string;
@@ -86,6 +86,11 @@ const vaultStore = {
86
86
  delete: vaultDeleteItem,
87
87
  list: vaultListItems,
88
88
  };
89
+ let keychainAgentOnlyBypassForTest = false;
90
+ /** Disable the broker-only guard for in-memory keychain tests. */
91
+ export function setKeychainAgentOnlyBypassForTest(bypass) {
92
+ keychainAgentOnlyBypassForTest = bypass;
93
+ }
89
94
  function itemStore(backend) {
90
95
  if (backend === 'file')
91
96
  return fileItemStore;
@@ -606,7 +611,7 @@ export function healKeychainBundleMetadata(metaJsonByName) {
606
611
  * backend has no real keychain — and gated by a sentinel so it runs at most
607
612
  * once. Best-effort: a heal failure never breaks bundle listing.
608
613
  */
609
- function healKeychainBundleMetadataAclOnce(metaJsonByName) {
614
+ export function healKeychainBundleMetadataAclOnce(metaJsonByName) {
610
615
  if (metaJsonByName.size === 0)
611
616
  return;
612
617
  if (process.platform !== 'darwin')
@@ -684,7 +689,6 @@ export function listBundles() {
684
689
  // still prompt once; it heals on the next interactive scan.)
685
690
  const fetched = getKeychainTokens(keychainServices, { silentNoAcl: true });
686
691
  const keychainBundles = [];
687
- const metaJsonByName = new Map();
688
692
  for (const service of keychainServices) {
689
693
  const json = fetched.get(service);
690
694
  if (json === undefined)
@@ -695,7 +699,6 @@ export function listBundles() {
695
699
  const bundle = parseBundleMeta(nameHint, json, 'keychain');
696
700
  if (bundle) {
697
701
  keychainBundles.push(bundle);
698
- metaJsonByName.set(bundle.name, json);
699
702
  }
700
703
  }
701
704
  for (const bundle of keychainBundles)
@@ -707,13 +710,6 @@ export function listBundles() {
707
710
  if (useAgent && keychainBundles.length > 0) {
708
711
  agentAutoLoadMetaSync(nameSetHash, keychainBundles, secretsHoldMs());
709
712
  }
710
- // One-time RUSH-1759 heal: bundles written before the metadata-no-ACL
711
- // change carry an ACL'd metadata item, so this fresh read (a broker miss)
712
- // popped Touch ID just to enumerate them. Re-home each metadata item
713
- // no-ACL now — reusing the JSON we just read, so the heal adds no extra
714
- // prompt — and every later enumeration is silent. Runs at most once (a
715
- // sentinel under the regenerable helpers dir).
716
- healKeychainBundleMetadataAclOnce(metaJsonByName);
717
713
  }
718
714
  }
719
715
  }
@@ -1099,24 +1095,14 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1099
1095
  return filtered;
1100
1096
  }
1101
1097
  }
1102
- // Never/no-ACL bundles remain prompt-free regardless. No agent launch — harness,
1103
- // teammate, routine, or the always-on daemon — may raise the sheet itself.
1104
- // Explicit opt-in ONLY — a deliberate NARROWING of the agent-triggered approval
1105
- // added in RUSH-2032 (b99796f8 removed this throw so an agent could raise the
1106
- // sheet itself; 4eeada68 generalized the daemon rule into `!interactiveUnlock`).
1107
- // That default — true whenever an agent name was present — was the spec, not a
1108
- // bug. It is unwanted: each keychain read runs in its own helper process, so the
1109
- // biometric assertion never reuses and one agent launch meant one sheet per
1110
- // bundle. `agentOnly` decides alone now; a human in a plain shell carries no
1111
- // AGENTS_RUNTIME, so isHeadlessSecretsContext() is false, agentOnly is false, the
1112
- // guard never fires, and they still get their prompt. No caller passes this flag;
1113
- // it remains the seam for a future unlock path that wants the sheet on purpose.
1098
+ // Never/no-ACL bundles remain prompt-free regardless. Every ordinary caller
1099
+ // sets agentOnly; only the unlock handler opts into interactive authentication.
1114
1100
  const interactiveUnlock = opts.interactiveUnlock ?? false;
1115
1101
  // A `never`-policy bundle's items carry no biometry ACL, so once the policy
1116
1102
  // check below proves that, the batch read is silent even in a headless
1117
1103
  // context — attest it to the raw-read storm guard via `silentNoAcl`.
1118
1104
  let verifiedNoAclBundle = false;
1119
- if (opts.agentOnly && backend === 'keychain' && !interactiveUnlock) {
1105
+ if (opts.agentOnly && backend === 'keychain' && !interactiveUnlock && !keychainAgentOnlyBypassForTest) {
1120
1106
  try {
1121
1107
  verifiedNoAclBundle = bundlePolicy(readBundle(name)) === 'never';
1122
1108
  }
@@ -5,22 +5,13 @@
5
5
  * importing bundles.ts (which already imports index.ts).
6
6
  */
7
7
  /**
8
- * True when the current process is a background / non-interactive context that
9
- * must NEVER raise a Keychain biometry prompt on the interactive user's screen —
10
- * a prompt nobody is watching. Two signals, either sufficient:
8
+ * True when the current process was structurally launched by an agent runtime
9
+ * that must NEVER raise a Keychain biometry prompt on the user's screen:
11
10
  * - `AGENTS_RUNTIME` is `headless`, `teams`, or `terminal` — i.e. ANY agent
12
11
  * launch, interactive included, and inherited by everything spawned beneath
13
12
  * one (set on the child env by `agents run --headless`, scheduled routines,
14
13
  * teammates, and interactive runs — see exec.ts:430, runner.ts,
15
14
  * teams/agents.ts).
16
- * - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
17
- * stdio is redirected to a log — e.g. a release script run in the
18
- * background as `( ... ) >log 2>&1 </dev/null`).
19
- * `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
20
- * even in a non-TTY context. An `eval "$(agents secrets export X)"` typed in a
21
- * PLAIN shell has no AGENTS_RUNTIME, so it is not classified headless and still
22
- * prompts. Run beneath an agent it inherits AGENTS_RUNTIME and resolves
23
- * broker-only — the agent, not the human, is the caller there.
24
15
  *
25
16
  * Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
26
17
  * broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
@@ -33,7 +24,4 @@
33
24
  * fast with an actionable error instead of hijacking Touch ID. This generalizes
34
25
  * the per-caller broker-only pattern used across the headless secrets readers.
35
26
  */
36
- export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, tty?: {
37
- stdin?: boolean;
38
- stdout?: boolean;
39
- }): boolean;
27
+ export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;