@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/dist/commands/budget.d.ts +14 -0
  4. package/dist/commands/budget.js +137 -0
  5. package/dist/commands/cost.d.ts +12 -0
  6. package/dist/commands/cost.js +139 -0
  7. package/dist/commands/exec.d.ts +20 -0
  8. package/dist/commands/exec.js +382 -5
  9. package/dist/commands/secrets.d.ts +15 -0
  10. package/dist/commands/secrets.js +250 -4
  11. package/dist/commands/sessions.js +4 -0
  12. package/dist/index.js +4 -0
  13. package/dist/lib/budget/config.d.ts +9 -0
  14. package/dist/lib/budget/config.js +115 -0
  15. package/dist/lib/budget/enforce.d.ts +94 -0
  16. package/dist/lib/budget/enforce.js +151 -0
  17. package/dist/lib/budget/ledger.d.ts +61 -0
  18. package/dist/lib/budget/ledger.js +107 -0
  19. package/dist/lib/budget/preflight.d.ts +110 -0
  20. package/dist/lib/budget/preflight.js +200 -0
  21. package/dist/lib/checkpoint.d.ts +54 -0
  22. package/dist/lib/checkpoint.js +56 -0
  23. package/dist/lib/cloud/rush.js +18 -0
  24. package/dist/lib/exec.d.ts +36 -0
  25. package/dist/lib/exec.js +192 -4
  26. package/dist/lib/git.d.ts +18 -0
  27. package/dist/lib/git.js +67 -4
  28. package/dist/lib/loop.d.ts +145 -0
  29. package/dist/lib/loop.js +330 -0
  30. package/dist/lib/mcp.d.ts +7 -0
  31. package/dist/lib/mcp.js +24 -0
  32. package/dist/lib/models.d.ts +11 -0
  33. package/dist/lib/models.js +21 -0
  34. package/dist/lib/plugins.js +5 -2
  35. package/dist/lib/pricing/cost.d.ts +46 -0
  36. package/dist/lib/pricing/cost.js +71 -0
  37. package/dist/lib/pricing/index.d.ts +8 -0
  38. package/dist/lib/pricing/index.js +8 -0
  39. package/dist/lib/pricing/prices.json +138 -0
  40. package/dist/lib/pricing/table.d.ts +17 -0
  41. package/dist/lib/pricing/table.js +73 -0
  42. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  43. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  44. package/dist/lib/secrets/agent.d.ts +134 -0
  45. package/dist/lib/secrets/agent.js +501 -0
  46. package/dist/lib/secrets/bundles.d.ts +21 -0
  47. package/dist/lib/secrets/bundles.js +43 -0
  48. package/dist/lib/session/db.d.ts +40 -0
  49. package/dist/lib/session/db.js +84 -2
  50. package/dist/lib/session/discover.d.ts +2 -0
  51. package/dist/lib/session/discover.js +126 -2
  52. package/dist/lib/session/render.d.ts +2 -0
  53. package/dist/lib/session/render.js +1 -1
  54. package/dist/lib/session/types.d.ts +4 -0
  55. package/dist/lib/teams/agents.d.ts +32 -0
  56. package/dist/lib/teams/agents.js +66 -3
  57. package/dist/lib/teams/api.js +20 -0
  58. package/dist/lib/teams/parsers.js +16 -4
  59. package/dist/lib/types.d.ts +48 -0
  60. package/dist/lib/workflows.d.ts +56 -0
  61. package/dist/lib/workflows.js +72 -5
  62. package/package.json +2 -1
@@ -7,9 +7,12 @@
7
7
  */
8
8
  import chalk from 'chalk';
9
9
  import * as fs from 'fs';
