@phnx-labs/agents-cli 1.22.19 → 1.22.21

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 CHANGED
@@ -1,5 +1,75 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.21
4
+
5
+ - **`agents secrets exec <bundle> -- <cmd>` now resolves a locked keychain bundle
6
+ interactively at a real terminal.** The local resolve hardcoded `agentOnly: true`
7
+ (`commands/secrets.ts`), so running `exec` on a locked bundle at a terminal
8
+ failed closed with "run `agents secrets unlock` first" instead of raising the one
9
+ Touch ID sheet the human just implied by asking to use the values. It now gates
10
+ `agentOnly` on `isHeadlessSecretsContext() || !isInteractiveTerminal()`: an
11
+ unlocked bundle still runs silently, a locked bundle at an interactive terminal
12
+ resolves with a single sheet and then runs the command with the secrets injected,
13
+ and under an agent (`AGENTS_RUNTIME`) or headless (no TTY) it stays broker-only —
14
+ release/CI scripts never prompt. Mirrors the same fix for `view --reveal`.
15
+ `--host` remote resolves and `export`/`get` are unchanged. Source:
16
+ `apps/cli/src/commands/secrets.ts`.
17
+
18
+ ## 1.22.20
19
+
20
+ - **Claude sessions are attributed to the account that produced them.** The session
21
+ scanner resolved ONE account email process-globally (`cachedClaudeAccount`) and
22
+ stamped it on every Claude row of a scan, so a machine with several signed-in
23
+ accounts reported all of its history under whichever resolved first — on a
24
+ three-account machine, 982 of 2,736 indexed sessions carried the wrong email and
25
+ most of the per-account cost was misplaced. Attribution now resolves per transcript,
26
+ from its path plus the version recorded inside the file. Two orgs sharing one email
27
+ (a Team seat and a personal Max plan) stay in separate buckets, keyed on the org
28
+ `usageKey` the same way `agents run`'s balanced rotation keys quota. Sessions whose
29
+ account cannot be established are reported as `unattributed:<reason>` rather than
30
+ folded into a real account. Source: `apps/cli/src/lib/session/claude-accounts.ts`,
31
+ `apps/cli/src/lib/session/discover.ts`.
32
+
33
+ Rows under the mutable `~/.claude` symlink are attributed by their recorded version,
34
+ not by wherever the symlink points now: only 684 of 1,334 such rows came from the
35
+ version the symlink currently names. Retired (`trash/`) homes keep their
36
+ `.claude.json`, so their transcripts stay attributable. A transcript in a home that
37
+ exists but is signed out stays dark and is named after that home — its location
38
+ proves which config dir Claude used.
39
+
40
+ **Harness scope: Claude only.** Attribution depends on the per-version home carrying
41
+ an `oauthAccount`. Other harnesses have their own per-version credential files, so
42
+ the mechanism generalizes, but each needs its own identity extractor and
43
+ quota-bucket notion. Until then a non-Claude session has no `account_key` and rolls
44
+ up under `unattributed:<agent>`.
45
+
46
+ - **`account_key` / `account_org` on indexed sessions, and `--by account` rollups.**
47
+ Schema v33 adds both columns plus `idx_sessions_account_key`, and `queryUsageRollup`
48
+ accepts `groupBy: 'account'` — surfaced by `agents cost --by account` and
49
+ `agents output --by account`, which render the org and email rather than the raw
50
+ uuid. The migration repairs existing rows in place from `file_path` and `version`
51
+ and deliberately does **not** flush `scan_ledger`: attribution needs no transcript
52
+ re-parse, so a 2,736-row index migrates in ~200ms with every ledger entry still
53
+ warm. `getDB` also runs a guarded self-healing repair for rows an older CLI left
54
+ unattributed. Source: `apps/cli/src/lib/session/db.ts`.
55
+
56
+ - **`upsertSessionsBatch` bound its named parameters from an untyped literal.** bun
57
+ binds named parameters in strict mode, where a MISSING key throws, while node binds
58
+ NULL — so a key omitted from that literal broke only the shipped standalone binary,
59
+ and the per-row guard swallowed it into a silently skipped session. The literal is
60
+ now typed against `SessionRow`, so the compiler rejects the next omission. Source:
61
+ `apps/cli/src/lib/session/db.ts`.
62
+
63
+ - `readClaudeHomeConfig()` in `apps/cli/src/lib/agents.ts` — the single place a Claude
64
+ home's `oauthAccount` identity is read. `getAccountInfo` now uses it, so identity
65
+ extraction is no longer duplicated. Unlike `getAccountInfo` it does not apply the
66
+ credential floor, because a revoked token does not change which org produced a past
67
+ transcript.
68
+
69
+ - **`agents routines add` accepts `--project <name>` (repeatable) and `--all-projects` to tag a routine to one or more projects.** Project names are validated against `agents projects list` at creation time; unknown names are rejected with a suggested fix command. The flag sets the new `projects?: string[]` field in the job config YAML — metadata-only, no effect on scheduling or execution. `--all-projects` sets `projects: ["*"]` (the "all defined projects" sentinel) and is mutually exclusive with `--project`. Source: `apps/cli/src/lib/routines.ts` (`JobConfig`, `validateJob`, `computeProjectGroup`, `writeJob`), `apps/cli/src/commands/routines.ts`.
70
+
71
+ - **`agents routines list` now groups by project by default.** The human terminal view buckets routines under their associated project name, **All projects** (`projects: ["*"]`), **Cross-project** (multiple project entries), **Operations** (no `projects:` field), or **Unknown projects** (project names not found in `agents projects`). Pass `--group-by device` to restore the previous device-placement grouping. The `--json` payload gains `projects` (array) and `projectGroup` (string) fields. Source: `apps/cli/src/commands/routines.ts` (`groupRoutineJobsByProject`).
72
+
3
73
  ## 1.22.19
