@phnx-labs/agents-cli 1.22.18 → 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.
- package/CHANGELOG.md +67 -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/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/refresh.js +35 -21
- 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/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;
|
package/dist/lib/routines.js
CHANGED
|
@@ -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. */
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
}
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
/**
|
|
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.
|