10
- import { bundleExists, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readBundle, renameBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
10
+ import { spawnSync } from 'child_process';
11
+ import { bundleExists, bundleTier, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, renameBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
11
12
  import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
12
13
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
14
+ import { DEFAULT_TTL_MS, agentLoad, agentLock, agentStatus, ensureAgentRunning, runSecretsAgent, } from '../lib/secrets/agent.js';
15
+ import { parseDuration } from '../lib/hooks/cache.js';
13
16
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
14
17
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
15
18
  import { registerSecretsSyncCommands } from './secrets-sync.js';
@@ -130,6 +133,39 @@ function readStdinSync() {
130
133
  }
131
134
  return Buffer.concat(chunks).toString('utf-8').trim();
132
135
  }
136
+ /**
137
+ * SSH target for `export --to-ssh`: a bare ssh-config host alias (e.g. `yosemite-s0`)
138
+ * or `user@host`. The strict allowlist blocks shell metacharacters and a leading `-`
139
+ * so a target can't be smuggled in as an ssh argv flag.
140
+ */
141
+ export const SSH_TARGET_RE = /^[a-zA-Z0-9._-]+(@[a-zA-Z0-9._-]+)?$/;
142
+ export function assertValidSshTarget(host) {
143
+ if (!SSH_TARGET_RE.test(host)) {
144
+ throw new Error(`Invalid SSH target ${JSON.stringify(host)}. Expected a host alias or user@host (letters, digits, '.', '_', '-').`);
145
+ }
146
+ }
147
+ /** POSIX single-quote a string for safe interpolation into a remote shell command. */
148
+ function shellQuote(s) {
149
+ return `'${s.replace(/'/g, `'\\''`)}'`;
150
+ }
151
+ /**
152
+ * Serialize a resolved env map to `.env` lines that round-trip losslessly through
153
+ * `parseDotenv` on the remote: `KEY="VALUE"`. parseDotenv strips exactly one outer
154
+ * quote pair and takes the inner bytes verbatim (no unescaping), so any single-line
155
+ * value survives unchanged with no escaping. Newlines would break its line-based
156
+ * parse, so multi-line values are rejected rather than silently corrupted.
157
+ */
158
+ export function bundleEnvToDotenv(env) {
159
+ const lines = [];
160
+ for (const [k, v] of Object.entries(env)) {
161
+ if (/[\r\n]/.test(v)) {
162
+ throw new Error(`Key '${k}' has a multi-line value; the SSH .env transport can't carry newlines. ` +
163
+ `Set it directly on the remote with 'agents secrets add ${k} --value-stdin'.`);
164
+ }
165
+ lines.push(`${k}="${v}"`);
166
+ }
167
+ return lines.join('\n') + '\n';
168
+ }
133
169
  /** Strip ANSI escape sequences so padding can be computed on visible width. */
