@phnx-labs/agents-cli 1.22.19 → 1.22.20

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.
@@ -12,6 +12,7 @@ import * as yaml from 'yaml';
12
12
  import { Cron } from 'croner';
13
13
  import { getRoutinesDir, getSystemRoutinesDir, getRunsDir, ensureAgentsDir, getProjectRoutinesDir } from './state.js';
14
14
  import { safeJoin, isSafeSegmentName } from './paths.js';
15
+ import { isSafeProjectName } from './projects.js';
15
16
  import { atomicWriteFileSync } from './fs-atomic.js';
16
17
  import { ALL_AGENT_IDS } from './agents.js';
17
18
  import { machineId, normalizeHost } from './machine-id.js';
@@ -54,6 +55,93 @@ export function normalizeTriggerEvent(input) {
54
55
  };
55
56
  return aliases[key] ?? null;
56
57
  }
58
+ /**
59
+ * Canonical form of a routine's `projects` field: drop non-string and empty
60
+ * entries and deduplicate while preserving first-seen order. This is the single
61
+ * source of truth for project-name normalization, applied at the schema
62
+ * boundary (`writeJob` before persistence) and at grouping (`computeProjectGroupKind`)
63
+ * so a hand-authored YAML with duplicates (`projects: [myapp, myapp]`) is
64
+ * treated identically to the canonical single-entry form everywhere.
65
+ *
66
+ * Returns `undefined` when nothing survives, so callers can omit the field.
67
+ */
68
+ export function normalizeProjects(projects) {
69
+ if (!Array.isArray(projects) || projects.length === 0)
70
+ return undefined;
71
+ const out = [...new Set(projects.filter((p) => typeof p === 'string' && p !== ''))];
72
+ return out.length === 0 ? undefined : out;
73
+ }
74
+ /**
75
+ * Classify a routine's `projects` field into a discriminated {@link ProjectGroup}.
76
+ * Duplicates are collapsed first ({@link normalizeProjects}), so `[myapp, myapp]`
77
+ * is a single named project, not a "Cross-project" span.
78
+ *
79
+ * @param projects - The routine's projects array (may be undefined).
80
+ * @param knownProjectNames - The set of currently defined project names (from `listProjectDefs`).
81
+ */
82
+ export function computeProjectGroupKind(projects, knownProjectNames) {
83
+ const norm = normalizeProjects(projects);
84
+ if (!norm)
85
+ return { kind: 'operations' };
86
+ if (norm.length === 1 && norm[0] === '*')
87
+ return { kind: 'all' };
88
+ const hasUnknown = norm.some((p) => p !== '*' && !knownProjectNames.has(p));
89
+ if (hasUnknown)
90
+ return { kind: 'unknown' };
91
+ if (norm.length === 1)
92
+ return { kind: 'named', name: norm[0] };
93
+ return { kind: 'cross' };
94
+ }
95
+ /** Human display title for a {@link ProjectGroup}. */
96
+ export function projectGroupTitle(group) {
97
+ switch (group.kind) {
98
+ case 'named': return group.name;
99
+ case 'all': return 'All projects';
100
+ case 'cross': return 'Cross-project';
101
+ case 'operations': return 'Operations';
102
+ case 'unknown': return 'Unknown projects';
103
+ }
104
+ }
105
+ /**
106
+ * Stable bucket key for a {@link ProjectGroup}. Named projects key on their name
107
+ * under a `named:` prefix; specials key on their `kind` under a `special:` prefix.
108
+ * The two namespaces can never collide, so a project named "Operations" gets its
109
+ * own bucket separate from the no-project "Operations" special.
110
+ */
111
+ export function projectGroupKey(group) {
112
+ return group.kind === 'named' ? `named:${group.name}` : `special:${group.kind}`;
113
+ }
114
+ /** Sort rank for a {@link ProjectGroup}: named projects first, then specials in a fixed order. */
115
+ export function projectGroupOrder(group) {
116
+ switch (group.kind) {
117
+ case 'named': return 0;
118
+ case 'all': return 1;
119
+ case 'cross': return 2;
120
+ case 'operations': return 3;
121
+ case 'unknown': return 4;
122
+ }
123
+ }
124
+ /**
125
+ * Compute the display group label for a routine's `projects` field.
126
+ *
127
+ * Kept as the label-returning form for the JSON `projectGroup` field and any
128
+ * text consumer; grouping and ordering use the discriminated
129
+ * {@link computeProjectGroupKind}/{@link projectGroupKey} instead so buckets are
130
+ * never keyed on the label.
131
+ *
132
+ * @param projects - The routine's projects array (may be undefined).
133
+ * @param knownProjectNames - The set of currently defined project names (from `listProjectDefs`).
134
+ *
135
+ * Returns one of:
136
+ * - A specific project name — when `projects` has exactly one known name.
137
+ * - `"All projects"` — when `projects` is `["*"]`.
138
+ * - `"Cross-project"` — when `projects` has multiple distinct known entries.
139
+ * - `"Operations"` — when `projects` is absent or empty.
140
+ * - `"Unknown projects"` — when any entry is no longer a defined project (stale).
141
+ */
142
+ export function computeProjectGroup(projects, knownProjectNames) {
143
+ return projectGroupTitle(computeProjectGroupKind(projects, knownProjectNames));
144
+ }
57
145
  /**
58
146
  * Finalize a run record with a terminal status, computing `duration` from
59
147
  * `startedAt` and the completion timestamp. Keeps failure-reason population
@@ -386,6 +474,15 @@ export function writeJob(config) {
386
474
  if (output.catchup === true || output.catchup === undefined)
387
475
  delete output.catchup;
388
476
  delete output.devices;
477
+ // Persist projects in canonical form: deduplicated, first-seen order, field
478
+ // omitted when nothing survives. This is the schema boundary, so a routine
479
+ // written from any path (add, edit, enable/disable re-write) lands canonical
480
+ // regardless of how the caller assembled the array.
481
+ const normProjects = normalizeProjects(output.projects);
482
+ if (normProjects)
483
+ output.projects = normProjects;
484
+ else
485
+ delete output.projects;
389
486
  let existingText = null;
390
487
  if (ymlExists || yamlExists) {
391
488
  try {
@@ -627,6 +724,29 @@ export function validateJob(config) {
627
724
  if (config.catchup !== undefined && typeof config.catchup !== 'boolean') {
628
725
  errors.push('catchup must be a boolean (false to skip running a missed fire late)');
629
726
  }
727
+ if (config.projects !== undefined) {
728
+ if (!Array.isArray(config.projects)) {
729
+ errors.push('projects must be an array of project names (or ["*"] for all projects)');
730
+ }
731
+ else if (config.projects.length === 1 && config.projects[0] === '*') {
732
+ // ["*"] is valid: "all projects" sentinel
733
+ }
734
+ else if (config.projects.includes('*')) {
735
+ errors.push('projects: "*" (all projects) must be the sole entry');
736
+ }
737
+ else {
738
+ for (const p of config.projects) {
739
+ if (typeof p !== 'string' || p.trim() === '') {
740
+ errors.push('each entry in projects must be a non-empty project name');
741
+ break;
742
+ }
743
+ if (!isSafeProjectName(p)) {
744
+ errors.push(`invalid project name "${p}": must start with a letter or digit, contain only letters, digits, dots, hyphens, or underscores`);
745
+ break;
746
+ }
747
+ }
748
+ }
749
+ }
630
750
  return errors;
631
751
  }
632
752
  /** Validate a job trigger block, returning a list of human-readable errors. */
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Which Claude account produced a transcript.
3
+ *
4
+ * A Claude `.jsonl` records `sessionId`, `cwd`, `version`, `gitBranch` and per-message
5
+ * `usage`, but carries **no account identity** — no `accountUuid`, no
6
+ * `organizationUuid`, no email. What agents-cli does have is the version layout: every
7
+ * installed version gets its own home with its own `.claude.json` (`CLAUDE_CONFIG_DIR`
8
+ * is swapped per version, see lib/exec.ts), so a home identifies an account.
9
+ *
10
+ * This matters because the default run strategy is `balanced` (lib/rotate.ts), which
11
+ * sprays sessions across every signed-in account. Before this module the scanner
12
+ * resolved ONE email process-globally and stamped it on every Claude session, so a
13
+ * machine with several accounts reported all of its history under whichever one
14
+ * happened to resolve first.
15
+ *
16
+ * Grouping is keyed on the **org** (`usageKey`), never the email: two orgs under one
17
+ * email (a Team seat and a personal Max plan) are separate quota buckets and must stay
18
+ * distinct — the same invariant `candidateIdentity` enforces in lib/rotate.ts.
19
+ *
20
+ * ## Evidence tiers
21
+ *
22
+ * Attribution is a pure function of (path, recorded version). It performs no per-file
23
+ * I/O and does not need the transcript to still exist, which is what lets the v33
24
+ * migration backfill already-indexed rows without re-parsing anything.
25
+ *
26
+ * 1. **The path names a home we can identify.** Strongest: the file physically lives in
27
+ * that home, including a retired `trash/` snapshot, which keeps its `.claude.json`.
28
+ * 1b. **The path names a home that exists but is signed out.** Dark, named after that
29
+ * home. The location proves which config dir Claude used, so this deliberately beats
30
+ * a recorded version — attributing it to some other version's account would be a
31
+ * guess dressed as evidence.
32
+ * 2. **The path is outside every known home, and the row records a version.** Resolve
33
+ * that version's own home. Covers the mutable `~/.claude` symlink and the routine
34
+ * archives under `<historyDir>/runs` that `readRoutineArchiveMeta` feeds in. The
35
+ * symlink's target moves with `agents use`, so "whatever it points at now" is weak
36
+ * evidence for old rows: on the machine this was developed against only 684 of 1,334
37
+ * such rows came from the version the symlink currently names, and 322 came from
38
+ * versions belonging to a *different* org.
39
+ * 3. **Under the symlink with no recorded version at all.** Its current target is the
40
+ * only evidence there is, and the bucket says so via `evidence`. A version that IS
41
+ * recorded but resolves to no home stops at tier 2 and stays dark — it never
42
+ * reaches here.
43
+ * 4. **None of the above.** An explicitly dark bucket, labelled with why. Never folded
44
+ * into a real account and never dropped.
45
+ *
46
+ * ## Harness scope: Claude only, deliberately
47
+ *
48
+ * Attribution is implemented for Claude and no other harness. It depends on the
49
+ * per-version home carrying an `oauthAccount` in `.claude.json`, which is what makes a
50
+ * home equal an account. The other harnesses do have per-version credential files
51
+ * (`CREDENTIAL_FILE_SEGMENTS` in lib/agents.ts), so the mechanism generalizes — codex
52
+ * stores an `auth.json` JWT, gemini a `google_accounts.json` — but each needs its own
53
+ * identity extractor and its own notion of a quota bucket, and none of them has the
54
+ * two-orgs-one-email problem that motivated keying on the org here.
55
+ *
56
+ * Until that lands, a non-Claude session has a NULL `account_key` and rolls up under
57
+ * `unattributed:<agent>` — named after its harness rather than implying we tried and
58
+ * failed. `--by account` on `agents cost` / `agents output` therefore reports Claude
59
+ * accounts plus one bucket per other harness.
60
+ */
61
+ /** The account a transcript is attributed to. */
62
+ export interface ClaudeAccountBucket {
63
+ /**
64
+ * Stable grouping key. For an attributed bucket this is the org-scoped `usageKey`
65
+ * (e.g. `claude:org=<uuid>`). For an unattributed one it is `unattributed:<reason>`
66
+ * so distinct dark sources never merge into each other or into a real account.
67
+ */
68
+ key: string;
69
+ /** True when the key came from a real `oauthAccount`. */
70
+ attributed: boolean;
71
+ email: string | null;
72
+ orgName: string | null;
73
+ /** "Team", "Max", "Pro", … derived from `organizationType`. */
74
+ plan: string | null;
75
+ /** Display string: org and email, or the reason a bucket is dark. */
76
+ label: string;
77
+ /** Which evidence tier produced this attribution. */
78
+ evidence: 'version-home' | 'recorded-version' | 'symlink-target' | 'none';
79
+ }
80
+ interface HomeEntry {
81
+ /** Literal path prefix a transcript must start with to belong to this home. */
82
+ prefix: string;
83
+ bucket: ClaudeAccountBucket;
84
+ }
85
+ /** Resolver over the Claude homes present on this machine. */
86
+ export interface ClaudeAccountIndex {
87
+ /** Version- and trash-home prefixes, longest first. Excludes the `~/.claude` symlink. */
88
+ entries: HomeEntry[];
89
+ /**
90
+ * Config-dir prefixes of homes that exist but carry no `oauthAccount`. Kept
91
+ * separately from `entries` so a transcript living in a signed-out home is reported
92
+ * against THAT home rather than falling through to its recorded version: the file's
93
+ * location is what proves which config dir Claude was pointed at.
94
+ */
95
+ darkHomes: Array<{
96
+ prefix: string;
97
+ version: string | null;
98
+ }>;
99
+ /**
100
+ * Claude CLI version → the account that version ran as. `'ambiguous'` when retired
101
+ * snapshots of one version disagree and no live home settles it, which is reported
102
+ * as dark rather than guessed.
103
+ */
104
+ byVersion: Map<string, ClaudeAccountBucket | 'ambiguous'>;
105
+ /** Whatever `~/.claude` points at right now; tier-3 evidence only. */
106
+ symlinkBucket: ClaudeAccountBucket | null;
107
+ /** Literal prefix of the live symlinked config dir. */
108
+ symlinkPrefix: string;
109
+ }
110
+ /**
111
+ * Enumerate every Claude home that could own an indexed transcript. Includes retired
112
+ * `trash/` snapshots: they keep their `.claude.json`, so a transcript indexed before
113
+ * its version was rotated out stays attributable.
114
+ */
115
+ export declare function buildClaudeAccountIndex(): ClaudeAccountIndex;
116
+ /**
117
+ * The account bucket a transcript belongs to. `recordedVersion` is the Claude CLI
118
+ * version stored on the session row (`sessions.version`), which is what disambiguates
119
+ * rows sitting under the mutable `~/.claude` symlink.
120
+ *
121
+ * Never returns null: a transcript that matches no known home resolves to an
122
+ * explicitly dark bucket rather than being dropped or folded into a real account.
123
+ * Backup mirrors (`<historyDir>/backups/claude/<stamp>/projects/…`) carry no
124
+ * `.claude.json` of their own, so they resolve by recorded version like any other
125
+ * out-of-home path, and go dark only when that version names no home.
126
+ */
127
+ export declare function resolveClaudeAccount(index: ClaudeAccountIndex, filePath: string, recordedVersion?: string | null): ClaudeAccountBucket;
128
+ export {};
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Which Claude account produced a transcript.
3
+ *
4
+ * A Claude `.jsonl` records `sessionId`, `cwd`, `version`, `gitBranch` and per-message
5
+ * `usage`, but carries **no account identity** — no `accountUuid`, no
6
+ * `organizationUuid`, no email. What agents-cli does have is the version layout: every
7
+ * installed version gets its own home with its own `.claude.json` (`CLAUDE_CONFIG_DIR`
8
+ * is swapped per version, see lib/exec.ts), so a home identifies an account.
9
+ *
10
+ * This matters because the default run strategy is `balanced` (lib/rotate.ts), which
11
+ * sprays sessions across every signed-in account. Before this module the scanner
12
+ * resolved ONE email process-globally and stamped it on every Claude session, so a
13
+ * machine with several accounts reported all of its history under whichever one
14
+ * happened to resolve first.
15
+ *
16
+ * Grouping is keyed on the **org** (`usageKey`), never the email: two orgs under one
17
+ * email (a Team seat and a personal Max plan) are separate quota buckets and must stay
18
+ * distinct — the same invariant `candidateIdentity` enforces in lib/rotate.ts.
19
+ *
20
+ * ## Evidence tiers
21
+ *
22
+ * Attribution is a pure function of (path, recorded version). It performs no per-file
23
+ * I/O and does not need the transcript to still exist, which is what lets the v33
24
+ * migration backfill already-indexed rows without re-parsing anything.
25
+ *
26
+ * 1. **The path names a home we can identify.** Strongest: the file physically lives in
27
+ * that home, including a retired `trash/` snapshot, which keeps its `.claude.json`.
28
+ * 1b. **The path names a home that exists but is signed out.** Dark, named after that
29
+ * home. The location proves which config dir Claude used, so this deliberately beats
30
+ * a recorded version — attributing it to some other version's account would be a
31
+ * guess dressed as evidence.
32
+ * 2. **The path is outside every known home, and the row records a version.** Resolve
33
+ * that version's own home. Covers the mutable `~/.claude` symlink and the routine
34
+ * archives under `<historyDir>/runs` that `readRoutineArchiveMeta` feeds in. The
35
+ * symlink's target moves with `agents use`, so "whatever it points at now" is weak
36
+ * evidence for old rows: on the machine this was developed against only 684 of 1,334
37
+ * such rows came from the version the symlink currently names, and 322 came from
38
+ * versions belonging to a *different* org.
39
+ * 3. **Under the symlink with no recorded version at all.** Its current target is the
40
+ * only evidence there is, and the bucket says so via `evidence`. A version that IS
41
+ * recorded but resolves to no home stops at tier 2 and stays dark — it never
42
+ * reaches here.
43
+ * 4. **None of the above.** An explicitly dark bucket, labelled with why. Never folded
44
+ * into a real account and never dropped.
45
+ *
46
+ * ## Harness scope: Claude only, deliberately
47
+ *
48
+ * Attribution is implemented for Claude and no other harness. It depends on the
49
+ * per-version home carrying an `oauthAccount` in `.claude.json`, which is what makes a
50
+ * home equal an account. The other harnesses do have per-version credential files
51
+ * (`CREDENTIAL_FILE_SEGMENTS` in lib/agents.ts), so the mechanism generalizes — codex
52
+ * stores an `auth.json` JWT, gemini a `google_accounts.json` — but each needs its own
53
+ * identity extractor and its own notion of a quota bucket, and none of them has the
54
+ * two-orgs-one-email problem that motivated keying on the org here.
55
+ *
56
+ * Until that lands, a non-Claude session has a NULL `account_key` and rolls up under
57
+ * `unattributed:<agent>` — named after its harness rather than implying we tried and
58
+ * failed. `--by account` on `agents cost` / `agents output` therefore reports Claude
59
+ * accounts plus one bucket per other harness.
60
+ */
61
+ import * as os from 'os';
62
+ import * as path from 'path';
63
+ import * as fs from 'fs';
64
+ import { readClaudeHomeConfig } from '../agents.js';
65
+ import { getAgentsDir, getHistoryDir } from '../state.js';
66
+ const HOME = os.homedir();
67
+ const VERSIONS_ROOTS = [getHistoryDir(), getAgentsDir()];
68
+ /** `claude_team` → "Team", `claude_max` → "Max". Mirrors lib/agents.ts's label logic. */
69
+ function planFromOrgType(orgType) {
70
+ if (!orgType)
71
+ return null;
72
+ const m = /^claude_(.+)$/.exec(orgType);
73
+ if (!m)
74
+ return orgType;
75
+ return m[1].charAt(0).toUpperCase() + m[1].slice(1);
76
+ }
77
+ function bucketForHome(home, evidence) {
78
+ const cfg = readClaudeHomeConfig(home);
79
+ if (!cfg)
80
+ return null;
81
+ const { email, organizationName: orgName, usageKey, accountKey, organizationType } = cfg.identity;
82
+ // No org uuid means no quota bucket to key on. Fall back to the narrower account key,
83
+ // then the email — an identity we cannot key is not one we should guess at.
84
+ const key = usageKey ?? accountKey ?? (email ? `claude:email=${email}` : null);
85
+ if (!key)
86
+ return null;
87
+ return {
88
+ key,
89
+ attributed: true,
90
+ email,
91
+ orgName,
92
+ plan: planFromOrgType(organizationType),
93
+ label: orgName && email ? `${orgName} <${email}>` : (orgName ?? email ?? key),
94
+ evidence,
95
+ };
96
+ }
97
+ /** A dark bucket, labelled by why it is dark so two dark sources never merge. */
98
+ function unattributed(reason) {
99
+ return {
100
+ key: `unattributed:${reason}`,
101
+ attributed: false,
102
+ email: null,
103
+ orgName: null,
104
+ plan: null,
105
+ label: `unattributed (${reason})`,
106
+ evidence: 'none',
107
+ };
108
+ }
109
+ function listDirs(dir) {
110
+ try {
111
+ return fs.readdirSync(dir, { withFileTypes: true })
112
+ .filter((e) => e.isDirectory() || e.isSymbolicLink())
113
+ .map((e) => e.name);
114
+ }
115
+ catch {
116
+ return [];
117
+ }
118
+ }
119
+ /**
120
+ * Enumerate every Claude home that could own an indexed transcript. Includes retired
121
+ * `trash/` snapshots: they keep their `.claude.json`, so a transcript indexed before
122
+ * its version was rotated out stays attributable.
123
+ */
124
+ export function buildClaudeAccountIndex() {
125
+ const entries = [];
126
+ const darkHomes = [];
127
+ const liveByVersion = new Map();
128
+ const trashByVersion = new Map();
129
+ const addHome = (home, version, retired) => {
130
+ const bucket = bucketForHome(home, 'version-home');
131
+ if (!bucket) {
132
+ // The home exists on disk but has no usable identity. Record its prefix so
133
+ // resolution can name it instead of guessing from a recorded version.
134
+ if (fs.existsSync(path.join(home, '.claude'))) {
135
+ darkHomes.push({ prefix: path.join(home, '.claude'), version });
136
+ }
137
+ return;
138
+ }
139
+ entries.push({ prefix: path.join(home, '.claude'), bucket });
140
+ if (!version)
141
+ return;
142
+ if (retired) {
143
+ const list = trashByVersion.get(version) ?? [];
144
+ list.push(bucket);
145
+ trashByVersion.set(version, list);
146
+ }
147
+ else {
148
+ liveByVersion.set(version, bucket);
149
+ }
150
+ };
151
+ for (const root of VERSIONS_ROOTS) {
152
+ const versionsBase = path.join(root, 'versions', 'claude');
153
+ for (const version of listDirs(versionsBase)) {
154
+ addHome(path.join(versionsBase, version, 'home'), version, false);
155
+ }
156
+ }
157
+ // Retired homes: <historyDir>/trash/versions/claude/<version>/<timestamp>/home
158
+ const trashBase = path.join(getHistoryDir(), 'trash', 'versions', 'claude');
159
+ for (const version of listDirs(trashBase)) {
160
+ for (const stamp of listDirs(path.join(trashBase, version))) {
161
+ addHome(path.join(trashBase, version, stamp, 'home'), version, true);
162
+ }
163
+ }
164
+ // A live home is authoritative for its version. Otherwise the retired snapshots
165
+ // decide, but only when they agree — disagreement is reported, not resolved.
166
+ const byVersion = new Map();
167
+ for (const [version, list] of trashByVersion) {
168
+ const keys = new Set(list.map((b) => b.key));
169
+ byVersion.set(version, keys.size === 1 ? { ...list[0], evidence: 'recorded-version' } : 'ambiguous');
170
+ }
171
+ for (const [version, bucket] of liveByVersion) {
172
+ byVersion.set(version, { ...bucket, evidence: 'recorded-version' });
173
+ }
174
+ // Longest prefix first so a nested home beats a shorter ancestor.
175
+ entries.sort((a, b) => b.prefix.length - a.prefix.length);
176
+ darkHomes.sort((a, b) => b.prefix.length - a.prefix.length);
177
+ return {
178
+ entries,
179
+ darkHomes,
180
+ byVersion,
181
+ symlinkBucket: bucketForHome(HOME, 'symlink-target'),
182
+ symlinkPrefix: path.join(HOME, '.claude'),
183
+ };
184
+ }
185
+ /** Version segment of a versions/ or trash/ path, for labelling dark buckets. */
186
+ function versionFromPath(filePath) {
187
+ const m = /[/\\]versions[/\\]claude[/\\]([^/\\]+)[/\\]/.exec(filePath);
188
+ return m ? m[1] : null;
189
+ }
190
+ /**
191
+ * The account bucket a transcript belongs to. `recordedVersion` is the Claude CLI
192
+ * version stored on the session row (`sessions.version`), which is what disambiguates
193
+ * rows sitting under the mutable `~/.claude` symlink.
194
+ *
195
+ * Never returns null: a transcript that matches no known home resolves to an
196
+ * explicitly dark bucket rather than being dropped or folded into a real account.
197
+ * Backup mirrors (`<historyDir>/backups/claude/<stamp>/projects/…`) carry no
198
+ * `.claude.json` of their own, so they resolve by recorded version like any other
199
+ * out-of-home path, and go dark only when that version names no home.
200
+ */
201
+ export function resolveClaudeAccount(index, filePath, recordedVersion) {
202
+ // Tier 1 — the file physically lives in a home we can identify.
203
+ for (const entry of index.entries) {
204
+ if (filePath.startsWith(entry.prefix + path.sep))
205
+ return entry.bucket;
206
+ }
207
+ // Tier 1b — it lives in a home that exists but is signed out. The location proves
208
+ // which config dir Claude used, so this beats any recorded version: reporting it
209
+ // against a *different* version's account would be a guess dressed as evidence.
210
+ for (const dark of index.darkHomes) {
211
+ if (filePath.startsWith(dark.prefix + path.sep)) {
212
+ return unattributed(dark.version ? `signed-out home ${dark.version}` : 'signed-out home');
213
+ }
214
+ }
215
+ // Tier 2 — outside every known home. The recorded version names the home that ran,
216
+ // which covers both the mutable ~/.claude symlink and the routine/run archives under
217
+ // <historyDir>/runs that readRoutineArchiveMeta feeds through this same path.
218
+ if (recordedVersion) {
219
+ const byVersion = index.byVersion.get(recordedVersion);
220
+ if (byVersion === 'ambiguous') {
221
+ return unattributed(`ambiguous history for version ${recordedVersion}`);
222
+ }
223
+ if (byVersion)
224
+ return byVersion;
225
+ // Recorded but unresolvable — the version was uninstalled and its trash snapshot
226
+ // pruned. Stay dark. Falling through to the symlink's current target would move
227
+ // these rows onto whichever account happens to be default now, which is the
228
+ // inference tier 2 exists to avoid.
229
+ return unattributed(`no home for version ${recordedVersion}`);
230
+ }
231
+ // Tier 3 — under the live symlink with no recorded version at all. Its current
232
+ // target is the only evidence that exists. A recorded-but-unresolvable version
233
+ // returned dark from tier 2 above and never arrives here.
234
+ if (filePath.startsWith(index.symlinkPrefix + path.sep) && index.symlinkBucket) {
235
+ return index.symlinkBucket;
236
+ }
237
+ const version = versionFromPath(filePath);
238
+ if (version)
239
+ return unattributed(`signed-out home ${version}`);
240
+ if (filePath.includes(`${path.sep}backups${path.sep}claude${path.sep}`)) {
241
+ return unattributed('backup mirror');
242
+ }
243
+ return unattributed('unknown home');
244
+ }
@@ -12,7 +12,7 @@ import { type IndexedToolCall } from './tool-calls.js';
12
12
  /** Current schema version; bumped when migrations are added. Exported so tests
13
13
  * assert against the constant instead of hardcoding a number that every bump
14
14
  * then has to chase (docs/05-sessions.md calls the constant the source of truth). */
