@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/loop.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autonomous loop driver (issue #332).
|
|
3
|
+
*
|
|
4
|
+
* Re-injects an entrypoint each iteration until a stop condition is met. The
|
|
5
|
+
* driver is the deterministic skeleton; the entrypoint inside stays dynamic (it
|
|
6
|
+
* can spawn subagents freely). Every guard — `max_iterations`, `budget`, the
|
|
7
|
+
* `until: signal` condition, SIGINT/SIGTERM — lives OUTSIDE the agent, so the
|
|
8
|
+
* agent cannot vote past a kill-switch (the standard answer to runaway-loop and
|
|
9
|
+
* runaway-cost failure modes; see docs/07-entrypoints-and-loops.md).
|
|
10
|
+
*
|
|
11
|
+
* Structure mirrors the teams supervisor (`runSupervisor` in teams/supervisor.ts):
|
|
12
|
+
* a bounded for-loop with a hard cap, a SIGINT/SIGTERM trap that flips a stop
|
|
13
|
+
* flag, a per-iteration guard check, an interval sleep, and a typed `stoppedBy`
|
|
14
|
+
* union for the exit reason.
|
|
15
|
+
*
|
|
16
|
+
* Token accounting: the budget cap is a TOKEN hard-cap, enforced after each
|
|
17
|
+
* turn from the usage events parsed off the agent's stream-json output. Token
|
|
18
|
+
* extraction reuses `extractUsageEvents` from budget/enforce.ts (read-only
|
|
19
|
+
* import) rather than re-implementing the per-provider parsing.
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from 'child_process';
|
|
22
|
+
import { randomUUID } from 'crypto';
|
|
23
|
+
import * as fs from 'fs';
|
|
24
|
+
import * as path from 'path';
|
|
25
|
+
import { buildExecCommand, buildExecEnv } from './exec.js';
|
|
26
|
+
import { extractUsageEvents } from './budget/enforce.js';
|
|
27
|
+
import { parseTimeout } from './routines.js';
|
|
28
|
+
import { writeCheckpoint } from './checkpoint.js';
|
|
29
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
30
|
+
/** Path to a run's loop-signal.json. */
|
|
31
|
+
export function loopSignalPath(runDir) {
|
|
32
|
+
return path.join(runDir, 'loop-signal.json');
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Build the prompt for iteration >= 2 so the agent CONTINUES the prior
|
|
36
|
+
* iteration's conversation instead of starting fresh.
|
|
37
|
+
*
|
|
38
|
+
* This reuses the repo's established cross-process Claude-continuity mechanism —
|
|
39
|
+
* the `/continue <id>` skill (see `buildFallbackPrompt` in exec.ts, which hands
|
|
40
|
+
* a rate-limit successor `/continue ${prevSessionId}`). The skill loads the
|
|
41
|
+
* prior transcript via `agents sessions <id>`, so continuity does NOT depend on
|
|
42
|
+
* the provider's native session being "active"; it reads the transcript off
|
|
43
|
+
* disk. That is why each loop iteration can safely pin a FRESH session id (the
|
|
44
|
+
* `--session-id` flag CREATES a session — re-passing one errors "Session ID
|
|
45
|
+
* already in use") while still threading the conversation forward via the
|
|
46
|
+
* prior id.
|
|
47
|
+
*
|
|
48
|
+
* The original entrypoint is re-appended after the continue directive so the
|
|
49
|
+
* agent both recalls the prior turn AND knows what to do this iteration.
|
|
50
|
+
*/
|
|
51
|
+
export function buildLoopContinuePrompt(prevSessionId, entrypoint) {
|
|
52
|
+
return `/continue ${prevSessionId}\n\n${entrypoint}`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Resolve a loop interval string to milliseconds. `"0"` is an explicit
|
|
56
|
+
* back-to-back run (0ms). Any other string must parse via parseTimeout
|
|
57
|
+
* (e.g. "30m", "1h"); an unparseable value (e.g. "30s", "5", "abc") is a
|
|
58
|
+
* configuration error and must NOT silently coalesce to 0 (which would run the
|
|
59
|
+
* loop full-speed on a typo). Throws on bad input; validate at config build
|
|
60
|
+
* time (validateLoopInterval) so the error surfaces before the loop starts.
|
|
61
|
+
*/
|
|
62
|
+
export function parseLoopInterval(interval) {
|
|
63
|
+
if (interval === undefined)
|
|
64
|
+
return 0;
|
|
65
|
+
if (interval.trim() === '0')
|
|
66
|
+
return 0;
|
|
67
|
+
const ms = parseTimeout(interval);
|
|
68
|
+
if (ms === null) {
|
|
69
|
+
throw new Error(`Invalid loop interval '${interval}'. Use "0" for back-to-back or a duration like "30m", "1h", "2h30m" (units: w/d/h/m).`);
|
|
70
|
+
}
|
|
71
|
+
return ms;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Read and parse loop-signal.json. Returns null when the file is absent or
|
|
75
|
+
* unparseable — the caller treats null as fail-closed (continue:false).
|
|
76
|
+
*/
|
|
77
|
+
export function readLoopSignal(runDir) {
|
|
78
|
+
const file = loopSignalPath(runDir);
|
|
79
|
+
if (!fs.existsSync(file))
|
|
80
|
+
return null;
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
83
|
+
if (!parsed || typeof parsed !== 'object')
|
|
84
|
+
return null;
|
|
85
|
+
return { continue: parsed.continue === true, reason: typeof parsed.reason === 'string' ? parsed.reason : undefined };
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Delete loop-signal.json so a stale signal never carries into the next iteration. */
|
|
92
|
+
export function clearLoopSignal(runDir) {
|
|
93
|
+
const file = loopSignalPath(runDir);
|
|
94
|
+
try {
|
|
95
|
+
if (fs.existsSync(file))
|
|
96
|
+
fs.unlinkSync(file);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
/* best-effort: a missing file is the desired state anyway. */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Default per-iteration runner: spawn the agent, tee stdout, and sum token usage
|
|
104
|
+
* off the stream. This is a purpose-built token-capturing spawn for the loop's
|
|
105
|
+
* budget guard, not a re-implementation of exec's fallback/budget machinery —
|
|
106
|
+
* it reuses `buildExecCommand` / `buildExecEnv` (the canonical command/env
|
|
107
|
+
* builders) and `extractUsageEvents` (the canonical stream parser). The agent
|
|
108
|
+
* is forced to JSON/headless so the usage stream is parseable.
|
|
109
|
+
*/
|
|
110
|
+
export function defaultRunIteration(options) {
|
|
111
|
+
// Force the stream-json output the usage parser needs; a loop iteration is
|
|
112
|
+
// always headless (re-injected programmatically, never an interactive TUI).
|
|
113
|
+
const execOptions = { ...options, json: true, headless: true, interactive: false };
|
|
114
|
+
const cmd = buildExecCommand(execOptions);
|
|
115
|
+
const [executable, ...args] = cmd;
|
|
116
|
+
const env = buildExecEnv(execOptions);
|
|
117
|
+
const cwd = execOptions.cwd || process.cwd();
|
|
118
|
+
const model = execOptions.model ?? `${execOptions.agent}-default`;
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const useShell = process.platform === 'win32' && (!path.isAbsolute(executable) || executable.endsWith('.cmd'));
|
|
121
|
+
const child = spawn(executable, args, {
|
|
122
|
+
cwd,
|
|
123
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
124
|
+
env,
|
|
125
|
+
shell: useShell,
|
|
126
|
+
});
|
|
127
|
+
let tokens = 0;
|
|
128
|
+
let pending = '';
|
|
129
|
+
if (child.stdout) {
|
|
130
|
+
child.stdout.pipe(process.stdout);
|
|
131
|
+
child.stdout.on('data', (chunk) => {
|
|
132
|
+
const { events, rest } = extractUsageEvents(chunk.toString('utf-8'), pending, model, execOptions.agent);
|
|
133
|
+
pending = rest;
|
|
134
|
+
for (const ev of events) {
|
|
135
|
+
tokens += (ev.inputTokens ?? 0) + (ev.outputTokens ?? 0)
|
|
136
|
+
+ (ev.cacheReadTokens ?? 0) + (ev.cacheCreationTokens ?? 0);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (child.stderr)
|
|
141
|
+
child.stderr.pipe(process.stderr);
|
|
142
|
+
child.on('error', (err) => reject(err));
|
|
143
|
+
child.on('close', (code, signal) => {
|
|
144
|
+
resolve({ exitCode: code ?? (signal ? 1 : 0), tokens });
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Run the autonomous loop. Returns when a guard trips, the until-condition is
|
|
150
|
+
* met, the iteration cap is reached, or a signal arrives.
|
|
151
|
+
*
|
|
152
|
+
* stoppedBy semantics:
|
|
153
|
+
* - `condition-met` — until=signal and the signal said stop (continue:false
|
|
154
|
+
* OR the file was absent/corrupt → fail-closed).
|
|
155
|
+
* - `budget` — cumulative tokens crossed the budget cap (checked after
|
|
156
|
+
* each turn, outside the agent).
|
|
157
|
+
* - `max` — ran maxIterations iterations without any earlier stop.
|
|
158
|
+
* - `signal` — SIGINT/SIGTERM arrived; checkpoint is written before exit.
|
|
159
|
+
* - `error` — an iteration threw or exited non-zero.
|
|
160
|
+
*/
|
|
161
|
+
export async function runLoop(execOptions, loop, ctx, deps) {
|
|
162
|
+
const runIteration = deps?.runIteration ?? defaultRunIteration;
|
|
163
|
+
const sleep = deps?.sleep ?? defaultSleep;
|
|
164
|
+
const persist = deps?.writeCheckpoint ?? writeCheckpoint;
|
|
165
|
+
const startedAt = Date.now();
|
|
166
|
+
const maxIterations = loop.maxIterations ?? 1000;
|
|
167
|
+
const intervalMs = parseLoopInterval(loop.interval);
|
|
168
|
+
// Per-iteration session pinning (issue #332). `--session-id` CREATES a
|
|
169
|
+
// session, so each iteration must pin a DISTINCT id — re-passing one errors
|
|
170
|
+
// "Session ID already in use". Iteration 1 pins `firstSessionId`; iteration
|
|
171
|
+
// >= 2 mints a fresh id AND injects `/continue <prior id>` so the agent
|
|
172
|
+
// threads the prior conversation forward (see buildLoopContinuePrompt).
|
|
173
|
+
//
|
|
174
|
+
// `prevSessionId` is the id whose transcript the NEXT iteration continues
|
|
175
|
+
// from. On a resume it is ctx.sessionId (the killed run's last session);
|
|
176
|
+
// on a fresh run it starts undefined and is set after iteration 1.
|
|
177
|
+
const firstSessionId = randomUUID();
|
|
178
|
+
let prevSessionId = ctx.sessionId;
|
|
179
|
+
// The session id recorded in the checkpoint is the most recent iteration's id
|
|
180
|
+
// (what a resume must continue from). Seeded to the resume id or iter-1 id.
|
|
181
|
+
let lastIterationSessionId = ctx.sessionId ?? firstSessionId;
|
|
182
|
+
const startIteration = ctx.startIteration ?? 1;
|
|
183
|
+
// The loop re-injects the entrypoint every iteration, so a prompt is required.
|
|
184
|
+
// The command layer enforces this before dispatch; assert it here so the
|
|
185
|
+
// continuity prompt-builder has a defined entrypoint to thread.
|
|
186
|
+
if (execOptions.prompt === undefined) {
|
|
187
|
+
throw new Error('runLoop requires execOptions.prompt — the loop re-injects the entrypoint each iteration.');
|
|
188
|
+
}
|
|
189
|
+
const entrypointPrompt = execOptions.prompt;
|
|
190
|
+
// `/continue` continuity only applies to claude (the skill + native resume
|
|
191
|
+
// surface). Other agents run each iteration as an independent fresh
|
|
192
|
+
// conversation — warn so the lost continuity is never silent.
|
|
193
|
+
const continuitySupported = ctx.agent === 'claude';
|
|
194
|
+
if (!continuitySupported && maxIterations !== 1) {
|
|
195
|
+
process.stderr.write(`[loop] WARNING: cross-iteration conversation continuity applies to claude only. ` +
|
|
196
|
+
`Each ${ctx.agent} iteration runs as an independent fresh conversation (no /continue handoff).\n`);
|
|
197
|
+
}
|
|
198
|
+
let tokens = ctx.startTokens ?? 0;
|
|
199
|
+
let lastSignal;
|
|
200
|
+
let stopSignal = false;
|
|
201
|
+
const onSig = () => { stopSignal = true; };
|
|
202
|
+
process.once('SIGINT', onSig);
|
|
203
|
+
process.once('SIGTERM', onSig);
|
|
204
|
+
const checkpoint = (iteration) => {
|
|
205
|
+
const now = new Date().toISOString();
|
|
206
|
+
persist({
|
|
207
|
+
id: ctx.runId,
|
|
208
|
+
agent: ctx.agent,
|
|
209
|
+
version: ctx.version,
|
|
210
|
+
prompt: entrypointPrompt,
|
|
211
|
+
// Resume must continue from the LAST iteration's conversation, so the
|
|
212
|
+
// checkpoint records that iteration's session id (the one a future
|
|
213
|
+
// `/continue` should thread from), not a single pinned id.
|
|
214
|
+
sessionId: lastIterationSessionId,
|
|
215
|
+
iteration,
|
|
216
|
+
loop,
|
|
217
|
+
loopSignal: lastSignal,
|
|
218
|
+
cumulativeTokens: tokens,
|
|
219
|
+
createdAt: now,
|
|
220
|
+
updatedAt: now,
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
const done = (iterations, stoppedBy) => ({
|
|
224
|
+
iterations,
|
|
225
|
+
stoppedBy,
|
|
226
|
+
elapsedMs: Date.now() - startedAt,
|
|
227
|
+
tokens,
|
|
228
|
+
lastSignal,
|
|
229
|
+
});
|
|
230
|
+
try {
|
|
231
|
+
let iteration = startIteration;
|
|
232
|
+
for (; iteration <= maxIterations; iteration++) {
|
|
233
|
+
if (stopSignal) {
|
|
234
|
+
checkpoint(iteration - 1);
|
|
235
|
+
return done(iteration - startIteration, 'signal');
|
|
236
|
+
}
|
|
237
|
+
// Pin a DISTINCT session id every iteration (`--session-id` CREATES a
|
|
238
|
+
// session; re-passing one errors "Session ID already in use"). The first
|
|
239
|
+
// executed iteration of a fresh run reuses firstSessionId; every later
|
|
240
|
+
// iteration mints a new id.
|
|
241
|
+
const iterationSessionId = prevSessionId === undefined ? firstSessionId : randomUUID();
|
|
242
|
+
// Continuity: when a prior iteration exists (prevSessionId set) and the
|
|
243
|
+
// agent supports it, thread the conversation forward via the established
|
|
244
|
+
// `/continue <prior id>` prompt-injection. Otherwise re-inject the bare
|
|
245
|
+
// entrypoint. prevSessionId is set after iteration 1 of a fresh run, or
|
|
246
|
+
// carried in from ctx.sessionId on a resume.
|
|
247
|
+
const iterationPrompt = prevSessionId !== undefined && continuitySupported
|
|
248
|
+
? buildLoopContinuePrompt(prevSessionId, entrypointPrompt)
|
|
249
|
+
: entrypointPrompt;
|
|
250
|
+
// AGENTS_LOOP_SIGNAL / AGENTS_RUN_DIR: tell the entrypoint where to write
|
|
251
|
+
// loop-signal.json so the guard (read OUTSIDE the agent) can see it. The
|
|
252
|
+
// agent never decides whether to continue — it only writes its vote.
|
|
253
|
+
const iterOptions = {
|
|
254
|
+
...execOptions,
|
|
255
|
+
prompt: iterationPrompt,
|
|
256
|
+
sessionId: iterationSessionId,
|
|
257
|
+
env: {
|
|
258
|
+
...execOptions.env,
|
|
259
|
+
AGENTS_RUN_DIR: ctx.runDir,
|
|
260
|
+
AGENTS_LOOP_SIGNAL: loopSignalPath(ctx.runDir),
|
|
261
|
+
AGENTS_LOOP_ITERATION: String(iteration),
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
let result;
|
|
265
|
+
try {
|
|
266
|
+
result = await runIteration(iterOptions);
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
// A SIGINT/SIGTERM mid-iteration kills the child; the resulting throw
|
|
270
|
+
// is a signal stop, not an error. Check the stop flag first.
|
|
271
|
+
if (stopSignal) {
|
|
272
|
+
checkpoint(iteration - 1);
|
|
273
|
+
return done(iteration - startIteration, 'signal');
|
|
274
|
+
}
|
|
275
|
+
checkpoint(iteration - 1);
|
|
276
|
+
process.stderr.write(`[loop] iteration ${iteration} failed: ${err.message}\n`);
|
|
277
|
+
return done(iteration - startIteration, 'error');
|
|
278
|
+
}
|
|
279
|
+
// This iteration's conversation is now on disk under iterationSessionId.
|
|
280
|
+
// The next iteration continues from it; a checkpoint records it for resume.
|
|
281
|
+
prevSessionId = iterationSessionId;
|
|
282
|
+
lastIterationSessionId = iterationSessionId;
|
|
283
|
+
tokens += result.tokens;
|
|
284
|
+
const completed = iteration - startIteration + 1;
|
|
285
|
+
// until=signal: read the signal the entrypoint wrote this iteration.
|
|
286
|
+
// Absent/corrupt OR continue:false => stop (fail-closed).
|
|
287
|
+
if (loop.until === 'signal') {
|
|
288
|
+
lastSignal = readLoopSignal(ctx.runDir) ?? { continue: false, reason: 'loop-signal.json absent (fail-closed)' };
|
|
289
|
+
clearLoopSignal(ctx.runDir);
|
|
290
|
+
if (!lastSignal.continue) {
|
|
291
|
+
checkpoint(iteration);
|
|
292
|
+
return done(completed, 'condition-met');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// Budget (token hard-cap), enforced after the turn — outside the agent.
|
|
296
|
+
if (loop.budget !== undefined && tokens >= loop.budget) {
|
|
297
|
+
checkpoint(iteration);
|
|
298
|
+
return done(completed, 'budget');
|
|
299
|
+
}
|
|
300
|
+
// A non-zero exit is a hard error — UNLESS a signal arrived mid-iteration.
|
|
301
|
+
// Ctrl-C kills the child (non-zero exit / SIGINT exit code); that is a
|
|
302
|
+
// 'signal' stop (exit 130), not an 'error'. Check the stop flag first.
|
|
303
|
+
if (result.exitCode !== 0) {
|
|
304
|
+
if (stopSignal) {
|
|
305
|
+
checkpoint(iteration);
|
|
306
|
+
return done(completed, 'signal');
|
|
307
|
+
}
|
|
308
|
+
checkpoint(iteration);
|
|
309
|
+
process.stderr.write(`[loop] iteration ${iteration} exited ${result.exitCode}\n`);
|
|
310
|
+
return done(completed, 'error');
|
|
311
|
+
}
|
|
312
|
+
checkpoint(iteration);
|
|
313
|
+
if (stopSignal) {
|
|
314
|
+
return done(completed, 'signal');
|
|
315
|
+
}
|
|
316
|
+
// Pace between iterations. Skip the sleep after the final iteration.
|
|
317
|
+
if (iteration < maxIterations && intervalMs > 0) {
|
|
318
|
+
await sleep(intervalMs);
|
|
319
|
+
if (stopSignal) {
|
|
320
|
+
return done(completed, 'signal');
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return done(maxIterations - startIteration + 1, 'max');
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
process.off('SIGINT', onSig);
|
|
328
|
+
process.off('SIGTERM', onSig);
|
|
329
|
+
}
|
|
330
|
+
}
|
package/dist/lib/mcp.d.ts
CHANGED
|
@@ -66,6 +66,13 @@ export declare function installMcpConfigCentrally(sourcePath: string): {
|
|
|
66
66
|
export declare function getMcpServersByName(names?: string[], options?: {
|
|
67
67
|
cwd?: string;
|
|
68
68
|
}): InstalledMcpServer[];
|
|
69
|
+
/**
|
|
70
|
+
* Assemble the JSON payload Claude's `--mcp-config` flag expects from a set of
|
|
71
|
+
* installed MCP servers: `{ "mcpServers": { "<name>": { command, args, env } | { url } } }`.
|
|
72
|
+
* Pure — takes servers, returns a JSON string. The caller writes it to an
|
|
73
|
+
* ephemeral file and passes the path to buildExecCommand.
|
|
74
|
+
*/
|
|
75
|
+
export declare function buildWorkflowMcpConfig(servers: InstalledMcpServer[]): string;
|
|
69
76
|
export declare function registerMcpCommandToTargets(targets: {
|
|
70
77
|
directAgents: AgentId[];
|
|
71
78
|
versionSelections: Map<AgentId, string[]>;
|
package/dist/lib/mcp.js
CHANGED
|
@@ -166,6 +166,30 @@ export function getMcpServersByName(names, options = {}) {
|
|
|
166
166
|
}
|
|
167
167
|
return allServers.filter((server) => names.includes(server.name));
|
|
168
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Assemble the JSON payload Claude's `--mcp-config` flag expects from a set of
|
|
171
|
+
* installed MCP servers: `{ "mcpServers": { "<name>": { command, args, env } | { url } } }`.
|
|
172
|
+
* Pure — takes servers, returns a JSON string. The caller writes it to an
|
|
173
|
+
* ephemeral file and passes the path to buildExecCommand.
|
|
174
|
+
*/
|
|
175
|
+
export function buildWorkflowMcpConfig(servers) {
|
|
176
|
+
const mcpServers = {};
|
|
177
|
+
for (const server of servers) {
|
|
178
|
+
const cfg = server.config;
|
|
179
|
+
if (cfg.transport === 'http') {
|
|
180
|
+
mcpServers[server.name] = { url: cfg.url };
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
const entry = { command: cfg.command };
|
|
184
|
+
if (cfg.args && cfg.args.length > 0)
|
|
185
|
+
entry.args = cfg.args;
|
|
186
|
+
if (cfg.env && Object.keys(cfg.env).length > 0)
|
|
187
|
+
entry.env = cfg.env;
|
|
188
|
+
mcpServers[server.name] = entry;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return JSON.stringify({ mcpServers });
|
|
192
|
+
}
|
|
169
193
|
/**
|
|
170
194
|
* Install MCP server using Claude CLI.
|
|
171
195
|
* Uses: claude mcp add --scope user --transport <type> <name> [--env K=V]... -- <cmd> [args...]
|
package/dist/lib/models.d.ts
CHANGED
|
@@ -85,6 +85,17 @@ export interface ResolvedModel {
|
|
|
85
85
|
* - If `requested` is unknown to our extractor, we forward it and warn.
|
|
86
86
|
*/
|
|
87
87
|
export declare function resolveModel(agent: AgentId, version: string, requested: string): ResolvedModel;
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the model id an `agents run` will ACTUALLY use, for cost estimation
|
|
90
|
+
* (issue #346). The run path resolves the model in this precedence:
|
|
91
|
+
* 1. explicit `--model` (or profile/workflow/runDefaults value) — `requested`
|
|
92
|
+
* 2. otherwise the agent CLI's own built-in default, which we read from the
|
|
93
|
+
* extracted catalog's `isDefault` model.
|
|
94
|
+
* Returns null only when we have neither — the caller must then treat the
|
|
95
|
+
* estimate as unpriced rather than silently using an unpriced placeholder id
|
|
96
|
+
* like `${agent}-default`.
|
|
97
|
+
*/
|
|
98
|
+
export declare function resolveEffectiveModel(agent: AgentId, version: string, requested?: string): string | null;
|
|
88
99
|
/**
|
|
89
100
|
* Build the per-agent CLI flags for a unified reasoning effort knob.
|
|
90
101
|
*
|
package/dist/lib/models.js
CHANGED
|
@@ -710,6 +710,27 @@ export function resolveModel(agent, version, requested) {
|
|
|
710
710
|
warning: `model "${requested}" not in known catalog for ${agent}@${version}; forwarding as-is${hint}`,
|
|
711
711
|
};
|
|
712
712
|
}
|
|
713
|
+
/**
|
|
714
|
+
* Resolve the model id an `agents run` will ACTUALLY use, for cost estimation
|
|
715
|
+
* (issue #346). The run path resolves the model in this precedence:
|
|
716
|
+
* 1. explicit `--model` (or profile/workflow/runDefaults value) — `requested`
|
|
717
|
+
* 2. otherwise the agent CLI's own built-in default, which we read from the
|
|
718
|
+
* extracted catalog's `isDefault` model.
|
|
719
|
+
* Returns null only when we have neither — the caller must then treat the
|
|
720
|
+
* estimate as unpriced rather than silently using an unpriced placeholder id
|
|
721
|
+
* like `${agent}-default`.
|
|
722
|
+
*/
|
|
723
|
+
export function resolveEffectiveModel(agent, version, requested) {
|
|
724
|
+
if (requested && requested.trim() !== '') {
|
|
725
|
+
const resolved = resolveModel(agent, version, requested);
|
|
726
|
+
return resolved.canonical ?? resolved.forwarded;
|
|
727
|
+
}
|
|
728
|
+
const catalog = getModelCatalog(agent, version);
|
|
729
|
+
if (!catalog)
|
|
730
|
+
return null;
|
|
731
|
+
const def = catalog.models.find((m) => m.isDefault);
|
|
732
|
+
return def?.id ?? null;
|
|
733
|
+
}
|
|
713
734
|
/** Find the closest matching model ids/aliases using edit distance. */
|
|
714
735
|
function pickSuggestions(requested, catalog) {
|
|
715
736
|
const all = [...catalog.models.map((m) => m.id), ...Object.keys(catalog.aliases)];
|
package/dist/lib/plugins.js
CHANGED
|
@@ -13,6 +13,7 @@ import * as path from 'path';
|
|
|
13
13
|
import { execFileSync } from 'child_process';
|
|
14
14
|
import { getPluginsDir, getTrashPluginsDir, getExtraPluginsDir, getProjectPluginsDir } from './state.js';
|
|
15
15
|
import { IS_WINDOWS, isWindowsAbsolutePath, homeDir } from './platform/index.js';
|
|
16
|
+
import { assertSafeGitTransport } from './git.js';
|
|
16
17
|
import { listInstalledVersions, getVersionHomePath } from './versions.js';
|
|
17
18
|
import { AGENTS, agentConfigDirName } from './agents.js';
|
|
18
19
|
import { capableAgents, isCapable } from './capabilities.js';
|
|
@@ -1078,11 +1079,13 @@ export async function installPlugin(spec) {
|
|
|
1078
1079
|
fs.cpSync(resolvedSource, targetRoot, { recursive: true });
|
|
1079
1080
|
}
|
|
1080
1081
|
else {
|
|
1081
|
-
// Git clone
|
|
1082
|
+
// Git clone. Validate the transport (blocks ext::/file:///http:///leading-"-")
|
|
1083
|
+
// and pass "--" so the source can never be parsed as a git option.
|
|
1084
|
+
assertSafeGitTransport(resolvedSource);
|
|
1082
1085
|
if (fs.existsSync(targetRoot)) {
|
|
1083
1086
|
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
1084
1087
|
}
|
|
1085
|
-
execFileSync('git', ['clone', '--depth', '1', resolvedSource, targetRoot], {
|
|
1088
|
+
execFileSync('git', ['clone', '--depth', '1', '--', resolvedSource, targetRoot], {
|
|
1086
1089
|
stdio: 'pipe',
|
|
1087
1090
|
});
|
|
1088
1091
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** A single usage record: one model and the tokens it consumed in each direction. */
|
|
2
|
+
export interface TokenUsage {
|
|
3
|
+
model?: string;
|
|
4
|
+
inputTokens?: number;
|
|
5
|
+
outputTokens?: number;
|
|
6
|
+
cacheReadTokens?: number;
|
|
7
|
+
cacheCreationTokens?: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* USD cost of one usage record. Returns 0 when the model is missing or unpriced
|
|
11
|
+
* (cost is additive — an unknown model contributes nothing, not NaN). Cache
|
|
12
|
+
* read/write tokens are priced at their dedicated rates when the table exposes
|
|
13
|
+
* them, otherwise they fall back to the input rate (the standard LiteLLM
|
|
14
|
+
* convention for models that don't publish a separate cache price).
|
|
15
|
+
*/
|
|
16
|
+
export declare function costOfUsage(u: TokenUsage): number;
|
|
17
|
+
/** Sum the USD cost of every usage record in a session. */
|
|
18
|
+
export declare function costOfSession(usages: TokenUsage[]): number;
|
|
19
|
+
/**
|
|
20
|
+
* Format a USD amount for human display. Cents-precise, with a "<$0.01" floor
|
|
21
|
+
* so tiny-but-nonzero sessions don't render as "$0.00" and read as free.
|
|
22
|
+
*/
|
|
23
|
+
export declare function formatUsd(usd: number): string;
|
|
24
|
+
/** Token bundle accepted by the #346 estimator/actual-cost helpers. */
|
|
25
|
+
interface EstimatorTokens {
|
|
26
|
+
inputTokens: number;
|
|
27
|
+
outputTokens: number;
|
|
28
|
+
cacheReadTokens?: number;
|
|
29
|
+
cacheCreationTokens?: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Pre-flight cost estimate for a model + token bundle (issue #346's budget
|
|
33
|
+
* gate). Returns the resolved canonical model id (`modelMatched`) so callers
|
|
34
|
+
* can warn when an estimate fell back to $0 because the model is unpriced.
|
|
35
|
+
*/
|
|
36
|
+
export declare function estimateCost(model: string, tokens: EstimatorTokens): {
|
|
37
|
+
usd: number;
|
|
38
|
+
modelMatched: string | null;
|
|
39
|
+
};
|
|
40
|
+
/** Actual (post-hoc) cost of a model + observed usage. Thin alias over costOfUsage. */
|
|
41
|
+
export declare function actualCost(model: string, usage: EstimatorTokens): {
|
|
42
|
+
usd: number;
|
|
43
|
+
};
|
|
44
|
+
/** True when the model resolves to a priced entry in the table. */
|
|
45
|
+
export declare function isModelPriced(model: string): boolean;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-usage → USD cost math, built on the offline pricing table.
|
|
3
|
+
*
|
|
4
|
+
* `costOfUsage` is the single multiply-by-price primitive every other helper
|
|
5
|
+
* (and issue #346's budget pre-flight estimator) routes through. It returns 0
|
|
6
|
+
* for unknown/unpriced models rather than throwing — cost is additive, and a
|
|
7
|
+
* single unknown model in a session shouldn't blow up the whole rollup.
|
|
8
|
+
*/
|
|
9
|
+
import { getModelPricing } from './table.js';
|
|
10
|
+
/**
|
|
11
|
+
* USD cost of one usage record. Returns 0 when the model is missing or unpriced
|
|
12
|
+
* (cost is additive — an unknown model contributes nothing, not NaN). Cache
|
|
13
|
+
* read/write tokens are priced at their dedicated rates when the table exposes
|
|
14
|
+
* them, otherwise they fall back to the input rate (the standard LiteLLM
|
|
15
|
+
* convention for models that don't publish a separate cache price).
|
|
16
|
+
*/
|
|
17
|
+
export function costOfUsage(u) {
|
|
18
|
+
if (!u.model)
|
|
19
|
+
return 0;
|
|
20
|
+
const pricing = getModelPricing(u.model);
|
|
21
|
+
if (!pricing)
|
|
22
|
+
return 0;
|
|
23
|
+
const input = u.inputTokens ?? 0;
|
|
24
|
+
const output = u.outputTokens ?? 0;
|
|
25
|
+
const cacheRead = u.cacheReadTokens ?? 0;
|
|
26
|
+
const cacheWrite = u.cacheCreationTokens ?? 0;
|
|
27
|
+
const cacheReadRate = pricing.cacheReadPerToken ?? pricing.inputPerToken;
|
|
28
|
+
const cacheWriteRate = pricing.cacheWritePerToken ?? pricing.inputPerToken;
|
|
29
|
+
return (input * pricing.inputPerToken +
|
|
30
|
+
output * pricing.outputPerToken +
|
|
31
|
+
cacheRead * cacheReadRate +
|
|
32
|
+
cacheWrite * cacheWriteRate);
|
|
33
|
+
}
|
|
34
|
+
/** Sum the USD cost of every usage record in a session. */
|
|
35
|
+
export function costOfSession(usages) {
|
|
36
|
+
let total = 0;
|
|
37
|
+
for (const u of usages)
|
|
38
|
+
total += costOfUsage(u);
|
|
39
|
+
return total;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Format a USD amount for human display. Cents-precise, with a "<$0.01" floor
|
|
43
|
+
* so tiny-but-nonzero sessions don't render as "$0.00" and read as free.
|
|
44
|
+
*/
|
|
45
|
+
export function formatUsd(usd) {
|
|
46
|
+
if (!Number.isFinite(usd) || usd <= 0)
|
|
47
|
+
return '$0.00';
|
|
48
|
+
if (usd < 0.01)
|
|
49
|
+
return '<$0.01';
|
|
50
|
+
return `$${usd.toFixed(2)}`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Pre-flight cost estimate for a model + token bundle (issue #346's budget
|
|
54
|
+
* gate). Returns the resolved canonical model id (`modelMatched`) so callers
|
|
55
|
+
* can warn when an estimate fell back to $0 because the model is unpriced.
|
|
56
|
+
*/
|
|
57
|
+
export function estimateCost(model, tokens) {
|
|
58
|
+
const pricing = getModelPricing(model);
|
|
59
|
+
const usd = costOfUsage({ model, ...tokens });
|
|
60
|
+
// modelMatched is the input model when priced, null when unknown — callers
|
|
61
|
+
// only need the priced/unpriced signal, not the internal canonical key.
|
|
62
|
+
return { usd, modelMatched: pricing ? model : null };
|
|
63
|
+
}
|
|
64
|
+
/** Actual (post-hoc) cost of a model + observed usage. Thin alias over costOfUsage. */
|
|
65
|
+
export function actualCost(model, usage) {
|
|
66
|
+
return { usd: costOfUsage({ model, ...usage }) };
|
|
67
|
+
}
|
|
68
|
+
/** True when the model resolves to a priced entry in the table. */
|
|
69
|
+
export function isModelPriced(model) {
|
|
70
|
+
return getModelPricing(model) !== null;
|
|
71
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical, reusable pricing module.
|
|
3
|
+
*
|
|
4
|
+
* Public surface re-exported here is the contract issue #346 (budget
|
|
5
|
+
* enforcement) imports against — keep it stable.
|
|
6
|
+
*/
|
|
7
|
+
export { type ModelPricing, PRICING_VERSION, getModelPricing, listPricedModels, } from './table.js';
|
|
8
|
+
export { type TokenUsage, costOfUsage, costOfSession, formatUsd, estimateCost, actualCost, isModelPriced, } from './cost.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical, reusable pricing module.
|
|
3
|
+
*
|
|
4
|
+
* Public surface re-exported here is the contract issue #346 (budget
|
|
5
|
+
* enforcement) imports against — keep it stable.
|
|
6
|
+
*/
|
|
7
|
+
export { PRICING_VERSION, getModelPricing, listPricedModels, } from './table.js';
|
|
8
|
+
export { costOfUsage, costOfSession, formatUsd, estimateCost, actualCost, isModelPriced, } from './cost.js';
|