@phnx-labs/agents-cli 1.20.17 → 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 +15 -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/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/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/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/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/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/lib/teams/agents.js
CHANGED
|
@@ -135,6 +135,25 @@ function hasTransitiveDep(byName, startName, targetName, seen = new Set()) {
|
|
|
135
135
|
}
|
|
136
136
|
return false;
|
|
137
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Single-quote a string for safe interpolation into a POSIX `sh -c` command.
|
|
140
|
+
* Wraps in single quotes and escapes embedded single quotes via the standard
|
|
141
|
+
* `'\''` close-escape-reopen idiom, so arbitrary prompts/paths can't break out
|
|
142
|
+
* of quoting or inject shell syntax.
|
|
143
|
+
*/
|
|
144
|
+
function shSingleQuote(value) {
|
|
145
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Wrap a teammate argv in a POSIX shell command that runs it and then records
|
|
149
|
+
* the real exit code to `exitCodePath`. `echo $?` captures the status of the
|
|
150
|
+
* preceding command, so the sentinel reflects the underlying CLI's exit code,
|
|
151
|
+
* not the shell's. Single source of truth shared by launchProcess() and its
|
|
152
|
+
* test. See reapProcess() for how the sentinel is consumed.
|
|
153
|
+
*/
|
|
154
|
+
export function buildSentinelCommand(cmd, exitCodePath) {
|
|
155
|
+
return `${cmd.map(shSingleQuote).join(' ')}; echo $? > ${shSingleQuote(exitCodePath)}`;
|
|
156
|
+
}
|
|
138
157
|
/**
|
|
139
158
|
* Capture a stable identifier for a process at the moment it was started.
|
|
140
159
|
* Used to defeat PID reuse: a kill(pid, ...) is only safe when the process
|
|
@@ -456,6 +475,15 @@ export class AgentProcess {
|
|
|
456
475
|
async getMetaPath() {
|
|
457
476
|
return path.join(await this.getAgentDir(), 'meta.json');
|
|
458
477
|
}
|
|
478
|
+
/**
|
|
479
|
+
* Path to the exit-code sentinel. The launcher wraps the teammate command in
|
|
480
|
+
* a shell that writes the underlying CLI's `$?` here once it exits. Detached
|
|
481
|
+
* teammates can't be wait()ed on by the parent, so this file is the only
|
|
482
|
+
* durable record of the real exit status — see reapProcess().
|
|
483
|
+
*/
|
|
484
|
+
async getExitCodePath() {
|
|
485
|
+
return path.join(await this.getAgentDir(), 'exit_code');
|
|
486
|
+
}
|
|
459
487
|
toDict() {
|
|
460
488
|
return {
|
|
461
489
|
agent_id: this.agentId,
|
|
@@ -748,14 +776,37 @@ export class AgentProcess {
|
|
|
748
776
|
}
|
|
749
777
|
await this.saveMeta();
|
|
750
778
|
}
|
|
779
|
+
/**
|
|
780
|
+
* Recover the teammate's exit status after its process is gone.
|
|
781
|
+
*
|
|
782
|
+
* The teammate is spawned detached + unref()'d (see launchProcess), so the
|
|
783
|
+
* parent never gets the child's exit code from the OS. Instead the launcher
|
|
784
|
+
* wraps the command in a shell that records `$?` to the exit-code sentinel.
|
|
785
|
+
* This reads that file:
|
|
786
|
+
* - still alive -> null (no verdict yet)
|
|
787
|
+
* - sentinel present -> the real exit code (0 = success)
|
|
788
|
+
* - sentinel absent -> 1 (the shell was killed before it could write
|
|
789
|
+
* it, e.g. SIGKILL on timeout/stop — a real
|
|
790
|
+
* failure)
|
|
791
|
+
*
|
|
792
|
+
* Returning a real code (not a hardcoded 1) is what lets agents whose stream
|
|
793
|
+
* never emits a parsed terminal event — kimi, antigravity, droid — be marked
|
|
794
|
+
* completed on success instead of falsely failed.
|
|
795
|
+
*/
|
|
751
796
|
async reapProcess() {
|
|
752
797
|
if (!this.pid)
|
|
753
798
|
return null;
|
|
754
|
-
|
|
755
|
-
|
|
799
|
+
// isProcessAlive() applies the start-time guard, so a recycled PID now
|
|
800
|
+
// owned by an unrelated process doesn't read as still-alive.
|
|
801
|
+
if (this.isProcessAlive())
|
|
756
802
|
return null;
|
|
803
|
+
try {
|
|
804
|
+
const raw = (await fs.readFile(await this.getExitCodePath(), 'utf-8')).trim();
|
|
805
|
+
const code = Number.parseInt(raw, 10);
|
|
806
|
+
return Number.isNaN(code) ? 1 : code;
|
|
757
807
|
}
|
|
758
808
|
catch {
|
|
809
|
+
// No sentinel: the shell died before recording $? (killed mid-run).
|
|
759
810
|
return 1;
|
|
760
811
|
}
|
|
761
812
|
}
|
|
@@ -998,7 +1049,19 @@ export class AgentManager {
|
|
|
998
1049
|
const stdoutPath = await agent.getStdoutPath();
|
|
999
1050
|
const stdoutFile = await fs.open(stdoutPath, 'w');
|
|
1000
1051
|
const stdoutFd = stdoutFile.fd;
|
|
1001
|
-
|
|
1052
|
+
// Wrap the teammate command in a shell that records the underlying CLI's
|
|
1053
|
+
// exit code to a sentinel file. Detached + unref()'d children can't be
|
|
1054
|
+
// wait()ed on by this parent, so the sentinel is the only durable record
|
|
1055
|
+
// of the real exit status — reapProcess() reads it to decide
|
|
1056
|
+
// completed-vs-failed for agents whose stream emits no parsed terminal
|
|
1057
|
+
// event (kimi, antigravity, droid). Remove any stale sentinel from a
|
|
1058
|
+
// prior run of the same agent id first so a restart can't read it.
|
|
1059
|
+
const exitCodePath = await agent.getExitCodePath();
|
|
1060
|
+
await fs.rm(exitCodePath, { force: true }).catch(() => { });
|
|
1061
|
+
const wrappedCmd = buildSentinelCommand(cmd, exitCodePath);
|
|
1062
|
+
// detached:true makes the shell the process-group leader, so stop()'s
|
|
1063
|
+
// `kill(-pid)` still reaches the underlying CLI through the group.
|
|
1064
|
+
const childProcess = spawn('/bin/sh', ['-c', wrappedCmd], {
|
|
1002
1065
|
stdio: ['ignore', stdoutFd, stdoutFd],
|
|
1003
1066
|
cwd: agent.cwd || undefined,
|
|
1004
1067
|
detached: true,
|
package/dist/lib/teams/api.js
CHANGED
|
@@ -139,6 +139,26 @@ export async function handleSpawn(manager, taskName, agentType, prompt, cwd, mod
|
|
|
139
139
|
const resolvedMode = resolveMode(mode, defaultMode);
|
|
140
140
|
const resolvedEffort = effort ?? 'medium';
|
|
141
141
|
debug(`[spawn] Spawning ${agentType} agent for task "${taskName}" [${resolvedMode}] effort=${resolvedEffort}${profileName ? ` profile=${profileName}` : ''}...`);
|
|
142
|
+
// Budget pre-flight gate (issue #346). Teammates inherit the project's caps:
|
|
143
|
+
// before launching one, project its estimated cost onto current spend and
|
|
144
|
+
// refuse when on_exceed:block would be breached. Cross-vendor by construction
|
|
145
|
+
// — a Claude teammate and a Codex teammate draw down the same per_project /
|
|
146
|
+
// per_day pool. Dormant (no-op) when no caps are configured.
|
|
147
|
+
{
|
|
148
|
+
const gateCwd = cwd || workspaceDir || worktreePath || process.cwd();
|
|
149
|
+
const { runPreflightGate } = await import('../budget/preflight.js');
|
|
150
|
+
const gate = runPreflightGate({
|
|
151
|
+
agent: agentType,
|
|
152
|
+
model: model ?? `${agentType}-default`,
|
|
153
|
+
mode: resolvedMode,
|
|
154
|
+
prompt,
|
|
155
|
+
project: gateCwd,
|
|
156
|
+
cwd: gateCwd,
|
|
157
|
+
});
|
|
158
|
+
if (!gate.dormant && !gate.decision.allow) {
|
|
159
|
+
throw new Error(`[budget] BLOCKED teammate "${taskName}" (${agentType}): ${gate.decision.reason}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
142
162
|
const agent = await manager.spawn(taskName, agentType, prompt, cwd, resolvedMode, resolvedEffort, parentSessionId, workspaceDir, version, name, after, model, envOverrides, taskType, cloudProvider, cloudSessionId, cloudRepo, cloudBranch, worktreeName, worktreePath, profileName);
|
|
143
163
|
debug(`[spawn] Spawned ${agentType} agent ${agent.agentId} for task "${taskName}"`);
|
|
144
164
|
return {
|
|
@@ -917,9 +917,16 @@ function normalizeGrok(raw) {
|
|
|
917
917
|
// - {"role":"assistant","content":"..."} → final message
|
|
918
918
|
// - {"role":"assistant","tool_calls":[{"function":{"name":"Bash","arguments":"<json>"}}]} → tool use
|
|
919
919
|
// - {"role":"tool","tool_call_id":"...","content":"..."} → tool result
|
|
920
|
-
// - {"role":"meta","type":"session.resume_hint","session_id":"..."} →
|
|
921
|
-
//
|
|
922
|
-
//
|
|
920
|
+
// - {"role":"meta","type":"session.resume_hint","session_id":"..."} → terminal/result
|
|
921
|
+
// Kimi emits NO dedicated result/turn-complete event and NO init event. The
|
|
922
|
+
// `session.resume_hint` meta is its terminal marker: emitted exactly once, as
|
|
923
|
+
// the LAST line, on clean completion (it carries the `kimi -r <id>` resume
|
|
924
|
+
// command). We map it to a success `result` so the team runner resolves status
|
|
925
|
+
// from the stream; the run's exit code remains the safety net for crashes that
|
|
926
|
+
// never reach the hint. Tool arguments are JSON-stringified inside
|
|
927
|
+
// `function.arguments` and must be parsed before extracting paths/commands.
|
|
928
|
+
// Verified against live `kimi` runs (no-tool and tool-using) — see
|
|
929
|
+
// __tests__/testdata/kimi-stream-*.jsonl.
|
|
923
930
|
function normalizeKimi(raw) {
|
|
924
931
|
const timestamp = new Date().toISOString();
|
|
925
932
|
if (!raw || typeof raw !== 'object') {
|
|
@@ -1044,9 +1051,14 @@ function normalizeKimi(raw) {
|
|
|
1044
1051
|
if (role === 'meta') {
|
|
1045
1052
|
const metaType = typeof raw.type === 'string' ? raw.type : '';
|
|
1046
1053
|
if (metaType === 'session.resume_hint') {
|
|
1054
|
+
// Kimi's terminal marker (see header). Emit a success `result` so the
|
|
1055
|
+
// team runner's terminal-event detection resolves the teammate to
|
|
1056
|
+
// COMPLETED from the stream. session_id is preserved for cross-
|
|
1057
|
+
// referencing — readNewEvents() captures it off any event.
|
|
1047
1058
|
return [{
|
|
1048
|
-
type: '
|
|
1059
|
+
type: 'result',
|
|
1049
1060
|
agent: 'kimi',
|
|
1061
|
+
status: 'success',
|
|
1050
1062
|
session_id: typeof raw.session_id === 'string' ? raw.session_id : null,
|
|
1051
1063
|
timestamp: timestamp,
|
|
1052
1064
|
}];
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -22,6 +22,43 @@ export interface RunDefaults {
|
|
|
22
22
|
export type RunConfig = Partial<Record<AgentId, AgentRunConfig>> & {
|
|
23
23
|
defaults?: Record<string, RunDefaults>;
|
|
24
24
|
};
|
|
25
|
+
/**
|
|
26
|
+
* What to do when a configured budget cap would be exceeded (issue #346).
|
|
27
|
+
* `block` refuses to launch (or kills a running child) and exits non-zero so
|
|
28
|
+
* CI/headless/teams/cloud all inherit the decision. `warn` prints the overrun
|
|
29
|
+
* but proceeds — useful for soft rollout / observability-only.
|
|
30
|
+
*/
|
|
31
|
+
export type BudgetOnExceed = 'block' | 'warn';
|
|
32
|
+
/**
|
|
33
|
+
* `budget:` block in agents.yaml — cross-vendor spend guardrails (issue #346).
|
|
34
|
+
*
|
|
35
|
+
* Resolution is project > user (same precedence as `run:`); see
|
|
36
|
+
* `resolveBudgetConfig` in lib/budget/config.ts. Every cap is in USD. A cap is
|
|
37
|
+
* "unset" when undefined — only set caps are enforced. `per_agent` caps apply
|
|
38
|
+
* to one agent's spend; the top-level caps (`per_run`, `per_day`,
|
|
39
|
+
* `per_project`) aggregate ACROSS every vendor the CLI dispatches, which is the
|
|
40
|
+
* cross-vendor property no single-vendor control has.
|
|
41
|
+
*/
|
|
42
|
+
export interface BudgetConfig {
|
|
43
|
+
/** Display currency. Only "USD" is priced today; carried for forward-compat. */
|
|
44
|
+
currency?: string;
|
|
45
|
+
/** Hard cap on the estimated/actual cost of a single run. */
|
|
46
|
+
per_run?: number;
|
|
47
|
+
/** Hard cap on total spend attributed to the current day (local date). */
|
|
48
|
+
per_day?: number;
|
|
49
|
+
/** Per-agent daily caps, keyed by agent id (e.g. { claude: 30, codex: 20 }). */
|
|
50
|
+
per_agent?: Partial<Record<AgentId, number>>;
|
|
51
|
+
/** Hard cap on cumulative spend attributed to the current project. */
|
|
52
|
+
per_project?: number;
|
|
53
|
+
/** block (refuse/kill) or warn (proceed). Defaults to block. */
|
|
54
|
+
on_exceed?: BudgetOnExceed;
|
|
55
|
+
/**
|
|
56
|
+
* Interactive confirm threshold (USD). When a run's pre-flight estimate is at
|
|
57
|
+
* or above this, prompt before launching (unless --yes). Does NOT gate a hard
|
|
58
|
+
* block — a cap breach always blocks regardless of this value.
|
|
59
|
+
*/
|
|
60
|
+
require_confirm_over?: number;
|
|
61
|
+
}
|
|
25
62
|
/** Preview features that users can opt into via `agents beta`. */
|
|
26
63
|
export type BetaFeatureName = 'drive' | 'factory';
|
|
27
64
|
/** Subset of chalk color names used for agent-specific terminal output. */
|
|
@@ -210,6 +247,8 @@ export interface InstalledHook {
|
|
|
210
247
|
export interface Manifest {
|
|
211
248
|
agents?: Partial<Record<AgentId, string>>;
|
|
212
249
|
run?: RunConfig;
|
|
250
|
+
/** Spend guardrails (issue #346). Project-local block overrides user. */
|
|
251
|
+
budget?: BudgetConfig;
|
|
213
252
|
beta?: {
|
|
214
253
|
enabled?: BetaFeatureName[];
|
|
215
254
|
};
|
|
@@ -516,6 +555,15 @@ export interface ExtraRepoConfig {
|
|
|
516
555
|
export interface Meta {
|
|
517
556
|
agents?: Partial<Record<AgentId, string>>;
|
|
518
557
|
run?: RunConfig;
|
|
558
|
+
/** macOS secrets-agent config. `auto` makes the first real keychain read of a
|
|
559
|
+
* `session`-tier bundle populate the broker so concurrent runs read silently. */
|
|
560
|
+
secrets?: {
|
|
561
|
+
agent?: {
|
|
562
|
+
auto?: boolean;
|
|
563
|
+
};
|
|
564
|
+
};
|
|
565
|
+
/** Spend guardrails (issue #346). User-global caps; project agents.yaml overrides. */
|
|
566
|
+
budget?: BudgetConfig;
|
|
519
567
|
beta?: {
|
|
520
568
|
enabled?: BetaFeatureName[];
|
|
521
569
|
};
|
package/dist/lib/workflows.d.ts
CHANGED
|
@@ -6,6 +6,21 @@
|
|
|
6
6
|
* are composed at runtime by `agents run <workflow>`.
|
|
7
7
|
*/
|
|
8
8
|
import type { AgentId } from './types.js';
|
|
9
|
+
/**
|
|
10
|
+
* The `loop:` block as it appears in WORKFLOW.md frontmatter (YAML, snake_case).
|
|
11
|
+
* Parsed defensively and translated to the camelCase LoopConfig the driver
|
|
12
|
+
* consumes (src/lib/loop.ts). See docs/07-entrypoints-and-loops.md.
|
|
13
|
+
*/
|
|
14
|
+
export interface LoopConfigRaw {
|
|
15
|
+
/** Stop condition. Only `signal` is supported today. */
|
|
16
|
+
until?: 'signal';
|
|
17
|
+
/** Hard cap on iterations. */
|
|
18
|
+
max_iterations?: number;
|
|
19
|
+
/** Token hard-cap, enforced outside the agent. */
|
|
20
|
+
budget?: number;
|
|
21
|
+
/** Delay between iterations ("0" back-to-back, "30m" paces). */
|
|
22
|
+
interval?: string;
|
|
23
|
+
}
|
|
9
24
|
/** Parsed WORKFLOW.md frontmatter. */
|
|
10
25
|
export interface WorkflowFrontmatter {
|
|
11
26
|
name: string;
|
|
@@ -22,6 +37,12 @@ export interface WorkflowFrontmatter {
|
|
|
22
37
|
* Pass `--no-auto-secrets` to skip this injection.
|
|
23
38
|
*/
|
|
24
39
|
secrets?: string[];
|
|
40
|
+
/**
|
|
41
|
+
* Optional loop block: wraps the workflow in a bounded until-condition loop
|
|
42
|
+
* (issue #332). When present, `agents run <workflow>` honors it without a
|
|
43
|
+
* `--loop` flag. Validated/coerced in parseWorkflowFrontmatter.
|
|
44
|
+
*/
|
|
45
|
+
loop?: LoopConfigRaw;
|
|
25
46
|
}
|
|
26
47
|
/** A workflow found during repo discovery. */
|
|
27
48
|
export interface DiscoveredWorkflow {
|
|
@@ -39,6 +60,41 @@ export interface InstalledWorkflow {
|
|
|
39
60
|
}
|
|
40
61
|
/** Parse WORKFLOW.md frontmatter from a workflow directory. Returns null if invalid. */
|
|
41
62
|
export declare function parseWorkflowFrontmatter(workflowDir: string): WorkflowFrontmatter | null;
|
|
63
|
+
/**
|
|
64
|
+
* Defensively coerce a frontmatter `loop:` value into a LoopConfigRaw.
|
|
65
|
+
*
|
|
66
|
+
* Mirrors the asStringArray discipline above: a malformed field is dropped to
|
|
67
|
+
* undefined rather than passed through, so the loop driver never sees a bad
|
|
68
|
+
* shape. Returns undefined when `loop:` is absent or not an object, or when no
|
|
69
|
+
* recognized field survives coercion (an all-garbage block is treated as
|
|
70
|
+
* "no loop", not "empty loop").
|
|
71
|
+
*
|
|
72
|
+
* Field rules:
|
|
73
|
+
* - until: only the literal `signal` is accepted; anything else dropped.
|
|
74
|
+
* - max_iterations: a finite positive integer; non-numbers/<=0 dropped.
|
|
75
|
+
* - budget: a finite positive number (tokens); non-numbers/<=0 dropped.
|
|
76
|
+
* - interval: a string (e.g. "0", "30m"); non-strings dropped.
|
|
77
|
+
*/
|
|
78
|
+
export declare function parseLoopBlock(v: unknown): LoopConfigRaw | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* Decide which subagent .md stems a workflow may use, given the discovered
|
|
81
|
+
* subagent files and the parsed `allowedAgents` frontmatter. This is the
|
|
82
|
+
* fail-closed security boundary for issue #324:
|
|
83
|
+
*
|
|
84
|
+
* - `allowedAgents === undefined` (field absent) -> NO restriction; allow all.
|
|
85
|
+
* - `allowedAgents === []` (present, empty) -> allow ZERO; copy none.
|
|
86
|
+
* - `allowedAgents = [a, b]` -> allow only those stems.
|
|
87
|
+
*
|
|
88
|
+
* An explicit empty array must NEVER widen to "allow all" — that would copy
|
|
89
|
+
* every subagent definition into the run, granting MORE access than declared.
|
|
90
|
+
*
|
|
91
|
+
* `available` are the .md filenames found in subagents/ (e.g. `security.md`).
|
|
92
|
+
* Returns the stems to copy and any allowedAgents entries with no matching file.
|
|
93
|
+
*/
|
|
94
|
+
export declare function resolveAllowedSubagents(available: string[], allowedAgents: string[] | undefined): {
|
|
95
|
+
allowedStems: string[];
|
|
96
|
+
missing: string[];
|
|
97
|
+
};
|
|
42
98
|
/** Count subagent .md files in a workflow's subagents/ directory. */
|
|
43
99
|
export declare function countWorkflowSubagents(workflowDir: string): number;
|
|
44
100
|
/**
|
package/dist/lib/workflows.js
CHANGED
|
@@ -28,21 +28,88 @@ export function parseWorkflowFrontmatter(workflowDir) {
|
|
|
28
28
|
const parsed = yaml.parse(frontmatter);
|
|
29
29
|
if (!parsed || typeof parsed !== 'object')
|
|
30
30
|
return null;
|
|
31
|
+
// Capability-scoping fields are wired into the run (see src/commands/exec.ts);
|
|
32
|
+
// coerce to string arrays defensively so a malformed `tools: foo` (scalar) or
|
|
33
|
+
// `tools: [Read, 3]` (mixed) never reaches buildExecCommand as a bad shape.
|
|
34
|
+
const asStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string') ? v : undefined;
|
|
31
35
|
return {
|
|
32
36
|
name: parsed.name || '',
|
|
33
37
|
description: parsed.description || '',
|
|
34
38
|
model: parsed.model,
|
|
35
|
-
tools: parsed.tools,
|
|
36
|
-
skills: parsed.skills,
|
|
37
|
-
mcpServers: parsed.mcpServers,
|
|
38
|
-
allowedAgents: parsed.allowedAgents,
|
|
39
|
-
secrets: parsed.secrets,
|
|
39
|
+
tools: asStringArray(parsed.tools),
|
|
40
|
+
skills: asStringArray(parsed.skills),
|
|
41
|
+
mcpServers: asStringArray(parsed.mcpServers),
|
|
42
|
+
allowedAgents: asStringArray(parsed.allowedAgents),
|
|
43
|
+
secrets: asStringArray(parsed.secrets),
|
|
44
|
+
loop: parseLoopBlock(parsed.loop),
|
|
40
45
|
};
|
|
41
46
|
}
|
|
42
47
|
catch {
|
|
43
48
|
return null;
|
|
44
49
|
}
|
|
45
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Defensively coerce a frontmatter `loop:` value into a LoopConfigRaw.
|
|
53
|
+
*
|
|
54
|
+
* Mirrors the asStringArray discipline above: a malformed field is dropped to
|
|
55
|
+
* undefined rather than passed through, so the loop driver never sees a bad
|
|
56
|
+
* shape. Returns undefined when `loop:` is absent or not an object, or when no
|
|
57
|
+
* recognized field survives coercion (an all-garbage block is treated as
|
|
58
|
+
* "no loop", not "empty loop").
|
|
59
|
+
*
|
|
60
|
+
* Field rules:
|
|
61
|
+
* - until: only the literal `signal` is accepted; anything else dropped.
|
|
62
|
+
* - max_iterations: a finite positive integer; non-numbers/<=0 dropped.
|
|
63
|
+
* - budget: a finite positive number (tokens); non-numbers/<=0 dropped.
|
|
64
|
+
* - interval: a string (e.g. "0", "30m"); non-strings dropped.
|
|
65
|
+
*/
|
|
66
|
+
export function parseLoopBlock(v) {
|
|
67
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
68
|
+
return undefined;
|
|
69
|
+
const raw = v;
|
|
70
|
+
const out = {};
|
|
71
|
+
if (raw.until === 'signal')
|
|
72
|
+
out.until = 'signal';
|
|
73
|
+
if (typeof raw.max_iterations === 'number'
|
|
74
|
+
&& Number.isFinite(raw.max_iterations)
|
|
75
|
+
&& Number.isInteger(raw.max_iterations)
|
|
76
|
+
&& raw.max_iterations > 0) {
|
|
77
|
+
out.max_iterations = raw.max_iterations;
|
|
78
|
+
}
|
|
79
|
+
if (typeof raw.budget === 'number' && Number.isFinite(raw.budget) && raw.budget > 0) {
|
|
80
|
+
out.budget = raw.budget;
|
|
81
|
+
}
|
|
82
|
+
if (typeof raw.interval === 'string')
|
|
83
|
+
out.interval = raw.interval;
|
|
84
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Decide which subagent .md stems a workflow may use, given the discovered
|
|
88
|
+
* subagent files and the parsed `allowedAgents` frontmatter. This is the
|
|
89
|
+
* fail-closed security boundary for issue #324:
|
|
90
|
+
*
|
|
91
|
+
* - `allowedAgents === undefined` (field absent) -> NO restriction; allow all.
|
|
92
|
+
* - `allowedAgents === []` (present, empty) -> allow ZERO; copy none.
|
|
93
|
+
* - `allowedAgents = [a, b]` -> allow only those stems.
|
|
94
|
+
*
|
|
95
|
+
* An explicit empty array must NEVER widen to "allow all" — that would copy
|
|
96
|
+
* every subagent definition into the run, granting MORE access than declared.
|
|
97
|
+
*
|
|
98
|
+
* `available` are the .md filenames found in subagents/ (e.g. `security.md`).
|
|
99
|
+
* Returns the stems to copy and any allowedAgents entries with no matching file.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveAllowedSubagents(available, allowedAgents) {
|
|
102
|
+
const stems = available.filter(f => f.endsWith('.md')).map(f => f.replace(/\.md$/, ''));
|
|
103
|
+
if (allowedAgents === undefined) {
|
|
104
|
+
return { allowedStems: stems, missing: [] };
|
|
105
|
+
}
|
|
106
|
+
const allow = new Set(allowedAgents);
|
|
107
|
+
const present = new Set(stems);
|
|
108
|
+
return {
|
|
109
|
+
allowedStems: stems.filter(s => allow.has(s)),
|
|
110
|
+
missing: allowedAgents.filter(a => !present.has(a)),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
46
113
|
/** Count subagent .md files in a workflow's subagents/ directory. */
|
|
47
114
|
export function countWorkflowSubagents(workflowDir) {
|
|
48
115
|
const subagentsDir = path.join(workflowDir, 'subagents');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.18",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"files": [
|
|
24
24
|
"dist/**/*.js",
|
|
25
25
|
"dist/**/*.d.ts",
|
|
26
|
+
"dist/**/*.json",
|
|
26
27
|
"dist/lib/secrets/Agents CLI.app/**",
|
|
27
28
|
"scripts/postinstall.js",
|
|
28
29
|
"scripts/install-helper.js",
|