15
- export declare const SCHEMA_VERSION = 32;
15
+ export declare const SCHEMA_VERSION = 33;
16
16
  /**
17
17
  * Bump to force `agents sessions backfill resources` to re-derive every
18
18
  * session's skill/slash-command tallies on its next run (resource_scan_ledger
@@ -30,6 +30,8 @@ export interface SessionRow {
30
30
  routine_run_id: string | null;
31
31
  version: string | null;
32
32
  account: string | null;
33
+ account_key: string | null;
34
+ account_org: string | null;
33
35
  mode: string | null;
34
36
  timestamp: string;
35
37
  last_activity: string | null;
@@ -297,8 +299,16 @@ export declare function querySessions(options?: QueryOptions): SessionMeta[];
297
299
  export declare function countSessions(options?: QueryOptions): number;
298
300
  /** One grouped row in a cost/duration rollup. */
299
301
  export interface UsageRollupRow {
300
- /** Grouping key value: the agent id, project name, or ISO date (YYYY-MM-DD). */
302
+ /**
303
+ * Grouping key value: the agent id, project name, ISO date (YYYY-MM-DD), or
304
+ * account identity (`claude:org=<uuid>` / `unattributed:<reason>`).
305
+ */
301
306
  key: string;
307
+ /**
308
+ * Human label for the key when it is not itself readable — an org uuid is an
309
+ * identity, not something to show a user. Absent when `key` reads fine on its own.
310
+ */
311
+ label?: string;
302
312
  costUsd: number;
303
313
  durationMs: number;
304
314
  sessionCount: number;
@@ -307,7 +317,7 @@ export interface UsageRollupRow {
307
317
  outputTokens: number;
308
318
  }
309
319
  /** What to group a usage rollup by. */
310
- export type UsageRollupGroup = 'agent' | 'project' | 'day';
320
+ export type UsageRollupGroup = 'agent' | 'project' | 'day' | 'account';
311
321
  /**
312
322
  * Smart-launch affinity priors: group sessions by origin machine, harness, or
313
323
  * joint (machine + agent). Ordered by launch count desc.