134
170
  function visibleWidth(s) {
135
171
  // eslint-disable-next-line no-control-regex
@@ -346,6 +382,9 @@ export function registerSecretsCommands(program) {
346
382
  # Eval the bundle into your current shell
347
383
  eval "$(agents secrets export prod --plaintext)"
348
384
 
385
+ # Push the bundle to remote machine(s) over SSH (lands as a native bundle there)
386
+ agents secrets export prod --to-ssh --host yosemite-s0 --host yosemite-s1 --force
387
+
349
388
  # Run a one-off command with secrets injected
350
389
  agents secrets exec prod -- ./deploy.sh
351
390
  `,
@@ -354,7 +393,15 @@ export function registerSecretsCommands(program) {
354
393
  never touch disk in plaintext. Every item is device-local and gated by Touch ID
355
394
  or device passcode; cross-machine sync is handled by 'agents secrets push/pull'.
356
395
 
396
+ Touch ID noise: macOS pops a prompt per bundle per process, so concurrent
397
+ agents each re-prompt. 'agents secrets unlock <bundle>' holds the resolved
398
+ bundle in a local agent after one prompt; later runs read it silently until
399
+ it expires (default 24h), you 'lock' it, or the screen locks. Nothing on disk.
400
+
357
401
  See also:
402
+ agents secrets unlock <bundle> hold a bundle after one Touch ID
403
+ agents secrets lock wipe held bundles (re-prompt next read)
404
+ agents secrets status show held bundles + when they lock
358
405
  agents secrets rotate <bundle> <key> rotate value, preserve metadata
359
406
  agents secrets import <bundle> --from .env bulk import from .env
360
407
  agents secrets import <bundle> --from-1password --vault <name>
@@ -365,6 +412,7 @@ export function registerSecretsCommands(program) {
365
412
  registerCommandGroups(cmd, [
366
413
  { title: 'Bundle commands', names: ['list', 'view', 'create', 'rename', 'describe', 'delete'] },
367
414
  { title: 'Secret commands', names: ['add', 'rotate', 'remove', 'import', 'export'] },
415
+ { title: 'Agent commands', names: ['unlock', 'lock', 'status', 'tier'] },
368
416
  { title: 'Raw item commands', names: ['get', 'set'] },
369
417
  { title: 'Sync commands', names: ['push', 'pull', 'remote-list'] },
370
418
  { title: 'Utilities', names: ['exec', 'generate', 'migrate-acl'] },
@@ -401,6 +449,8 @@ export function registerSecretsCommands(program) {
401
449
  console.log(chalk.gray(safePrint(bundle.description)));
402
450
  if (bundle.allow_exec)
403
451
  console.log(chalk.yellow('allow_exec: true'));
452
+ if (bundleTier(bundle) === 'session')
453
+ console.log(chalk.gray('tier: session (secrets-agent eligible)'));
404
454
  if (bundle.created_at)
405
455
  console.log(chalk.gray(`created_at: ${bundle.created_at} (${humanAge(bundle.created_at)})`));
406
456
  if (bundle.updated_at)
@@ -522,11 +572,13 @@ export function registerSecretsCommands(program) {
522
572
  .description('Create an empty bundle')
523
573
  .option('--description <text>', 'Free-form description')
524
574
  .option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
575
+ .option('--tier <tier>', 'secrets-agent tier: biometry (default) or session', 'biometry')
525
576
  .option('--force', 'Overwrite an existing bundle')
526
577
  .action(async (name, opts) => {
527
578
  try {
528
579
  const resolvedName = name ?? (await promptBundleName());
529
580
  validateBundleName(resolvedName);
581
+ const tier = parseTierOpt(opts.tier);
530
582
  if (bundleExists(resolvedName) && !opts.force) {
531
583
  console.error(chalk.red(`Bundle '${resolvedName}' already exists. Use --force to overwrite.`));
532
584
  process.exit(1);
@@ -535,10 +587,11 @@ export function registerSecretsCommands(program) {
535
587
  name: resolvedName,
536
588
  description: opts.description,
537
589
  allow_exec: opts.allowExec,
590
+ tier,
538
591
  vars: {},
539
592
  };
540
593
  writeBundle(bundle);
541
- console.log(chalk.green(`Bundle '${resolvedName}' created.`));
594
+ console.log(chalk.green(`Bundle '${resolvedName}' created${tier === 'session' ? ' (tier: session)' : ''}.`));
542
595
  console.log(chalk.gray(`Try: agents secrets add ${resolvedName} MY_KEY`));
543
596
  }
544
597
  catch (err) {
@@ -951,15 +1004,61 @@ Examples:
951
1004
  });
952
1005
  cmd
953
1006
  .command('export [bundle]')
954
- .description('Resolve a bundle and print KEY=VALUE lines, or push it to a 1Password vault with --to-1password.')
1007
+ .description('Resolve a bundle and print KEY=VALUE lines, push it to a 1Password vault with --to-1password, or push it to remote machine(s) over SSH with --to-ssh.')
955
1008
  .option('--plaintext', 'Acknowledge that the resolved values will be printed in the clear (shell export mode)')
956
1009
  .option('--to-1password', 'Push every key in the bundle as a PASSWORD item in a 1Password vault')
957
1010
  .option('--vault <name>', '1Password vault name (used with --to-1password)')
958
- .option('--force', 'Overwrite existing 1Password items (used with --to-1password)')
1011
+ .option('--to-ssh', 'Push the bundle to remote machine(s) over SSH via their native agents-cli import')
1012
+ .option('--host <target...>', 'SSH target(s) for --to-ssh: host alias or user@host (repeatable)')
1013
+ .option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --to-ssh)')
959
1014
  .action(async (bundleName, opts) => {
960
1015
  try {
961
1016
  const { readAndResolveBundleEnv, bundleToEnvPrefix, isReservedEnvName } = await import('../lib/secrets/bundles.js');
962
1017
  const resolvedBundleName = bundleName ?? (await pickBundleName('export'));
1018
+ if (opts.toSsh) {
1019
+ const hosts = opts.host ?? [];
1020
+ if (hosts.length === 0) {
1021
+ throw new Error('--to-ssh requires at least one --host <target>.');
1022
+ }
1023
+ for (const h of hosts)
1024
+ assertValidSshTarget(h);
1025
+ const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export` });
1026
+ const dotenv = bundleEnvToDotenv(env);
1027
+ const keyCount = Object.keys(env).length;
1028
+ // Drive the remote's own `agents secrets` CLI so values land in its native
1029
+ // backend (Keychain on macOS, libsecret / encrypted-file on Linux). Create
1030
+ // the bundle if missing, then import the piped .env. `bash -lc` so the login
1031
+ // PATH resolves `agents`; the .env flows over ssh stdin and is never parsed
1032
+ // by a remote shell.
1033
+ const force = opts.force ? ' --force' : '';
1034
+ const remoteAgents = `agents secrets create ${shellQuote(resolvedBundleName)} >/dev/null 2>&1 || true; ` +
1035
+ `agents secrets import ${shellQuote(resolvedBundleName)} --from /dev/stdin${force}`;
1036
+ const remoteCmd = `bash -lc ${shellQuote(remoteAgents)}`;
1037
+ let failures = 0;
1038
+ for (const host of hosts) {
1039
+ const res = spawnSync('ssh', ['-o', 'BatchMode=yes', host, remoteCmd], {
1040
+ input: dotenv,
1041
+ stdio: ['pipe', 'pipe', 'pipe'],
1042
+ encoding: 'utf-8',
1043
+ });
1044
+ if (res.error) {
1045
+ failures++;
1046
+ console.error(chalk.red(`${host}: ${res.error.message}`));
1047
+ continue;
1048
+ }
1049
+ if (res.status !== 0) {
1050
+ failures++;
1051
+ const msg = (res.stderr || res.stdout || '').trim();
1052
+ console.error(chalk.red(`${host}: remote import failed (exit ${res.status ?? 'signal'})${msg ? `: ${msg}` : ''}`));
1053
+ continue;
1054
+ }
1055
+ const remoteMsg = (res.stdout || '').trim().split('\n').map((l) => l.trim()).filter(Boolean).pop();
1056
+ console.log(chalk.green(`${host} -> '${resolvedBundleName}': ${remoteMsg || `${keyCount} key(s) exported`}`));
1057
+ }
1058
+ if (failures > 0)
1059
+ process.exit(1);
1060
+ return;
1061
+ }
963
1062
  if (opts.to1password) {
964
1063
  assertOpAvailable();
965
1064
  const vault = await resolveVault(opts.vault);
@@ -1118,9 +1217,156 @@ Examples:
1118
1217
  console.log(password);
1119
1218
  }