4
74
 
5
75
  ### Fixed
package/dist/bin/agents CHANGED
Binary file
@@ -10,7 +10,7 @@ export function registerCostCommand(program) {
10
10
  .description('Roll up $ cost and duration across local agent sessions')
11
11
  .option('--json', 'Output the rollup as JSON')
12
12
  .option('--since <time>', 'Only sessions newer than this (e.g., 7d, 4w, or ISO date)')
13
- .option('--by <dimension>', 'Group the breakdown by: agent (default), project, or day')
13
+ .option('--by <dimension>', 'Group the breakdown by: agent (default), project, day, or account (the Claude org that produced each session)')
14
14
  .addHelpText('after', `
15
15
  Examples:
16
16
  agents cost Daily histogram + top sessions + per-agent breakdown
@@ -28,9 +28,9 @@ Cost is computed offline from a versioned per-model price table (${PRICING_VERSI
28
28
  function resolveGroup(by) {
29
29
  if (by === undefined)
30
30
  return 'agent';
31
- if (by === 'agent' || by === 'project' || by === 'day')
31
+ if (by === 'agent' || by === 'project' || by === 'day' || by === 'account')
32
32
  return by;
33
- console.error(chalk.red('error: --by must be one of: agent, project, day'));
33
+ console.error(chalk.red('error: --by must be one of: agent, project, day, account'));
34
34
  process.exit(1);
35
35
  }
36
36
  async function costAction(options) {
@@ -101,7 +101,10 @@ async function costAction(options) {
101
101
  out.push('');
102
102
  }
103
103
  // Per-agent / per-project / per-day breakdown.
104
- const groupLabel = groupBy === 'agent' ? 'agent' : groupBy === 'project' ? 'project' : 'day';
104
+ const groupLabel = groupBy === 'agent' ? 'agent'
105
+ : groupBy === 'project' ? 'project'
106
+ : groupBy === 'account' ? 'account'
107
+ : 'day';
105
108
  out.push(chalk.bold(`By ${groupLabel}`));
106
109
  const cols = terminalWidth();
107
110
  const costW2 = Math.max(...breakdown.map(r => formatUsd(r.costUsd).length), 4);
@@ -109,12 +112,13 @@ async function costAction(options) {
109
112
  const sessionW = Math.max(...breakdown.map(r => `${String(r.sessionCount).padStart(countW)} session${r.sessionCount !== 1 ? 's' : ''}`.length));
110
113
  const durationW = Math.max(...breakdown.map(r => r.durationMs > 0 ? stringWidth(formatDuration(r.durationMs)) : 1), 1);
111
114
  const fixedW = 2 + 2 + costW2 + 2 + sessionW + 2 + durationW;
112
- const keyW = Math.max(8, Math.min(Math.max(...breakdown.map(r => stringWidth(r.key)), groupLabel.length), cols - fixedW));
115
+ const display = (r) => r.label ?? r.key;
116
+ const keyW = Math.max(8, Math.min(Math.max(...breakdown.map(r => stringWidth(display(r))), groupLabel.length), cols - fixedW));
113
117
  for (const r of breakdown) {
114
118
  const cost = formatUsd(r.costUsd).padStart(costW2);
115
119
  const dur = r.durationMs > 0 ? formatDuration(r.durationMs) : '—';
116
120
  const sessions = `${String(r.sessionCount).padStart(countW)} session${r.sessionCount !== 1 ? 's' : ''}`;
117
- out.push(` ${padToWidth(truncateToWidth(r.key, keyW), keyW)} ${chalk.green(cost)} ${chalk.gray(padToWidth(sessions, sessionW))} ${chalk.gray(padToWidth(dur, durationW))}`);
121
+ out.push(` ${padToWidth(truncateToWidth(display(r), keyW), keyW)} ${chalk.green(cost)} ${chalk.gray(padToWidth(sessions, sessionW))} ${chalk.gray(padToWidth(dur, durationW))}`);
118
122
  }
119
123
  console.log(out.join('\n'));
120
124
  }
@@ -18,7 +18,7 @@ export function registerOutputCommand(program) {
18
18
  .description('Productivity rollup — token burn vs shipped output (PRs, commits) across agents')
19
19
  .option('--json', 'Output the rollup as JSON')
20
20
  .option('--since <time>', 'Only sessions/commits newer than this: 1h, 24h, 7d, 4w, 1mo, 1y, or ISO date (default 7d)')
21
- .option('--by <dimension>', 'Group the burn/output breakdown by: agent (default), project, or day')
21
+ .option('--by <dimension>', 'Group the burn/output breakdown by: agent (default), project, day, or account (the Claude org that produced each session)')
22
22
  .option('--repos-dir <dir>', 'Root scanned for git repos (default ~/src)')
23
23
  .option('--author <email...>', 'Count commits by these author emails (default: your git identities)')
24
24
  .option('--login <login...>', 'Count PRs for these GitHub logins (default: current gh user)')
@@ -42,9 +42,9 @@ Output tokens are the real generated tokens — NOT the cache-inflated total tok
42
42
  function resolveGroup(by) {
43
43
  if (by === undefined)
44
44
  return 'agent';
45
- if (by === 'agent' || by === 'project' || by === 'day')
45
+ if (by === 'agent' || by === 'project' || by === 'day' || by === 'account')
46
46
  return by;
47
- console.error(chalk.red('error: --by must be one of: agent, project, day'));
47
+ console.error(chalk.red('error: --by must be one of: agent, project, day, account'));
48
48
  process.exit(1);
49
49
  }
50
50
  /** Compact token formatter: 38.6M, 4.1K, 10.6B. */
@@ -188,7 +188,10 @@ function mergeMachines(machines, options) {
188
188
  for (const a of m.uncostedAgents)
189
189
  uncosted.add(a);
190
190
  for (const r of m.breakdown.rows) {
191
- const cur = byKey.get(r.key) ?? { key: r.key, costUsd: 0, durationMs: 0, sessionCount: 0, tokenCount: 0, outputTokens: 0 };
191
+ const cur = byKey.get(r.key) ?? { key: r.key, label: r.label, costUsd: 0, durationMs: 0, sessionCount: 0, tokenCount: 0, outputTokens: 0 };
192
+ // Peers resolve their own labels; keep the first non-empty one so a machine that
193
+ // has not indexed an account yet does not blank a label another machine supplied.
194
+ cur.label ??= r.label;
192
195
  cur.costUsd += r.costUsd;
193
196
  cur.durationMs += r.durationMs;
194
197
  cur.sessionCount += r.sessionCount;
@@ -256,11 +259,12 @@ function renderBreakdown(rows, groupBy) {
256
259
  const outW = Math.max(...rows.map(r => formatCompact(r.outputTokens).length), 6);
257
260
  const sessW = Math.max(...rows.map(r => String(r.sessionCount).length), 3);
258
261
  const fixedW = 2 + 2 + burnW + 2 + outW + 2 + sessW + 8;
259
- const keyW = Math.max(8, Math.min(Math.max(...rows.map(r => r.key.length), groupBy.length), cols - fixedW));
262
+ const display = (r) => r.label ?? r.key;
263
+ const keyW = Math.max(8, Math.min(Math.max(...rows.map(r => display(r).length), groupBy.length), cols - fixedW));
260
264
  out.push(' ' + chalk.gray(padToWidth('', keyW)) + ' ' + chalk.gray(padToWidth('burn', burnW)) + ' ' + chalk.gray(padToWidth('output', outW)) + ' ' + chalk.gray('sessions'));
261
265
  for (const r of rows) {
262
266
  out.push(' ' +
263
- padToWidth(truncateToWidth(r.key, keyW), keyW) +
267
+ padToWidth(truncateToWidth(display(r), keyW), keyW) +
264
268
  ' ' +
265
269
  chalk.green(padToWidth(formatUsd(r.costUsd), burnW)) +
266
270
  ' ' +
@@ -39,6 +39,12 @@ export interface RoutineListGroup {
39
39
  local: boolean;
40
40
  }
41
41
  export declare function groupRoutineJobsByDevice(jobs: JobConfig[], registry: DeviceRegistry, self?: string): RoutineListGroup[];
42
+ /**
43
+ * Group routines by their `projects` metadata field.
44
+ * Named projects come first (alphabetically), followed by All projects, Cross-project,
45
+ * Operations (no project), and Unknown projects (stale names).
46
+ */
47
+ export declare function groupRoutineJobsByProject(jobs: JobConfig[], knownProjectNames: Set<string>): RoutineListGroup[];
42
48
  export declare function buildRunsJson(runs: RunMeta[]): Record<string, unknown>[];
43
49
  /** Register the `agents routines` command tree. */
44
50
  export declare function registerRoutinesCommands(program: Command): void;
@@ -14,7 +14,8 @@ import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon, readDaemo
14
14
  import { assertSchedulerEnabled } from '../lib/device-config.js';
15
15
  import { resolveAgentName, isAgentHardDeprecated, hardDeprecationError } from '../lib/agents.js';
16
16
  import { humanizeCron, humanizeNextRun, formatRepoLink, REPO_DISPLAY_MAX } from '../lib/routines-format.js';
17
- import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, routineStats, getLatestRun, getRunDir, getJobPath, parseAtTime, hasCompletedOneShotRun, isOneShotLikeSchedule, isOneShotRoutine, isPastOneShotRoutine, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, HOST_STRATEGIES, } from '../lib/routines.js';
17
+ import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, routineStats, getLatestRun, getRunDir, getJobPath, parseAtTime, hasCompletedOneShotRun, isOneShotLikeSchedule, isOneShotRoutine, isPastOneShotRoutine, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, HOST_STRATEGIES, computeProjectGroup, computeProjectGroupKind, projectGroupKey, projectGroupTitle, projectGroupOrder, normalizeProjects, } from '../lib/routines.js';
18
+ import { listProjectDefs, isSafeProjectName } from '../lib/projects.js';
18
19
  import { discoverProjectRoutinesAt, enableProjectRoutines, disableProjectRoutines, syncProjectRoutines, syncAllProjectRoutines, listEnabledProjectRoots, resolveProjectRoot, displayProjectPath, listProjectRoutineFiles, } from '../lib/routines-project.js';
19
20
  import { fireWebhookJobs, matchJobsToWebhook } from '../lib/triggers/webhook.js';
20
21
  import { getRoutinesDir } from '../lib/state.js';
@@ -217,6 +218,40 @@ export function groupRoutineJobsByDevice(jobs, registry, self = machineId()) {
217
218
  };
218
219
  return [...groups.values()].sort((a, b) => order(a) - order(b) || a.title.localeCompare(b.title));
219
220
  }
221
+ /**
222
+ * Group routines by their `projects` metadata field.
223
+ * Named projects come first (alphabetically), followed by All projects, Cross-project,
224
+ * Operations (no project), and Unknown projects (stale names).
225
+ */
226
+ export function groupRoutineJobsByProject(jobs, knownProjectNames) {
227
+ const groups = new Map();
228
+ const add = (key, title, job) => {
229
+ const existing = groups.get(key);
230
+ if (existing) {
231
+ existing.jobs.push(job);
232
+ return;
233
+ }
234
+ groups.set(key, { key, title, jobs: [job], local: true });
235
+ };
236
+ const orderByKey = new Map();
237
+ for (const job of jobs) {
238
+ const group = computeProjectGroupKind(job.projects, knownProjectNames);
239
+ const key = projectGroupKey(group);
240
+ orderByKey.set(key, projectGroupOrder(group));
241
+ add(key, projectGroupTitle(group), job);
242
+ }
243
+ // Order by the discriminated group rank (named first, then All projects,
244
+ // Cross-project, Operations, Unknown projects), then alphabetically by title
245
+ // within a rank. Buckets are keyed on the discriminant, never the label, so a
246
+ // project named "Operations" sorts among the named projects — not with the
247
+ // no-project special that shares its title.
248
+ const order = (group) => orderByKey.get(group.key) ?? 0;
249
+ return [...groups.values()].sort((a, b) => order(a) - order(b) || a.title.localeCompare(b.title));
250
+ }
251
+ /** commander repeatable-option collector for --project. */
252
+ function collectProject(value, previous) {
253
+ return previous.concat([value]);
254
+ }
220
255
  function renderRoutineRows({ jobs, scheduler, overdueSet, link, now, local = true }) {
221
256
  const NAME_W = 24;
222
257
  const AGENT_W = 10;
@@ -512,11 +547,11 @@ export function registerRoutinesCommands(program) {
512
547
  .command('list')
513
548
  .description('See all scheduled jobs, when they run next, and their last execution status')
514
549
  .option('--json', 'Emit machine-readable JSON instead of the table (used by the menu bar helper)')
515
- .option('--group-by <field>', 'Group table output by device (default for terminal output)')
550
+ .option('--group-by <field>', 'Group output by field: project (default) or device', 'project')
516
551
  .option('--flat', 'Print the legacy flat table instead of grouped sections')
517
552
  .action((options) => {
518
- if (options.groupBy && options.groupBy !== 'device') {
519
- console.error(chalk.red(`Unsupported --group-by '${options.groupBy}'. Use: device`));
553
+ if (options.groupBy && options.groupBy !== 'device' && options.groupBy !== 'project') {
554
+ console.error(chalk.red(`Unsupported --group-by '${options.groupBy}'. Use: project (default) or device`));
520
555
  process.exit(1);
521
556
  }
522
557
  try {
@@ -548,6 +583,7 @@ export function registerRoutinesCommands(program) {
548
583
  // The menu bar helper relies on this so it never reimplements cron math.
549
584
  if (options.json) {
550
585
  const nowJson = new Date();
586
+ const knownProjectNames = new Set(listProjectDefs().map((p) => p.name));
551
587
  const payload = jobs.map((job) => {
552
588
  const latestRun = localLatestRun(job);
553
589
  const enabledDevices = devicesWithRoutineEnabled(job.name);
@@ -582,6 +618,8 @@ export function registerRoutinesCommands(program) {
582
618
  failureReason: latestRun?.errorMessage ?? null,
583
619
  lastRunStartedAt: latestRun?.startedAt ?? null,
584
620
  lastRunCompletedAt: latestRun?.completedAt ?? null,
621
+ projects: job.projects ?? [],
622
+ projectGroup: computeProjectGroup(job.projects, knownProjectNames),
585
623
  };
586
624
  });
587
625
  scheduler.stopAll();
@@ -597,7 +635,7 @@ export function registerRoutinesCommands(program) {
597
635
  if (options.flat) {
598
636
  renderRoutineRows({ jobs, scheduler, overdueSet, link, now });
599
637
  }
600
- else {
638
+ else if (options.groupBy === 'device') {
601
639
  let registry = {};
602
640
  try {
603
641
  registry = loadDevicesSync();
@@ -615,6 +653,15 @@ export function registerRoutinesCommands(program) {
615
653
  console.log(chalk.gray(' Last Status is per-device: rows under another device show "-" — read it there with: agents routines list --device <name>'));
616
654
  }
617
655
  }
656
+ else {
657
+ // Default: group by project
658
+ const knownProjectNames = new Set(listProjectDefs().map((p) => p.name));
659
+ const groups = groupRoutineJobsByProject(jobs, knownProjectNames);
660
+ for (const group of groups) {
661
+ console.log(chalk.bold(`\n${group.title}`));
662
+ renderRoutineRows({ jobs: group.jobs, scheduler, overdueSet, link, now });
663
+ }
664
+ }
618
665
  if (overdueSet.size > 0) {
619
666
  console.log();
620
667
  console.log(chalk.yellow(` ${overdueSet.size} routine(s) overdue — catch up with: agents routines catchup`));
@@ -651,6 +698,8 @@ export function registerRoutinesCommands(program) {
651
698
  .option('--no-catchup', 'Do not run this routine late if its fire is missed (daemon down/asleep). The miss is still recorded. For routines whose value expires with their slot, e.g. a 9am brief.')
652
699
  .option('--disabled', 'Create the routine but keep it paused (enable later with resume)')
653
700
  .option('--resume <sessionId>', 'At fire time, resume this existing session id (via `agents run <agent> --resume`) instead of starting fresh — the actual session reopens with full context and the prompt becomes its next turn. Powers self-scheduled wake-ups (e.g. /hibernate). Requires --agent claude or codex; runs un-sandboxed (the session store lives in the real home, not the job overlay).')
701
+ .option('--project <name>', 'Associate with a named project (repeatable; use --all-projects for all)', collectProject, [])
702
+ .option('--all-projects', 'Associate this routine with all defined projects (sets projects: ["*"])')
654
703
  .option('--json', 'Emit machine-readable JSON with the created routine id and status')
655
704
  .action(async (nameOrPath, options) => {
656
705
  // Check if inline mode (has flags) or file mode
@@ -719,6 +768,34 @@ export function registerRoutinesCommands(program) {
719
768
  if (options.devices !== undefined) {
720
769
  devices = await parseAndValidateDevices(options.devices);
721
770
  }
771
+ // Parse and validate --project / --all-projects.
772
+ let projects;
773
+ if (options.allProjects) {
774
+ if (options.project && options.project.length > 0) {
775
+ console.error(chalk.red('--all-projects and --project are mutually exclusive'));
776
+ process.exit(1);
777
+ }
778
+ projects = ['*'];
779
+ }
780
+ else if (options.project && options.project.length > 0) {
781
+ // Validate each name: format check then existence check against defined projects.
782
+ for (const name of options.project) {
783
+ if (!isSafeProjectName(name)) {
784
+ console.error(chalk.red(`Invalid project name "${name}": must start with a letter or digit, contain only letters, digits, dots, hyphens, or underscores`));
785
+ process.exit(1);
786
+ }
787
+ }
788
+ // Deduplicate at the same canonical boundary writeJob uses, so the
789
+ // add path and a hand-authored YAML land identical persisted forms.
790
+ const deduped = normalizeProjects(options.project) ?? [];
791
+ const knownProjectNames = new Set(listProjectDefs().map((p) => p.name));
792
+ const unknown = deduped.filter((n) => !knownProjectNames.has(n));
793
+ if (unknown.length > 0) {
794
+ console.error(chalk.red(`Unknown project(s): ${unknown.join(', ')}. Define them first with: agents projects add`));
795
+ process.exit(1);
796
+ }
797
+ projects = deduped;
798
+ }
722
799
  let hostStrategy;
723
800
  try {
724
801
  hostStrategy = parseHostStrategy(options.placement) ?? undefined;
@@ -755,6 +832,7 @@ export function registerRoutinesCommands(program) {
755
832
  ...(options.catchup === false ? { catchup: false } : {}),
756
833
  ...(options.endAt ? { endAt: options.endAt } : {}),
757
834
  ...(options.resume ? { resume: options.resume } : {}),
835
+ ...(projects ? { projects } : {}),
758
836
  };
759
837
  const errors = validateJob(config);
760
838
  if (errors.length > 0) {
@@ -2307,7 +2307,13 @@ Examples:
2307
2307
  caller: `command ${cmd}`,
2308
2308
  keys: keysSubset,
2309
2309
  allowExpired: execOpts.allowExpired,
2310
- agentOnly: true,
2310
+ // An explicit `secrets exec` at a real terminal is a deliberate use of
2311
+ // the values: an unlocked bundle runs silently, a locked one resolves
2312
+ // with one Touch ID sheet, then the command runs with the secrets
2313
+ // injected. Under an agent (AGENTS_RUNTIME) or headless (no TTY) it
2314
+ // stays broker-only and points at the explicit unlock command instead
2315
+ // of raising a sheet — so release/CI scripts never prompt.
2316
+ agentOnly: isHeadlessSecretsContext() || !isInteractiveTerminal(),
2311
2317
  }).env;
2312
2318
  }
2313
2319
  const { spawn } = await import('child_process');
@@ -302,6 +302,47 @@ export declare function __resetAntigravityKeychainCacheForTest(): void;
302
302
  * Sync, no Keychain, no network — safe on the `agents run` hot path.
303
303
  */
304
304
  export declare function isClaudeCredentialFileBlank(base: string, platform?: NodeJS.Platform): boolean;
305
+ /**
306
+ * Identity of the Claude account a version home is (or was) logged into, read
307
+ * straight from `.claude.json`'s `oauthAccount`.
308
+ *
309
+ * Deliberately independent of whether the credential still works. `getAccountInfo`
310
+ * applies a credential floor so `agents view` and rotation route around an install
311
+ * that would die at spawn; attribution of *history* must not. A transcript written
312
+ * under an org is still that org's work after the token is revoked or the home is
313
+ * trashed, and on Linux/Windows the floor would otherwise erase the identity of
314
+ * every retired home (`isClaudeCredentialFileBlank` short-circuits only on darwin).
315
+ */
316
+ export interface ClaudeHomeIdentity {
317
+ email: string | null;
318
+ accountId: string | null;
319
+ organizationId: string | null;
320
+ organizationName: string | null;
321
+ organizationType: string | null;
322
+ /**
323
+ * Org-scoped identity — the rate-limit bucket, and the correct key to group by.
324
+ * Two orgs under one email (a Team seat and a personal Max plan) are separate
325
+ * buckets and MUST stay distinct; see `candidateIdentity` in lib/rotate.ts.
326
+ */
327
+ usageKey: string | null;
328
+ /** Account+org identity, narrower than `usageKey`. */
329
+ accountKey: string | null;
330
+ }
331
+ /** A version home's `.claude.json` plus the identity derived from it. */
332
+ export interface ClaudeHomeConfig {
333
+ /** The config file actually read. */
334
+ path: string;
335
+ config: Record<string, any>;
336
+ identity: ClaudeHomeIdentity;
337
+ }
338
+ /**
339
+ * Read a Claude home's config and account identity. Returns null when the home has
340
+ * no readable `.claude.json`, or has one with no `oauthAccount` (never signed in).
341
+ *
342
+ * Sync because the session scanner calls it once per home on a hot path, and the
343
+ * file is a few KB of local JSON. No Keychain access — see `getAccountInfo`.
344
+ */
345
+ export declare function readClaudeHomeConfig(base: string): ClaudeHomeConfig | null;
305
346
  export declare function getAccountInfo(agentId: AgentId, home?: string): Promise<AccountInfo>;
306
347
  /**
307
348
  * Determine when the agent was last used by checking session file mtimes,
@@ -1482,6 +1482,50 @@ export function isClaudeCredentialFileBlank(base, platform = process.platform) {
1482
1482
  return false;
1483
1483
  }
1484
1484
  }
1485
+ /**
1486
+ * Read a Claude home's config and account identity. Returns null when the home has
1487
+ * no readable `.claude.json`, or has one with no `oauthAccount` (never signed in).
1488
+ *
1489
+ * Sync because the session scanner calls it once per home on a hot path, and the
1490
+ * file is a few KB of local JSON. No Keychain access — see `getAccountInfo`.
1491
+ */
1492
+ export function readClaudeHomeConfig(base) {
1493
+ // Claude reads/writes config at $CLAUDE_CONFIG_DIR/.claude.json when set, falling
1494
+ // back to $HOME/.claude.json. Our shim sets CLAUDE_CONFIG_DIR to the per-version
1495
+ // .claude dir, so prefer that file; fall back to home-level for versions ever
1496
+ // launched without the shim (IDE extension, direct binary).
1497
+ const configDirFile = path.join(base, '.claude', '.claude.json');
1498
+ const homeLevelFile = path.join(base, '.claude.json');
1499
+ const activeFile = fs.existsSync(configDirFile) ? configDirFile : homeLevelFile;
1500
+ let config;
1501
+ try {
1502
+ config = JSON.parse(fs.readFileSync(activeFile, 'utf-8'));
1503
+ }
1504
+ catch {
1505
+ return null;
1506
+ }
1507
+ const oa = config.oauthAccount;
1508
+ if (!oa)
1509
+ return null;
1510
+ const accountId = normalizeIdentityPart(oa.accountUuid);
1511
+ const organizationId = normalizeIdentityPart(oa.organizationUuid);
1512
+ return {
1513
+ path: activeFile,
1514
+ config,
1515
+ identity: {
1516
+ email: oa.emailAddress || null,
1517
+ accountId,
1518
+ organizationId,
1519
+ organizationName: oa.organizationName ?? null,
1520
+ organizationType: oa.organizationType ?? null,
1521
+ usageKey: buildIdentityKey('claude', [['org', organizationId]]),
1522
+ accountKey: buildIdentityKey('claude', [
1523
+ ['account', accountId],
1524
+ ['org', organizationId],
1525
+ ]),
1526
+ },
1527
+ };
1528
+ }
1485
1529
  export async function getAccountInfo(agentId, home) {
1486
1530
  const base = home || os.homedir();
1487
1531
  const empty = {
@@ -1506,18 +1550,16 @@ export async function getAccountInfo(agentId, home) {
1506
1550
  try {
1507
1551
  switch (agentId) {
1508
1552
  case 'claude': {
1509
- // Claude reads/writes config at $CLAUDE_CONFIG_DIR/.claude.json when set,
1510
- // falling back to $HOME/.claude.json. Our shim sets CLAUDE_CONFIG_DIR to
1511
- // the per-version .claude dir, so prefer that file; fall back to home-level
1512
- // for versions ever launched without the shim (IDE extension, direct binary).
1513
- const configDirFile = path.join(base, '.claude', '.claude.json');
1514
- const homeLevelFile = path.join(base, '.claude.json');
1515
- const activeFile = fs.existsSync(configDirFile) ? configDirFile : homeLevelFile;
1516
- const data = JSON.parse(await fs.promises.readFile(activeFile, 'utf-8'));
1553
+ // Identity extraction is shared with the session scanner's account
1554
+ // attribution see readClaudeHomeConfig. A home with no readable config or
1555
+ // no oauthAccount is signed out, which is what the pre-refactor code
1556
+ // produced when JSON.parse threw or oauthAccount was absent.
1557
+ const claudeHome = readClaudeHomeConfig(base);
1558
+ if (!claudeHome)
1559
+ return { ...empty, lastActive };
1560
+ const { config: data, identity } = claudeHome;
1517
1561
  const oa = data.oauthAccount;
1518
- const accountId = normalizeIdentityPart(oa?.accountUuid);
1519
- const organizationId = normalizeIdentityPart(oa?.organizationUuid);
1520
- const email = oa?.emailAddress || null;
1562
+ const { accountId, organizationId, email, accountKey, usageKey } = identity;
1521
1563
  // Credential floor: a blanked credential file means this home cannot
1522
1564
  // authenticate, whatever `.claude.json` still says. Report it signed out
1523
1565
  // so `agents view` prompts a re-login and rotation routes around it,
@@ -1525,11 +1567,6 @@ export async function getAccountInfo(agentId, home) {
1525
1567
  if (email && isClaudeCredentialFileBlank(base)) {
1526
1568
  return { ...empty, lastActive };
1527
1569
  }
1528
- const accountKey = buildIdentityKey(agentId, [
1529
- ['account', accountId],
1530
- ['org', organizationId],
1531
- ]);
1532
- const usageKey = buildIdentityKey(agentId, [['org', organizationId]]);
1533
1570
  // Plan tier is derived from .claude.json's organizationType, which carries
1534
1571
  // the TRUE tier (claude_max → "Max", claude_pro → "Pro", claude_team →
1535
1572
  // "Team") and is already in-hand from the config we just read — no Keychain
@@ -227,7 +227,87 @@ export interface JobConfig {
227
227
  * RUSH-2020.
228
228
  */
229
229
  actor?: string;
230
+ /**
231
+ * Named projects this routine belongs to. Metadata-only: organises the
232
+ * routine under a project group in `agents routines list` and the menu bar;
233
+ * has no effect on scheduling or execution.
234
+ *
235
+ * Special values:
236
+ * - `["*"]` — routine applies to all defined projects (the "All projects" group).
237
+ * - A single name — routine belongs to that specific project.
238
+ * - Multiple names — routine spans several projects ("Cross-project" group).
239
+ * - Absent/empty — routine belongs to no project ("Operations" group).
240
+ */
241
+ projects?: string[];
230
242
  }
243
+ /**
244
+ * Canonical form of a routine's `projects` field: drop non-string and empty
245
+ * entries and deduplicate while preserving first-seen order. This is the single
246
+ * source of truth for project-name normalization, applied at the schema
247
+ * boundary (`writeJob` before persistence) and at grouping (`computeProjectGroupKind`)
248
+ * so a hand-authored YAML with duplicates (`projects: [myapp, myapp]`) is
249
+ * treated identically to the canonical single-entry form everywhere.
250
+ *
251
+ * Returns `undefined` when nothing survives, so callers can omit the field.
252
+ */
253
+ export declare function normalizeProjects(projects: string[] | undefined): string[] | undefined;
254
+ /**
255
+ * A routine's project bucket, discriminated by `kind` so buckets are never keyed
256
+ * on their human display label. A named project called literally "Operations" or
257
+ * "Cross-project" is `{ kind: 'named', name }` and can never collide with the
258
+ * `operations` / `cross` special buckets that happen to share those titles.
259
+ */
260
+ export type ProjectGroup = {
261
+ kind: 'named';
262
+ name: string;
263
+ } | {
264
+ kind: 'all';
265
+ } | {
266
+ kind: 'cross';
267
+ } | {
268
+ kind: 'operations';
269
+ } | {
270
+ kind: 'unknown';
271
+ };
272
+ /**
273
+ * Classify a routine's `projects` field into a discriminated {@link ProjectGroup}.
274
+ * Duplicates are collapsed first ({@link normalizeProjects}), so `[myapp, myapp]`
275
+ * is a single named project, not a "Cross-project" span.
276
+ *
277
+ * @param projects - The routine's projects array (may be undefined).
278
+ * @param knownProjectNames - The set of currently defined project names (from `listProjectDefs`).
279
+ */
280
+ export declare function computeProjectGroupKind(projects: string[] | undefined, knownProjectNames: Set<string>): ProjectGroup;
281
+ /** Human display title for a {@link ProjectGroup}. */
282
+ export declare function projectGroupTitle(group: ProjectGroup): string;
283
+ /**
284
+ * Stable bucket key for a {@link ProjectGroup}. Named projects key on their name
285
+ * under a `named:` prefix; specials key on their `kind` under a `special:` prefix.
286
+ * The two namespaces can never collide, so a project named "Operations" gets its
287
+ * own bucket separate from the no-project "Operations" special.
288
+ */
289
+ export declare function projectGroupKey(group: ProjectGroup): string;
290
+ /** Sort rank for a {@link ProjectGroup}: named projects first, then specials in a fixed order. */
291
+ export declare function projectGroupOrder(group: ProjectGroup): number;
292
+ /**
293
+ * Compute the display group label for a routine's `projects` field.
294
+ *
295
+ * Kept as the label-returning form for the JSON `projectGroup` field and any
296
+ * text consumer; grouping and ordering use the discriminated
297
+ * {@link computeProjectGroupKind}/{@link projectGroupKey} instead so buckets are
298
+ * never keyed on the label.
299
+ *
300
+ * @param projects - The routine's projects array (may be undefined).
301
+ * @param knownProjectNames - The set of currently defined project names (from `listProjectDefs`).
302
+ *
303
+ * Returns one of:
304
+ * - A specific project name — when `projects` has exactly one known name.
305
+ * - `"All projects"` — when `projects` is `["*"]`.
306
+ * - `"Cross-project"` — when `projects` has multiple distinct known entries.
307
+ * - `"Operations"` — when `projects` is absent or empty.
308
+ * - `"Unknown projects"` — when any entry is no longer a defined project (stale).
309
+ */
310
+ export declare function computeProjectGroup(projects: string[] | undefined, knownProjectNames: Set<string>): string;
231
311
  /** Metadata for a single job execution, persisted as JSON in the run directory. */
232
312
  export interface RunMeta {
233
313
  jobName: string;