@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.
- package/CHANGELOG.md +19 -0
- package/README.md +1 -1
- package/dist/commands/budget.d.ts +14 -0
- package/dist/commands/budget.js +137 -0
- package/dist/commands/cost.d.ts +12 -0
- package/dist/commands/cost.js +139 -0
- package/dist/commands/exec.d.ts +20 -0
- package/dist/commands/exec.js +382 -5
- package/dist/commands/secrets.d.ts +15 -0
- package/dist/commands/secrets.js +250 -4
- package/dist/commands/sessions.js +4 -0
- package/dist/commands/sync.d.ts +10 -3
- package/dist/commands/sync.js +72 -9
- package/dist/index.js +4 -0
- package/dist/lib/budget/config.d.ts +9 -0
- package/dist/lib/budget/config.js +115 -0
- package/dist/lib/budget/enforce.d.ts +94 -0
- package/dist/lib/budget/enforce.js +151 -0
- package/dist/lib/budget/ledger.d.ts +61 -0
- package/dist/lib/budget/ledger.js +107 -0
- package/dist/lib/budget/preflight.d.ts +110 -0
- package/dist/lib/budget/preflight.js +200 -0
- package/dist/lib/checkpoint.d.ts +54 -0
- package/dist/lib/checkpoint.js +56 -0
- package/dist/lib/cloud/rush.js +18 -0
- package/dist/lib/exec.d.ts +36 -0
- package/dist/lib/exec.js +192 -4
- package/dist/lib/git.d.ts +18 -0
- package/dist/lib/git.js +67 -4
- package/dist/lib/hooks.js +12 -0
- package/dist/lib/loop.d.ts +145 -0
- package/dist/lib/loop.js +330 -0
- package/dist/lib/mcp.d.ts +7 -0
- package/dist/lib/mcp.js +24 -0
- package/dist/lib/models.d.ts +11 -0
- package/dist/lib/models.js +21 -0
- package/dist/lib/plugin-marketplace.js +16 -6
- package/dist/lib/plugins.js +5 -2
- package/dist/lib/pricing/cost.d.ts +46 -0
- package/dist/lib/pricing/cost.js +71 -0
- package/dist/lib/pricing/index.d.ts +8 -0
- package/dist/lib/pricing/index.js +8 -0
- package/dist/lib/pricing/prices.json +138 -0
- package/dist/lib/pricing/table.d.ts +17 -0
- package/dist/lib/pricing/table.js +73 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +134 -0
- package/dist/lib/secrets/agent.js +501 -0
- package/dist/lib/secrets/bundles.d.ts +21 -0
- package/dist/lib/secrets/bundles.js +43 -0
- package/dist/lib/secrets/drivers/rush.d.ts +14 -0
- package/dist/lib/secrets/drivers/rush.js +84 -0
- package/dist/lib/secrets/linux.js +88 -10
- package/dist/lib/secrets/sync-backend.d.ts +48 -0
- package/dist/lib/secrets/sync-backend.js +13 -0
- package/dist/lib/secrets/sync.d.ts +15 -23
- package/dist/lib/secrets/sync.js +31 -66
- package/dist/lib/session/db.d.ts +40 -0
- package/dist/lib/session/db.js +84 -2
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +126 -2
- package/dist/lib/session/render.d.ts +2 -0
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/types.d.ts +4 -0
- package/dist/lib/sync-umbrella.d.ts +76 -0
- package/dist/lib/sync-umbrella.js +125 -0
- package/dist/lib/teams/agents.d.ts +32 -0
- package/dist/lib/teams/agents.js +66 -3
- package/dist/lib/teams/api.js +20 -0
- package/dist/lib/teams/parsers.js +16 -4
- package/dist/lib/types.d.ts +48 -0
- package/dist/lib/workflows.d.ts +56 -0
- package/dist/lib/workflows.js +72 -5
- package/package.json +2 -1
package/dist/commands/exec.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|