@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 +70 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/cost.js +10 -6
- package/dist/commands/output.js +10 -6
- package/dist/commands/routines.d.ts +6 -0
- package/dist/commands/routines.js +83 -5
- package/dist/commands/secrets.js +7 -1
- package/dist/lib/agents.d.ts +41 -0
- package/dist/lib/agents.js +53 -16
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/routines.d.ts +80 -0
- package/dist/lib/routines.js +120 -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/session/claude-accounts.d.ts +128 -0
- package/dist/lib/session/claude-accounts.js +244 -0
- package/dist/lib/session/db.d.ts +13 -3
- package/dist/lib/session/db.js +117 -12
- package/dist/lib/session/discover.js +29 -44
- package/dist/lib/session/types.d.ts +13 -0
- package/package.json +1 -1
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
|
package/dist/commands/cost.js
CHANGED
|
@@ -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
|
|
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'
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/commands/output.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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) {
|
package/dist/commands/secrets.js
CHANGED
|
@@ -2307,7 +2307,13 @@ Examples:
|
|
|
2307
2307
|
caller: `command ${cmd}`,
|
|
2308
2308
|
keys: keysSubset,
|
|
2309
2309
|
allowExpired: execOpts.allowExpired,
|
|
2310
|
-
|
|
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');
|
package/dist/lib/agents.d.ts
CHANGED
|
@@ -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,
|
package/dist/lib/agents.js
CHANGED
|
@@ -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
|
-
//
|
|
1510
|
-
//
|
|
1511
|
-
//
|
|
1512
|
-
//
|
|
1513
|
-
const
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
const data
|
|
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 =
|
|
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
|
|
Binary file
|
|
Binary file
|
package/dist/lib/routines.d.ts
CHANGED
|
@@ -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;
|