1120
1219
  });
1220
+ cmd
1221
+ .command('unlock [names...]')
1222
+ .description('Hold a bundle in the secrets-agent after one Touch ID, so concurrent runs read it without re-prompting (macOS).')
1223
+ .option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h). Default 24h.')
1224
+ .option('--all', 'Unlock every configured bundle')
1225
+ .action(async (names, opts) => {
1226
+ if (process.platform !== 'darwin') {
1227
+ console.error(chalk.red('secrets-agent is macOS-only (no biometry prompt to deduplicate elsewhere).'));
1228
+ process.exit(1);
1229
+ }
1230
+ let targets = opts.all ? listBundles().map((b) => b.name) : names;
1231
+ if (!targets || targets.length === 0) {
1232
+ console.error(chalk.red('Specify one or more bundle names, or --all.'));
1233
+ process.exit(1);
1234
+ }
1235
+ let ttlMs = DEFAULT_TTL_MS;
1236
+ if (opts.ttl) {
1237
+ const secs = parseDuration(opts.ttl);
1238
+ if (!secs) {
1239
+ console.error(chalk.red(`Invalid --ttl '${opts.ttl}'. Use e.g. 30m, 2h, 8h.`));
1240
+ process.exit(1);
1241
+ }
1242
+ ttlMs = secs * 1000;
1243
+ }
1244
+ if (!(await ensureAgentRunning())) {
1245
+ console.error(chalk.red('Could not start the secrets-agent.'));
1246
+ process.exit(1);
1247
+ }
1248
+ let loaded = 0;
1249
+ for (const name of targets) {
1250
+ try {
1251
+ // noAgent: read the real keychain (one Touch ID) rather than the
1252
+ // agent we're about to populate.
1253
+ const { bundle, env } = readAndResolveBundleEnv(name, { noAgent: true, caller: 'unlock' });
1254
+ if (await agentLoad(name, bundle, env, ttlMs)) {
1255
+ loaded++;
1256
+ console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(env).length} keys, ${humanRemaining(Date.now() + ttlMs)})`)}`);
1257
+ }
1258
+ else {
1259
+ console.error(chalk.red(`Failed to load '${name}' into the agent.`));
1260
+ }
1261
+ }
1262
+ catch (err) {
1263
+ if (isPromptCancelled(err)) {
1264
+ console.error(chalk.yellow(`Cancelled unlocking '${name}'.`));
1265
+ continue;
1266
+ }
1267
+ console.error(chalk.red(`${name}: ${err.message}`));
1268
+ }
1269
+ }
1270
+ if (loaded === 0)
1271
+ process.exit(1);
1272
+ });
1273
+ cmd
1274
+ .command('lock [names...]')
1275
+ .description('Wipe bundles from the secrets-agent (forces Touch ID again next read). Default: all.')
1276
+ .option('--all', 'Wipe every unlocked bundle (same as no names)')
1277
+ .action(async (names, opts) => {
1278
+ if (process.platform !== 'darwin')
1279
+ return; // nothing to lock off darwin
1280
+ if (names && names.length > 0 && !opts.all) {
1281
+ let total = 0;
1282
+ for (const name of names)
1283
+ total += await agentLock(name);
1284
+ console.log(total > 0 ? chalk.green(`Locked ${total} bundle(s).`) : chalk.gray('Nothing to lock.'));
1285
+ }
1286
+ else {
1287
+ const wiped = await agentLock();
1288
+ console.log(wiped > 0 ? chalk.green(`Locked ${wiped} bundle(s).`) : chalk.gray('Nothing to lock.'));
1289
+ }
1290
+ });
1291
+ cmd
1292
+ .command('status')
1293
+ .description('Show which bundles the secrets-agent currently holds and when they lock.')
1294
+ .action(async () => {
1295
+ if (process.platform !== 'darwin') {
1296
+ console.log(chalk.gray('secrets-agent is macOS-only.'));
1297
+ return;
1298
+ }
1299
+ const entries = await agentStatus();
1300
+ if (entries.length === 0) {
1301
+ console.log(chalk.gray('No bundles unlocked. The secrets-agent is idle or not running.'));
1302
+ console.log(chalk.gray('Try: agents secrets unlock <bundle>'));
1303
+ return;
1304
+ }
1305
+ console.log(chalk.bold(`${'BUNDLE'.padEnd(24)} ${'KEYS'.padEnd(5)} LOCKS IN`));
1306
+ for (const e of entries) {
1307
+ console.log(`${chalk.cyan(e.name.padEnd(24))} ${String(e.keyCount).padEnd(5)} ${humanRemaining(e.expiresAt)}`);
1308
+ }
1309
+ });
1310
+ cmd
1311
+ .command('tier <bundle> [tier]')
1312
+ .description("Show or set a bundle's secrets-agent tier: biometry (default) or session.")
1313
+ .action((bundleName, tier) => {
1314
+ try {
1315
+ const bundle = readBundle(bundleName);
1316
+ if (tier === undefined) {
1317
+ console.log(`${chalk.cyan(bundle.name)} tier: ${chalk.bold(bundleTier(bundle))}`);
1318
+ return;
1319
+ }
1320
+ const next = parseTierOpt(tier);
1321
+ bundle.tier = next;
1322
+ writeBundle(bundle);
1323
+ console.log(chalk.green(`${bundle.name} tier set to ${next}.`));
1324
+ if (next === 'session') {
1325
+ console.log(chalk.gray('Eligible for the secrets-agent: unlock it, or enable auto-cache with `secrets.agent.auto: true` in agents.yaml.'));
1326
+ }
1327
+ }
1328
+ catch (err) {
1329
+ console.error(chalk.red(err.message));
1330
+ process.exit(1);
1331
+ }
1332
+ });
1333
+ cmd
1334
+ .command('_agent-run', { hidden: true })
1335
+ .description('Run the secrets-agent broker in the foreground (internal)')
1336
+ .action(async () => {
1337
+ await runSecretsAgent();
1338
+ });
1121
1339
  registerSecretsSyncCommands(cmd);
