@phnx-labs/agents-cli 1.20.16 → 1.20.18

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 (75) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +1 -1
  3. package/dist/commands/budget.d.ts +14 -0
  4. package/dist/commands/budget.js +137 -0
  5. package/dist/commands/cost.d.ts +12 -0
  6. package/dist/commands/cost.js +139 -0
  7. package/dist/commands/exec.d.ts +20 -0
  8. package/dist/commands/exec.js +382 -5
  9. package/dist/commands/secrets.d.ts +15 -0
  10. package/dist/commands/secrets.js +250 -4
  11. package/dist/commands/sessions.js +4 -0
  12. package/dist/commands/sync.d.ts +10 -3
  13. package/dist/commands/sync.js +72 -9
  14. package/dist/index.js +4 -0
  15. package/dist/lib/budget/config.d.ts +9 -0
  16. package/dist/lib/budget/config.js +115 -0
  17. package/dist/lib/budget/enforce.d.ts +94 -0
  18. package/dist/lib/budget/enforce.js +151 -0
  19. package/dist/lib/budget/ledger.d.ts +61 -0
  20. package/dist/lib/budget/ledger.js +107 -0
  21. package/dist/lib/budget/preflight.d.ts +110 -0
  22. package/dist/lib/budget/preflight.js +200 -0
  23. package/dist/lib/checkpoint.d.ts +54 -0
  24. package/dist/lib/checkpoint.js +56 -0
  25. package/dist/lib/cloud/rush.js +18 -0
  26. package/dist/lib/exec.d.ts +36 -0
  27. package/dist/lib/exec.js +192 -4
  28. package/dist/lib/git.d.ts +18 -0
  29. package/dist/lib/git.js +67 -4
  30. package/dist/lib/hooks.js +12 -0
  31. package/dist/lib/loop.d.ts +145 -0
  32. package/dist/lib/loop.js +330 -0
  33. package/dist/lib/mcp.d.ts +7 -0
  34. package/dist/lib/mcp.js +24 -0
  35. package/dist/lib/models.d.ts +11 -0
  36. package/dist/lib/models.js +21 -0
  37. package/dist/lib/plugin-marketplace.js +16 -6
  38. package/dist/lib/plugins.js +5 -2
  39. package/dist/lib/pricing/cost.d.ts +46 -0
  40. package/dist/lib/pricing/cost.js +71 -0
  41. package/dist/lib/pricing/index.d.ts +8 -0
  42. package/dist/lib/pricing/index.js +8 -0
  43. package/dist/lib/pricing/prices.json +138 -0
  44. package/dist/lib/pricing/table.d.ts +17 -0
  45. package/dist/lib/pricing/table.js +73 -0
  46. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  47. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  48. package/dist/lib/secrets/agent.d.ts +134 -0
  49. package/dist/lib/secrets/agent.js +501 -0
  50. package/dist/lib/secrets/bundles.d.ts +21 -0
  51. package/dist/lib/secrets/bundles.js +43 -0
  52. package/dist/lib/secrets/drivers/rush.d.ts +14 -0
  53. package/dist/lib/secrets/drivers/rush.js +84 -0
  54. package/dist/lib/secrets/linux.js +88 -10
  55. package/dist/lib/secrets/sync-backend.d.ts +48 -0
  56. package/dist/lib/secrets/sync-backend.js +13 -0
  57. package/dist/lib/secrets/sync.d.ts +15 -23
  58. package/dist/lib/secrets/sync.js +31 -66
  59. package/dist/lib/session/db.d.ts +40 -0
  60. package/dist/lib/session/db.js +84 -2
  61. package/dist/lib/session/discover.d.ts +2 -0
  62. package/dist/lib/session/discover.js +126 -2
  63. package/dist/lib/session/render.d.ts +2 -0
  64. package/dist/lib/session/render.js +1 -1
  65. package/dist/lib/session/types.d.ts +4 -0
  66. package/dist/lib/sync-umbrella.d.ts +76 -0
  67. package/dist/lib/sync-umbrella.js +125 -0
  68. package/dist/lib/teams/agents.d.ts +32 -0
  69. package/dist/lib/teams/agents.js +66 -3
  70. package/dist/lib/teams/api.js +20 -0
  71. package/dist/lib/teams/parsers.js +16 -4
  72. package/dist/lib/types.d.ts +48 -0
  73. package/dist/lib/workflows.d.ts +56 -0
  74. package/dist/lib/workflows.js +72 -5
  75. package/package.json +2 -1