1122
1340
  registerSecretsMigrateAclCommand(cmd);
1123
1341
  }
1342
+ /** Validate a --tier value, exiting with a clear message on a bad one. `none`
1343
+ * is rejected explicitly: it would require storing items without the biometry
1344
+ * ACL (a separate signed-helper change), so it isn't offered yet. */
1345
+ function parseTierOpt(raw) {
1346
+ const v = (raw ?? 'biometry').toLowerCase();
1347
+ if (v === 'biometry' || v === 'session')
1348
+ return v;
1349
+ if (v === 'none') {
1350
+ console.error(chalk.red("tier 'none' (no biometry ACL) is not available yet — use 'biometry' or 'session'."));
1351
+ process.exit(1);
1352
+ }
1353
+ console.error(chalk.red(`Invalid --tier '${raw}'. Use 'biometry' or 'session'.`));
1354
+ process.exit(1);
1355
+ }
1356
+ /** Human-readable "locks in 3 hours" / "locks in 5 minutes" from an epoch-ms expiry. */
1357
+ function humanRemaining(expiresAt) {
1358
+ const ms = expiresAt - Date.now();
1359
+ if (ms <= 0)
1360
+ return 'expired';
1361
+ const mins = Math.round(ms / 60000);
1362
+ if (mins < 60)
1363
+ return `locks in ${mins} minute${mins === 1 ? '' : 's'}`;
1364
+ const hours = Math.round(mins / 60);
1365
+ if (hours < 24)
1366
+ return `locks in ${hours} hour${hours === 1 ? '' : 's'}`;
1367
+ const days = Math.round(hours / 24);
1368
+ return `locks in ${days} day${days === 1 ? '' : 's'}`;
1369
+ }
1124
1370
  /**
1125
1371
  * Copy text to the system clipboard, cross-platform.
1126
1372
  * macOS: `pbcopy`. Windows: `clip`. Linux: tries `wl-copy` (Wayland), then
@@ -376,6 +376,8 @@ async function sessionsAction(query, options) {
376
376
  // Without this, a dev dir with heavy SDK spawn activity (Task subagents,
377
377
  // `agents run`, team agents) can fill the top-N window entirely with
378
378
  // hidden rows and make real CLI sessions appear to vanish.
379
+ // 'recent' is the user-facing alias for the default timestamp sort.
380
+ const sortBy = options.sort === 'cost' ? 'cost' : options.sort === 'duration' ? 'duration' : 'timestamp';
379
381
  const scope = {
380
382
  agent,
381
383
  version,
@@ -385,6 +387,7 @@ async function sessionsAction(query, options) {
385
387
  project: options.project,
386
388
  since,
387
389
  until: options.until,
390
+ sortBy,
388
391
  };
389
392
  let sessions = await discoverSessions({
390
393
  ...scope,
@@ -1102,6 +1105,7 @@ export function registerSessionsCommands(program) {
1102
1105
  .option('--since <time>', 'Only sessions newer than this (e.g., 2h, 7d, 4w, or ISO date)')
1103
1106
  .option('--until <time>', 'Only sessions older than this (ISO timestamp)')
1104
1107
  .option('-n, --limit <n>', 'Maximum number of sessions to return', '50')
1108
+ .option('--sort <field>', 'Sort the list by: recent (default), cost, or duration')
1105
1109
  .option('--markdown', 'Render the session as markdown (user, assistant, thinking, tool calls)')
1106
1110
  .option('--no-redact', 'Disable default secret redaction in markdown session output')
1107
1111
  .option('--json', 'Output JSON (session list when browsing, event array when rendering one session)')
package/dist/index.js CHANGED
@@ -97,6 +97,8 @@ import { registerWalletCommands } from './commands/wallet.js';
97
97
  import { registerHelperCommand } from './commands/helper.js';
98
98
  import { registerFactoryCommands } from './commands/factory.js';
99
99
  import { registerUsageCommand } from './commands/usage.js';
100
+ import { registerCostCommand } from './commands/cost.js';
101
+ import { registerBudgetCommand } from './commands/budget.js';
100
102
  import { registerAliasCommand } from './commands/alias.js';
101
103
  import { registerBetaCommands } from './commands/beta.js';
102
104
  import { applyGlobalHelpConventions } from './lib/help.js';
@@ -679,6 +681,8 @@ registerRefreshRulesCommand(program);
679
681
  registerDriveCommands(program);
680
682
  registerFactoryCommands(program);
681
683
  registerUsageCommand(program);
684
+ registerCostCommand(program);
685
+ registerBudgetCommand(program);
682
686
  registerAliasCommand(program);
683
687
  registerPtyCommands(program);
684
688
  registerTmuxCommands(program);
@@ -0,0 +1,9 @@
1
+ import type { BudgetConfig } from '../types.js';
2
+ /**
3
+ * Effective budget for `cwd`: user/global base, then each project-local block
4
+ * from farthest ancestor to nearest, nearest winning. `on_exceed` defaults to
5
+ * `block` when nothing sets it (fail-closed: the safe default is to enforce).
6
+ */
7
+ export declare function resolveBudgetConfig(cwd?: string): BudgetConfig;
8
+ /** True when at least one enforceable cap is set. No caps => budget feature is dormant. */
9
+ export declare function hasAnyCap(cfg: BudgetConfig): boolean;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Budget config resolution (issue #346).
3
+ *
4
+ * The `budget:` block can live in the user/global agents.yaml (`readMeta().budget`)
5
+ * and in any project-local agents.yaml walked from cwd upward. Precedence is
6
+ * project > user, matching `run:` resolution (lib/run-config.ts). Caps merge
7
+ * field-by-field — a project that sets only `per_run` inherits the user's
8
+ * `per_day`/`per_project`/`per_agent` rather than wiping them.
9
+ *
10
+ * This is the single resolver the pre-flight gate, the live watcher, and the
11
+ * `agents budget` command all route through, so the effective cap set is
12
+ * computed in exactly one place.
13
+ */
14
+ import * as fs from 'fs';
15
+ import * as path from 'path';
16
+ import * as yaml from 'yaml';
17
+ import { getUserAgentsDir, readMeta } from '../state.js';
18
+ function isRecord(value) {
19
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
20
+ }
21
+ /**
22
+ * Coerce a raw parsed `budget:` block into a typed BudgetConfig, dropping any
23
+ * field whose value is the wrong shape. Malformed entries are ignored, not
24
+ * thrown — a typo in one cap must never crash a run (no-fallbacks applies to
25
+ * the data path, not to user-typed config we choose to be lenient about).
26
+ */
27
+ function coerceBudget(raw) {
28
+ if (!isRecord(raw))
29
+ return {};
30
+ const out = {};
31
+ if (typeof raw.currency === 'string')
32
+ out.currency = raw.currency;
33
+ if (typeof raw.per_run === 'number' && raw.per_run >= 0)
34
+ out.per_run = raw.per_run;
35
+ if (typeof raw.per_day === 'number' && raw.per_day >= 0)
36
+ out.per_day = raw.per_day;
37
+ if (typeof raw.per_project === 'number' && raw.per_project >= 0)
38
+ out.per_project = raw.per_project;
39
+ if (raw.on_exceed === 'block' || raw.on_exceed === 'warn')
40
+ out.on_exceed = raw.on_exceed;
41
+ if (typeof raw.require_confirm_over === 'number' && raw.require_confirm_over >= 0) {
42
+ out.require_confirm_over = raw.require_confirm_over;
43
+ }
44
+ if (isRecord(raw.per_agent)) {
45
+ const perAgent = {};
46
+ for (const [k, v] of Object.entries(raw.per_agent)) {
47
+ if (typeof v === 'number' && v >= 0)
48
+ perAgent[k] = v;
49
+ }
50
+ if (Object.keys(perAgent).length > 0)
51
+ out.per_agent = perAgent;
52
+ }
53
+ return out;
54
+ }
55
+ /** Merge a higher-precedence budget over a base. Set fields win; per_agent merges key-by-key. */
56
+ function mergeBudget(base, over) {
57
+ const merged = { ...base, ...stripUndefined(over) };
58
+ if (base.per_agent || over.per_agent) {
59
+ merged.per_agent = { ...(base.per_agent ?? {}), ...(over.per_agent ?? {}) };
60
+ }
61
+ return merged;
62
+ }
63
+ function stripUndefined(cfg) {
64
+ const out = {};
65
+ for (const [k, v] of Object.entries(cfg)) {
66
+ if (v !== undefined)
67
+ out[k] = v;
68
+ }
69
+ return out;
70
+ }
71
+ /** Read project-local `budget:` blocks from nearest dir upward, nearest LAST (highest precedence). */
72
+ function getProjectBudgets(startPath) {
73
+ const configs = [];
74
+ let dir = path.resolve(startPath);
75
+ const userAgentsYaml = path.join(getUserAgentsDir(), 'agents.yaml');
76
+ while (dir !== path.dirname(dir)) {
77
+ const manifestPath = path.join(dir, 'agents.yaml');
78
+ if (manifestPath !== userAgentsYaml && fs.existsSync(manifestPath)) {
79
+ try {
80
+ const parsed = yaml.parse(fs.readFileSync(manifestPath, 'utf-8'));
81
+ if (isRecord(parsed) && parsed.budget !== undefined) {
82
+ configs.push(coerceBudget(parsed.budget));
83
+ }
84
+ }
85
+ catch {
86
+ // Malformed project config — ignore and keep walking.
87
+ }
88
+ }
89
+ dir = path.dirname(dir);
90
+ }
91
+ // configs[0] is the nearest dir. Reverse so the nearest applies LAST (wins).
92
+ return configs.reverse();
93
+ }
94
+ /**
95
+ * Effective budget for `cwd`: user/global base, then each project-local block
96
+ * from farthest ancestor to nearest, nearest winning. `on_exceed` defaults to
97
+ * `block` when nothing sets it (fail-closed: the safe default is to enforce).
98
+ */
99
+ export function resolveBudgetConfig(cwd = process.cwd()) {
100
+ const userBudget = coerceBudget(readMeta().budget);
101
+ let merged = userBudget;
102
+ for (const projectBudget of getProjectBudgets(cwd)) {
103
+ merged = mergeBudget(merged, projectBudget);
104
+ }
105
+ if (merged.on_exceed === undefined)
106
+ merged.on_exceed = 'block';
107
+ return merged;
108
+ }
109
+ /** True when at least one enforceable cap is set. No caps => budget feature is dormant. */
110
+ export function hasAnyCap(cfg) {
111
+ return (cfg.per_run !== undefined ||
112
+ cfg.per_day !== undefined ||
113
+ cfg.per_project !== undefined ||
114
+ (cfg.per_agent !== undefined && Object.keys(cfg.per_agent).length > 0));
115
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Live spend watcher + cap math (issue #346).
3
+ *
4
+ * This is the provider-agnostic shared surface the loop driver (#332) will
5
+ * reuse for its budget guard. It knows nothing about child processes, agents,
6
+ * or the ledger — it accepts parsed usage events and a caps object, accumulates
7
+ * cost via the canonical pricing module, and fires `onBreach` exactly once when
8
+ * any active cap is crossed.
9
+ *
10
+ * The accumulation is the cross-vendor primitive: feed Claude usage and Codex
11
+ * usage to the same watcher under one `per_project` / `per_run` cap and the
12
+ * spend aggregates across both — no single-vendor control can do that.
13
+ */
14
+ import type { AgentId, BudgetConfig } from '../types.js';
15
+ /** A parsed usage event from any agent's stream (fields match session/parse). */
16
+ export interface UsageEvent {
17
+ agent?: AgentId | string;
18
+ model?: string;
19
+ inputTokens?: number;
20
+ outputTokens?: number;
21
+ cacheReadTokens?: number;
22
+ cacheCreationTokens?: number;
23
+ }
24
+ /**
25
+ * Caps the watcher enforces. `priorDaySpend` / `priorProjectSpend` seed the
26
+ * accumulators with spend already on the ledger BEFORE this run started, so a
27
+ * per_day cap counts today's earlier runs too — not just this process. Per-cap
28
+ * fields are USD; undefined means "not enforced".
29
+ */
30
+ export interface LiveCaps {
31
+ perRun?: number;
32
+ perDay?: number;
33
+ perProject?: number;
34
+ /** Per-agent daily caps. Each agent's running spend is checked against its own cap. */
35
+ perAgent?: Partial<Record<string, number>>;
36
+ /** Day spend already on the ledger before this run (cross-vendor). */
37
+ priorDaySpend?: number;
38
+ /** Project spend already on the ledger before this run (cross-vendor). */
39
+ priorProjectSpend?: number;
40
+ /** Per-agent day spend already on the ledger before this run, keyed by agent. */
41
+ priorAgentDaySpend?: Partial<Record<string, number>>;
42
+ }
43
+ /** Which cap tripped, and the spend figures at the moment of the breach. */
44
+ export interface BreachInfo {
45
+ cap: 'per_run' | 'per_day' | 'per_project' | 'per_agent';
46
+ /** The configured limit that was crossed (USD). */
47
+ limit: number;
48
+ /** The spend that crossed it (USD). */
49
+ spend: number;
50
+ /** Agent attributed to the breach (only meaningful for per_agent). */
51
+ agent?: string;
52
+ /** This run's accumulated spend so far (USD). */
53
+ runSpend: number;
54
+ }
55
+ /** Public watcher surface. `feedUsage` is idempotent after a breach (no double-fire). */
56
+ export interface LiveSpendWatcher {
57
+ /** Feed one parsed usage event; accrues cost and may fire onBreach. */
58
+ feedUsage(event: UsageEvent): void;
59
+ /** Total USD this run has accumulated across all fed events. */
60
+ runSpend(): number;
61
+ /** True once a cap has been breached. */
62
+ breached(): boolean;
63
+ /** Stop accepting events / release references. Idempotent. */
64
+ dispose(): void;
65
+ }
66
+ /** Convert a resolved BudgetConfig + prior ledger spend into the caps the watcher needs. */
67
+ export declare function capsFromConfig(cfg: BudgetConfig, prior?: {
68
+ daySpend?: number;
69
+ projectSpend?: number;
70
+ agentDaySpend?: Partial<Record<string, number>>;
71
+ }): LiveCaps;
72
+ /**
73
+ * Create a live spend watcher. `onBreach` fires at most once, on the first
74
+ * event that pushes any active cap from at-or-under to over. After it fires the
75
+ * watcher keeps accumulating (so `runSpend()` stays accurate for the final
76
+ * ledger record) but never calls `onBreach` again.
77
+ */
78
+ export declare function makeLiveSpendWatcher(args: {
79
+ caps: LiveCaps;
80
+ onBreach: (breach: BreachInfo) => void;
81
+ }): LiveSpendWatcher;
82
+ /**
83
+ * Incrementally extract usage events from a stream-json chunk. Buffers a partial
84
+ * trailing line across calls (returned in `rest`), parses each complete line,
85
+ * and yields one UsageEvent per line that carries token counts. Provider shapes
86
+ * handled: Claude/`--json` assistant turns (`message.usage` with
87
+ * `input_tokens`/`output_tokens`/`cache_*_input_tokens`) and the flatter
88
+ * `usage.record` shape (`usage.input_tokens`/`output`). Lines that aren't JSON
89
+ * or carry no usage are skipped — this never throws on agent output.
90
+ */
91
+ export declare function extractUsageEvents(chunk: string, pending: string, fallbackModel?: string, fallbackAgent?: string): {
92
+ events: UsageEvent[];
93
+ rest: string;
94
+ };