@@ -7,9 +7,11 @@
7
7
  */
8
8
  import chalk from 'chalk';
9
9
  import { setHelpSections } from '../lib/help.js';
10
+ import { parseLoopInterval } from '../lib/loop.js';
10
11
  import { AGENTS } from '../lib/agents.js';
11
12
  import * as fs from 'fs';
12
13
  import * as path from 'path';
14
+ import * as os from 'os';
13
15
  /** Type guard that narrows a string to a known AgentId. */
14
16
  function isValidAgent(agent) {
15
17
  return agent in AGENTS;
@@ -21,6 +23,83 @@ function formatRotationBanner(result, verb = 'balanced') {
21
23
  const ratio = `${healthy.length} of ${healthy.length + excluded.length} healthy`;
22
24
  return `[agents] ${verb} picked ${label} (${ratio})`;
23
25
  }
26
+ /**
27
+ * Build the LoopConfig the driver consumes from CLI flags and/or a workflow's
28
+ * `loop:` frontmatter block (issue #332). Returns undefined when neither source
29
+ * activates a loop (the common single-shot run). CLI flags take precedence over
30
+ * the workflow's declared values field-by-field, so `--max-iterations 5`
31
+ * overrides a workflow's `max_iterations: 3`.
32
+ *
33
+ * `--loop` with no sub-options is a valid bare loop (driver applies its own
34
+ * maxIterations safety cap). A workflow `loop:` block activates a loop even
35
+ * without `--loop` so `agents run <workflow>` honors a declared loop.
36
+ */
37
+ export function buildLoopConfig(flags, workflowLoop) {
38
+ const active = flags.loop === true || workflowLoop !== undefined;
39
+ if (!active)
40
+ return undefined;
41
+ const cfg = {};
42
+ // until: CLI > workflow. Only `signal` is supported.
43
+ const until = flags.until ?? workflowLoop?.until;
44
+ if (until !== undefined) {
45
+ if (until !== 'signal') {
46
+ throw new Error(`Invalid --until '${until}'. Only 'signal' is supported.`);
47
+ }
48
+ cfg.until = 'signal';
49
+ }
50
+ // max_iterations: CLI > workflow.
51
+ if (flags.maxIterations !== undefined) {
52
+ const n = Number(flags.maxIterations);
53
+ if (!Number.isInteger(n) || n <= 0) {
54
+ throw new Error(`Invalid --max-iterations '${flags.maxIterations}'. Use a positive integer.`);
55
+ }
56
+ cfg.maxIterations = n;
57
+ }
58
+ else if (workflowLoop?.max_iterations !== undefined) {
59
+ cfg.maxIterations = workflowLoop.max_iterations;
60
+ }
61
+ // budget (tokens): CLI > workflow.
62
+ if (flags.budget !== undefined) {
63
+ const b = Number(flags.budget);
64
+ if (!Number.isFinite(b) || b <= 0) {
65
+ throw new Error(`Invalid --budget '${flags.budget}'. Use a positive token count.`);
66
+ }
67
+ cfg.budget = b;
68
+ }
69
+ else if (workflowLoop?.budget !== undefined) {
70
+ cfg.budget = workflowLoop.budget;
71
+ }
72
+ // interval: CLI > workflow. Validate eagerly — an unparseable interval
73
+ // (e.g. "30s", "5", "abc") must be rejected here, not silently coalesced to
74
+ // 0ms (back-to-back) at run time. "0" is the one accepted non-duration value.
75
+ const interval = flags.interval ?? workflowLoop?.interval;
76
+ if (interval !== undefined) {
77
+ try {
78
+ parseLoopInterval(interval);
79
+ }
80
+ catch {
81
+ throw new Error(`Invalid --interval '${interval}'. Use "0" for back-to-back or a duration like "30m", "1h", "2h30m" (units: w/d/h/m).`);
82
+ }
83
+ cfg.interval = interval;
84
+ }
85
+ return cfg;
86
+ }
87
+ /** Map a loop stop reason to a process exit code. condition-met/max are clean exits. */
88
+ export function loopExitCode(stoppedBy) {
89
+ switch (stoppedBy) {
90
+ case 'condition-met':
91
+ case 'max':
92
+ return 0;
93
+ case 'budget':
94
+ return 7; // mirrors BUDGET_KILL_EXIT_CODE so CI can tell a budget stop apart
95
+ case 'signal':
96
+ return 130; // 128 + SIGINT(2)
97
+ case 'stalled':
98
+ case 'error':
99
+ default:
100
+ return 1;
101
+ }
102
+ }
24
103
  /** Register the `agents run <agent> [prompt]` command. */
25
104
  export function registerRunCommand(program) {
26
105
  const runCmd = program
@@ -44,7 +123,14 @@ export function registerRunCommand(program) {
44
123
  .option('--fallback <agents>', 'Comma-separated agents to try on rate-limit failure. Each entry accepts an optional @version pin (e.g., codex@0.116.0,gemini). The primary runs first; if it exits with a rate-limit error, the next agent picks up via /continue handoff.')
45
124
  .option('-b, --balanced', 'Shortcut for --strategy balanced. Ignored when @version is pinned.')
46
125
  .option('--strategy <strategy>', 'Version/account selection strategy: pinned | available | balanced. Defaults to run.<agent>.strategy, then pinned. (Legacy `rotate` accepted as alias for `balanced`.)')
47
- .option('--acp', 'Route through the Agent Client Protocol instead of direct exec. Supported for gemini, claude (via @zed-industries/claude-code-acp adapter). Unified event stream; emits ndjson when --json.');
126
+ .option('--acp', 'Route through the Agent Client Protocol instead of direct exec. Supported for gemini, claude (via @zed-industries/claude-code-acp adapter). Unified event stream; emits ndjson when --json.')
127
+ .option('-y, --yes', 'Skip the interactive budget-confirm prompt (require_confirm_over). Never skips a hard budget block.', false)
128
+ .option('--loop', 'Re-inject the prompt/entrypoint each iteration until a stop condition (issue #332). Guards (--max-iterations, --budget, --until) are enforced outside the agent. Writes a checkpoint after every iteration for --resume-checkpoint.')
129
+ .option('--resume-checkpoint <file>', 'Resume a killed loop run from its checkpoint.json. Continues from the last completed iteration, reusing the same runId, session id, prompt, and loop config.')
130
+ .option('--max-iterations <n>', 'Loop hard cap: stop after N iterations (stoppedBy: max). Loop only.')
131
+ .option('--budget <tokens>', 'Loop token hard-cap: stop once cumulative tokens reach this (stoppedBy: budget), enforced outside the agent. Loop only.')
132
+ .option('--until <signal>', 'Loop stop condition. `signal` reads <runDir>/loop-signal.json {continue,reason} each iteration; absent or continue:false stops (fail-closed). Loop only.')
133
+ .option('--interval <dur>', 'Loop delay between iterations ("0" back-to-back, "30m" paces). Loop only.');
48
134
  setHelpSections(runCmd, {
49
135
  examples: `
50
136
  # Headless, read-only: investigate or summarize without writing files
@@ -85,7 +171,85 @@ export function registerRunCommand(program) {
85
171
  `,
86
172
  });
87
173
  runCmd.action(async (agentSpec, prompt, options) => {
88
- const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, defaultModeFor, headlessPlanStallCommand }, { ALL_AGENT_IDS }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, RUN_STRATEGIES }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef }, { resolveRunDefaults },] = await Promise.all([
174
+ // --resume-checkpoint short-circuits normal dispatch entirely: the
175
+ // checkpoint already carries the agent, version, prompt, session id,
176
+ // iteration, and loop config of the killed run. Reconstruct ExecOptions
177
+ // straight from it and continue the loop from the last completed
178
+ // iteration, reusing the SAME runId/runDir (issue #332).
179
+ if (options.resumeCheckpoint) {
180
+ const { readCheckpoint } = await import('../lib/checkpoint.js');
181
+ const { runLoop } = await import('../lib/loop.js');
182
+ const { getRunsDir } = await import('../lib/state.js');
183
+ const cp = readCheckpoint(options.resumeCheckpoint);
184
+ if (!cp) {
185
+ console.error(chalk.red(`Checkpoint not found or unreadable: ${options.resumeCheckpoint}`));
186
+ process.exit(1);
187
+ }
188
+ const runDir = path.join(getRunsDir(), cp.id);
189
+ fs.mkdirSync(runDir, { recursive: true });
190
+ const resumeExec = {
191
+ agent: cp.agent,
192
+ version: cp.version,
193
+ prompt: cp.prompt,
194
+ mode: options.mode,
195
+ effort: options.effort,
196
+ cwd: options.cwd,
197
+ sessionId: cp.sessionId,
198
+ json: true,
199
+ headless: true,
200
+ };
201
+ // Resume honors the checkpoint's loop config, but lets the resume
202
+ // command RAISE the bounds field-by-field — `--max-iterations 4` on a
203
+ // checkpoint capped at 2 is the natural "continue, run more" gesture.
204
+ // Flags override; unspecified fields fall through from the checkpoint.
205
+ const resumeLoop = { ...cp.loop };
206
+ if (options.maxIterations !== undefined) {
207
+ const n = Number(options.maxIterations);
208
+ if (!Number.isInteger(n) || n <= 0) {
209
+ console.error(chalk.red(`Invalid --max-iterations '${options.maxIterations}'. Use a positive integer.`));
210
+ process.exit(1);
211
+ }
212
+ resumeLoop.maxIterations = n;
213
+ }
214
+ if (options.budget !== undefined) {
215
+ const b = Number(options.budget);
216
+ if (!Number.isFinite(b) || b <= 0) {
217
+ console.error(chalk.red(`Invalid --budget '${options.budget}'. Use a positive token count.`));
218
+ process.exit(1);
219
+ }
220
+ resumeLoop.budget = b;
221
+ }
222
+ if (options.interval !== undefined) {
223
+ try {
224
+ parseLoopInterval(options.interval);
225
+ }
226
+ catch {
227
+ console.error(chalk.red(`Invalid --interval '${options.interval}'. Use "0" for back-to-back or a duration like "30m", "1h", "2h30m" (units: w/d/h/m).`));
228
+ process.exit(1);
229
+ }
230
+ resumeLoop.interval = options.interval;
231
+ }
232
+ if (options.until !== undefined) {
233
+ if (options.until !== 'signal') {
234
+ console.error(chalk.red(`Invalid --until '${options.until}'. Only 'signal' is supported.`));
235
+ process.exit(1);
236
+ }
237
+ resumeLoop.until = 'signal';
238
+ }
239
+ process.stderr.write(chalk.gray(`[loop] resuming ${cp.agent} run ${cp.id} from iteration ${cp.iteration + 1} (session ${(cp.sessionId ?? '').slice(0, 8)})\n`));
240
+ const result = await runLoop(resumeExec, resumeLoop, {
241
+ runId: cp.id,
242
+ runDir,
243
+ agent: cp.agent,
244
+ version: cp.version,
245
+ startIteration: cp.iteration + 1,
246
+ startTokens: cp.cumulativeTokens ?? 0,
247
+ sessionId: cp.sessionId,
248
+ });
249
+ process.stderr.write(chalk.gray(`[loop] stopped: ${result.stoppedBy} after ${result.iterations} iteration(s), ${result.tokens} tokens\n`));
250
+ process.exit(loopExitCode(result.stoppedBy));
251
+ }
252
+ const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, defaultModeFor, headlessPlanStallCommand }, { ALL_AGENT_IDS }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, RUN_STRATEGIES }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports },] = await Promise.all([
89
253
  import('../lib/exec.js'),
90
254
  import('../lib/agents.js'),
91
255
  import('../lib/profiles.js'),
@@ -95,6 +259,8 @@ export function registerRunCommand(program) {
95
259
  import('../lib/plugins.js'),
96
260
  import('../lib/workflows.js'),
97
261
  import('../lib/run-defaults.js'),
262
+ import('../lib/mcp.js'),
263
+ import('../lib/capabilities.js'),
98
264
  ]);
99
265
  const isValidAgent = (agent) => ALL_AGENT_IDS.includes(agent);
100
266
  // Parse agent@version
@@ -104,6 +270,12 @@ export function registerRunCommand(program) {
104
270
  let profileEnv;
105
271
  let fromProfile = false;
106
272
  let workflowModel;
273
+ // WORKFLOW.md capability scoping, translated to Claude headless flags below.
274
+ let workflowToolsRestrict;
275
+ let workflowMcpConfigPath;
276
+ // WORKFLOW.md `loop:` block (issue #332). When a workflow declares it,
277
+ // `agents run <workflow>` honors the loop without a --loop flag.
278
+ let workflowLoop;
107
279
  const cwd = options.cwd ?? process.cwd();
108
280
  if (isValidAgent(rawAgent)) {
109
281
  agent = rawAgent;
@@ -139,15 +311,46 @@ export function registerRunCommand(program) {
139
311
  if (typeof workflowFrontmatter?.model === 'string' && workflowFrontmatter.model.trim() !== '') {
140
312
  workflowModel = workflowFrontmatter.model.trim();
141
313
  }
314
+ workflowLoop = workflowFrontmatter?.loop;
142
315
  const resolvedVersion = resolveVersionAlias('claude', version);
143
316
  const versionHome = getVersionHomePath('claude', resolvedVersion ?? getGlobalDefault('claude') ?? '');
144
317
  const claudeAgentsDir = path.join(versionHome, '.claude', 'agents');
145
- // Copy subagents/*.md into ~/.claude/agents/ so Claude's Agent tool finds them.
318
+ // Copy subagents/*.md into ~/.claude/agents/ so Claude's Agent tool finds
319
+ // them. allowedAgents enforcement (issue #324): when the workflow declares
320
+ // `allowedAgents:`, copy ONLY those subagent files (matched by filename
321
+ // stem, e.g. security.md -> "security"). A subagent whose definition isn't
322
+ // on disk can't be dispatched — this is the actual, fail-closed mechanism.
323
+ // (Claude's `--agents` flag DEFINES custom agents; it does not restrict
324
+ // which subagents may be dispatched, so it is not used here.)
146
325
  const subagentsDir = path.join(workflowDir, 'subagents');
326
+ const allowedAgents = workflowFrontmatter?.allowedAgents;
147
327
  if (fs.existsSync(subagentsDir)) {
148
328
  fs.mkdirSync(claudeAgentsDir, { recursive: true });
149
- for (const file of fs.readdirSync(subagentsDir).filter(f => f.endsWith('.md'))) {
329
+ // Fail-closed subagent scoping (issue #324). resolveAllowedSubagents
330
+ // distinguishes "allowedAgents absent" (undefined -> copy all) from
331
+ // "present but empty" (=> copy ZERO). An explicit `allowedAgents: []`
332
+ // must mean "allow none", never silently widen to "allow all".
333
+ const allFiles = fs.readdirSync(subagentsDir).filter(f => f.endsWith('.md'));
334
+ const { allowedStems, missing } = resolveAllowedSubagents(allFiles, allowedAgents);
335
+ const allowStemSet = new Set(allowedStems);
336
+ let copied = 0;
337
+ let skipped = 0;
338
+ for (const file of allFiles) {
339
+ const stem = file.replace(/\.md$/, '');
340
+ if (!allowStemSet.has(stem)) {
341
+ skipped++;
342
+ continue;
343
+ }
150
344
  fs.copyFileSync(path.join(subagentsDir, file), path.join(claudeAgentsDir, file));
345
+ copied++;
346
+ }
347
+ if (allowedAgents !== undefined) {
348
+ // Surface any allowedAgents entry with no matching subagent file, and
349
+ // report how many were filtered out, so the scope is auditable.
350
+ if (missing.length > 0) {
351
+ process.stderr.write(chalk.yellow(`[workflow] allowedAgents not found in subagents/: ${missing.join(', ')}\n`));
352
+ }
353
+ process.stderr.write(chalk.gray(`[workflow] subagents restricted to allowedAgents: copied ${copied}, withheld ${skipped}\n`));
151
354
  }
152
355
  }
153
356
  // Feed WORKFLOW.md body (strip frontmatter) as orchestrator system context.
@@ -201,8 +404,60 @@ export function registerRunCommand(program) {
201
404
  }
202
405
  }
203
406
  }
407
+ // Capability scoping: translate WORKFLOW.md `tools:` / `mcpServers:` into
408
+ // the Claude headless flags that ACTUALLY restrict the run (verified
409
+ // against `claude --help`): tools -> `--tools` (restricts the available
410
+ // built-in tool set), mcpServers -> `--mcp-config` + `--strict-mcp-config`
411
+ // (loads ONLY the named servers). `allowedAgents:` is enforced separately,
412
+ // above, by copying only the allowed subagent definition files. Gated
413
+ // behind the `allowlist` capability — if the resolved agent lacks it, warn
414
+ // loudly rather than silently dropping the declaration (issue #324).
415
+ const scopeVersion = resolveVersionAlias('claude', version) ?? getGlobalDefault('claude') ?? undefined;
416
+ const allowlist = supports('claude', 'allowlist', scopeVersion);
417
+ const tools = workflowFrontmatter?.tools;
418
+ const mcpServerNames = workflowFrontmatter?.mcpServers;
419
+ const hasScoping = (tools && tools.length > 0)
420
+ || (mcpServerNames && mcpServerNames.length > 0)
421
+ || (allowedAgents && allowedAgents.length > 0);
422
+ if (hasScoping && !allowlist.ok) {
423
+ process.stderr.write(chalk.yellow(`[workflow] tools/mcpServers declared but unenforceable on claude${scopeVersion ? `@${scopeVersion}` : ''} (allowlist ${allowlist.reason ?? 'unsupported'}) — running unscoped\n`));
424
+ }
425
+ else if (hasScoping) {
426
+ if (tools && tools.length > 0) {
427
+ workflowToolsRestrict = tools;
428
+ process.stderr.write(chalk.gray(`[workflow] restricting available tools to: ${tools.join(', ')} (Write/Bash/Edit unavailable unless listed)\n`));
429
+ }
430
+ if (mcpServerNames && mcpServerNames.length > 0) {
431
+ const servers = getMcpServersByName(mcpServerNames, { cwd });
432
+ const found = new Set(servers.map(s => s.name));
433
+ const missing = mcpServerNames.filter(n => !found.has(n));
434
+ if (missing.length > 0) {
435
+ process.stderr.write(chalk.yellow(`[workflow] mcpServers not found in registry, skipped: ${missing.join(', ')}\n`));
436
+ }
437
+ // Fail-closed: `mcpServers:` was declared, so the run MUST be scoped to
438
+ // a config — never fall through to the user's ambient MCP set. When
439
+ // zero declared names resolve to installed servers, write a locked-down
440
+ // empty config (`{ "mcpServers": {} }`); with `--strict-mcp-config` the
441
+ // run gets NO MCP servers, which is LESS access than ambient (issue #324).
442
+ const mcpConfig = buildWorkflowMcpConfig(servers);
443
+ const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-workflow-mcp-'));
444
+ workflowMcpConfigPath = path.join(configDir, 'mcp-config.json');
445
+ // 0o600: the config embeds server `env` which can carry tokens.
446
+ // Cleaned up after the run (finally block below).
447
+ fs.writeFileSync(workflowMcpConfigPath, mcpConfig, { mode: 0o600 });
448
+ if (servers.length > 0) {
449
+ process.stderr.write(chalk.gray(`[workflow] scoping MCP servers to ONLY: ${servers.map(s => s.name).join(', ')}\n`));
450
+ }
451
+ else {
452
+ process.stderr.write(chalk.yellow(`[workflow] no declared mcpServers resolved — scoping run to NO MCP servers (fail-closed)\n`));
453
+ }
454
+ }
455
+ }
456
+ // Count the subagents THIS workflow made available (after allowedAgents
457
+ // filtering), not every file in the shared agents dir. Same fail-closed
458
+ // semantics as the copy above: `allowedAgents: []` -> 0.
204
459
  const subagentCount = fs.existsSync(subagentsDir)
205
- ? fs.readdirSync(subagentsDir).filter(f => f.endsWith('.md')).length
460
+ ? resolveAllowedSubagents(fs.readdirSync(subagentsDir).filter(f => f.endsWith('.md')), allowedAgents).allowedStems.length
206
461
  : 0;
207
462
  process.stderr.write(chalk.gray(`Workflow '${rawAgent}' → claude (${subagentCount} subagents)\n`));
208
463
  }
@@ -385,6 +640,8 @@ export function registerRunCommand(program) {
385
640
  verbose: options.verbose,
386
641
  timeout: options.timeout,
387
642
  env,
643
+ toolsRestrict: workflowToolsRestrict,
644
+ mcpConfigPath: workflowMcpConfigPath,
388
645
  };
389
646
  if (options.interactive && options.headless) {
390
647
  console.error(chalk.red('--interactive and --headless are mutually exclusive. Pass one, or neither (mode is inferred from prompt presence).'));
@@ -463,10 +720,128 @@ export function registerRunCommand(program) {
463
720
  process.exit(1);
464
721
  }
465
722
  }
723
+ // Budget pre-flight gate (issue #346). Estimate the run's cost and, when a
724
+ // cap is configured with on_exceed:block, refuse to launch if it would push
725
+ // a cap over the line — exiting non-zero so CI/headless inherit the block.
726
+ // --yes skips ONLY the interactive confirm threshold, never a hard block.
727
+ {
728
+ const { runPreflightGate } = await import('../lib/budget/preflight.js');
729
+ const { resolveEffectiveModel } = await import('../lib/models.js');
730
+ // Estimate against the model that will ACTUALLY run, not an unpriced
731
+ // `${agent}-default` placeholder (which made estimateCost return $0 and
732
+ // silently neutered the per_run/per_day gate for the common no-`--model`
733
+ // case). When `model` is undefined the spawned CLI uses its built-in
734
+ // default, which we recover from the extracted catalog. If we still can't
735
+ // resolve a concrete model, pass the placeholder — the gate now treats an
736
+ // unpriced estimate under active caps as needing confirmation, so it is
737
+ // never a silent $0 wave-through.
738
+ const effectiveModel = resolveEffectiveModel(agent, version ?? '', model) ?? `${agent}-default`;
739
+ const gate = runPreflightGate({
740
+ agent,
741
+ model: effectiveModel,
742
+ mode,
743
+ prompt,
744
+ project: cwd,
745
+ cwd,
746
+ });
747
+ if (!gate.dormant) {
748
+ if (!options.quiet) {
749
+ process.stderr.write(chalk.gray(gate.banner + '\n'));
750
+ }
751
+ if (!gate.decision.allow) {
752
+ // Hard block. --yes does NOT override (acceptance criterion).
753
+ console.error(chalk.red(`[budget] BLOCKED: ${gate.decision.reason}`));
754
+ console.error(chalk.gray(`Raise the cap in agents.yaml budget: or set on_exceed: warn to proceed.`));
755
+ process.exit(2);
756
+ }
757
+ if (gate.decision.needsConfirm && !options.yes) {
758
+ if (!process.stdin.isTTY) {
759
+ // Non-interactive (CI/headless) and no --yes: cannot confirm — refuse.
760
+ console.error(chalk.red(`[budget] ${gate.decision.reason}`));
761
+ console.error(chalk.gray(`Re-run with --yes to confirm the spend, or lower require_confirm_over.`));
762
+ process.exit(2);
763
+ }
764
+ const { confirm } = await import('@inquirer/prompts');
765
+ const proceed = await confirm({
766
+ message: `${gate.decision.reason}. Proceed?`,
767
+ default: false,
768
+ });
769
+ if (!proceed) {
770
+ console.error(chalk.yellow('[budget] aborted by user.'));
771
+ process.exit(2);
772
+ }
773
+ }
774
+ else if (gate.decision.blockedCap && gate.decision.allow && !options.quiet) {
775
+ // on_exceed:warn overrun notice (allowed but reported).
776
+ process.stderr.write(chalk.yellow(`[budget] WARN: ${gate.decision.reason}\n`));
777
+ }
778
+ }
779
+ }
466
780
  const cmd = buildExecCommand(execOptions);
467
781
  if (!options.quiet) {
468
782
  process.stderr.write(chalk.gray(`Running: ${cmd.join(' ')}\n\n`));
469
783
  }
784
+ // Remove the ephemeral mcp-config (and its temp dir) after the run. It is
785
+ // written at mode 0o600 but still embeds server `env` (possibly tokens),
786
+ // so it must not linger in tmp. Synchronous so it completes before exit.
787
+ const cleanupWorkflowMcpConfig = () => {
788
+ if (!workflowMcpConfigPath)
789
+ return;
790
+ try {
791
+ fs.rmSync(path.dirname(workflowMcpConfigPath), { recursive: true, force: true });
792
+ }
793
+ catch {
794
+ // best-effort: nothing actionable if the temp dir is already gone.
795
+ }
796
+ };
797
+ // Loop dispatch (issue #332). Active when --loop is passed OR a workflow
798
+ // declares a `loop:` block. The loop path runs AFTER the #346 pre-flight
799
+ // gate above (which fired once) — the loop's token budget is an ADDITIONAL
800
+ // guard, not a replacement. Composable, not bypassing.
801
+ let loopConfig;
802
+ try {
803
+ loopConfig = buildLoopConfig(options, workflowLoop);
804
+ }
805
+ catch (err) {
806
+ console.error(chalk.red(err.message));
807
+ process.exit(1);
808
+ }
809
+ if (loopConfig) {
810
+ if (prompt === undefined) {
811
+ console.error(chalk.red('--loop requires a prompt (or a workflow whose loop is paired with a prompt). The loop re-injects the prompt each iteration.'));
812
+ process.exit(1);
813
+ }
814
+ if (options.interactive) {
815
+ console.error(chalk.red('--loop is headless-only. The loop re-injects programmatically; an interactive TUI cannot be re-driven.'));
816
+ process.exit(1);
817
+ }
818
+ if (fallback.length > 0) {
819
+ console.error(chalk.red('--loop is not compatible with --fallback yet. Drop one.'));
820
+ process.exit(1);
821
+ }
822
+ const { runLoop } = await import('../lib/loop.js');
823
+ const { getRunsDir } = await import('../lib/state.js');
824
+ const runId = `loop-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
825
+ const runDir = path.join(getRunsDir(), runId);
826
+ fs.mkdirSync(runDir, { recursive: true });
827
+ process.stderr.write(chalk.gray(`[loop] run ${runId} — max ${loopConfig.maxIterations ?? '∞'}${loopConfig.budget ? `, budget ${loopConfig.budget} tokens` : ''}${loopConfig.until ? `, until ${loopConfig.until}` : ''}${loopConfig.interval ? `, interval ${loopConfig.interval}` : ''}\n`));
828
+ try {
829
+ const result = await runLoop({ ...execOptions, json: true, headless: true }, loopConfig, {
830
+ runId,
831
+ runDir,
832
+ agent,
833
+ version,
834
+ });
835
+ cleanupWorkflowMcpConfig();
836
+ process.stderr.write(chalk.gray(`[loop] stopped: ${result.stoppedBy} after ${result.iterations} iteration(s), ${result.tokens} tokens (checkpoint: ${path.join(runDir, 'checkpoint.json')})\n`));
837
+ process.exit(loopExitCode(result.stoppedBy));
838
+ }
839
+ catch (err) {
840
+ cleanupWorkflowMcpConfig();
841
+ console.error(chalk.red(`Loop failed for ${agent}: ${err.message}`));
842
+ process.exit(1);
843
+ }
844
+ }
470
845
  try {
471
846
  let exitCode;
472
847
  if (fallback.length > 0) {
@@ -476,9 +851,11 @@ export function registerRunCommand(program) {
476
851
  else {
477
852
  exitCode = await execAgent(execOptions);
478
853
  }
854
+ cleanupWorkflowMcpConfig();
479
855
  process.exit(exitCode);
480
856
  }
481
857
  catch (err) {
858
+ cleanupWorkflowMcpConfig();
482
859
  console.error(chalk.red(`Failed to execute ${agent}: ${err.message}`));
483
860
  process.exit(1);
484
861
  }
@@ -6,5 +6,20 @@
6
6
  * Keychain. Bundles are injected at run time via `agents run --secrets`.
7
7
  */
8
8
  import type { Command } from 'commander';
9
+ /**
10
+ * SSH target for `export --to-ssh`: a bare ssh-config host alias (e.g. `yosemite-s0`)
11
+ * or `user@host`. The strict allowlist blocks shell metacharacters and a leading `-`
12
+ * so a target can't be smuggled in as an ssh argv flag.
13
+ */
14
+ export declare const SSH_TARGET_RE: RegExp;
15
+ export declare function assertValidSshTarget(host: string): void;
16
+ /**
17
+ * Serialize a resolved env map to `.env` lines that round-trip losslessly through
18
+ * `parseDotenv` on the remote: `KEY="VALUE"`. parseDotenv strips exactly one outer
19
+ * quote pair and takes the inner bytes verbatim (no unescaping), so any single-line
20
+ * value survives unchanged with no escaping. Newlines would break its line-based
21
+ * parse, so multi-line values are rejected rather than silently corrupted.
22
+ */
23
+ export declare function bundleEnvToDotenv(env: Record<string, string>): string;
9
24
  /** Register the `agents secrets` command tree. */
10
25
  export declare function registerSecretsCommands(program: Command): void;