@phnx-labs/agents-cli 1.22.105 → 1.22.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +28 -6
  3. package/dist/browser.js +0 -0
  4. package/dist/commands/exec.d.ts +78 -8
  5. package/dist/commands/exec.js +407 -284
  6. package/dist/commands/resume.d.ts +6 -21
  7. package/dist/commands/resume.js +18 -55
  8. package/dist/commands/run-account-picker.d.ts +11 -0
  9. package/dist/commands/run-account-picker.js +11 -1
  10. package/dist/commands/sessions-resume.d.ts +4 -0
  11. package/dist/commands/sessions-resume.js +132 -49
  12. package/dist/commands/sessions.js +32 -5
  13. package/dist/index.js +0 -0
  14. package/dist/lib/accounting/account-launch.d.ts +54 -0
  15. package/dist/lib/accounting/account-launch.js +117 -0
  16. package/dist/lib/accounting/account-pool-collect.js +2 -1
  17. package/dist/lib/accounting/account-pool.d.ts +2 -0
  18. package/dist/lib/accounting/account-pool.js +1 -0
  19. package/dist/lib/accounting/rotate.d.ts +32 -6
  20. package/dist/lib/accounting/rotate.js +70 -42
  21. package/dist/lib/accounting/usage.d.ts +71 -0
  22. package/dist/lib/accounting/usage.js +160 -11
  23. package/dist/lib/exec-account-home.d.ts +3 -1
  24. package/dist/lib/exec-account-home.js +2 -2
  25. package/dist/lib/exec.d.ts +27 -1
  26. package/dist/lib/exec.js +150 -27
  27. package/dist/lib/hosts/dispatch.d.ts +1 -1
  28. package/dist/lib/hosts/dispatch.js +1 -1
  29. package/dist/lib/models.d.ts +1 -1
  30. package/dist/lib/models.js +4 -4
  31. package/dist/lib/session/actor-sidecar.d.ts +3 -11
  32. package/dist/lib/session/actor-sidecar.js +3 -0
  33. package/dist/lib/session/claude-accounts.d.ts +12 -73
  34. package/dist/lib/session/claude-accounts.js +32 -70
  35. package/dist/lib/session/db.d.ts +1 -1
  36. package/dist/lib/session/db.js +18 -5
  37. package/dist/lib/session/discover.d.ts +4 -0
  38. package/dist/lib/session/discover.js +116 -15
  39. package/dist/lib/session/recovery.d.ts +30 -34
  40. package/dist/lib/session/recovery.js +212 -76
  41. package/dist/lib/session/types.d.ts +2 -0
  42. package/dist/lib/teams/placement-probe.js +1 -1
  43. package/dist/session-tracker/dist/adapters/claude.d.ts +10 -0
  44. package/dist/session-tracker/dist/adapters/claude.js +45 -0
  45. package/dist/session-tracker/dist/hook.sh +191 -0
  46. package/dist/session-tracker/dist/index.d.ts +19 -0
  47. package/dist/session-tracker/dist/index.js +67 -0
  48. package/dist/session-tracker/dist/install-hook.d.ts +19 -0
  49. package/dist/session-tracker/dist/install-hook.js +245 -0
  50. package/dist/session-tracker/dist/prune-state.d.ts +2 -0
  51. package/dist/session-tracker/dist/prune-state.js +7 -0
  52. package/dist/session-tracker/dist/reader.d.ts +7 -0
  53. package/dist/session-tracker/dist/reader.js +151 -0
  54. package/dist/session-tracker/dist/state-file.d.ts +10 -0
  55. package/dist/session-tracker/dist/state-file.js +119 -0
  56. package/dist/session-tracker/dist/types.d.ts +32 -0
  57. package/dist/session-tracker/dist/types.js +1 -0
  58. package/dist/session-tracker/dist/writer.d.ts +12 -0
  59. package/dist/session-tracker/dist/writer.js +27 -0
  60. package/package.json +1 -1
@@ -14,18 +14,10 @@ export interface SessionActorRecord {
14
14
  phoenixId?: string;
15
15
  /** Effective permissions mode used by the launcher. */
16
16
  mode?: SessionRunMode;
17
- /**
18
- * The agents-cli version-home id this session launched under (e.g. `2.1.207`,
19
- * codex `0.146.0`) — the same namespace `listInstalledVersions` /
20
- * `collectRunCandidates` use, so a native resume can pin the exact origin
21
- * version. Recorded at launch by the SessionStart hook (from `AGENTS_RUN_VERSION`),
22
- * because a harness coins its real session id only AFTER spawn — the same reason
23
- * `mode` rides the hook rather than a spawn-time `writeSessionActorRecord`. Joined
24
- * onto the session index at scan time so a session whose transcript carries no
25
- * embedded/derivable version (codex's `.codex-homes/<version>/` layout) no longer
26
- * degrades native resume to `/continue` for lack of a recorded origin (PHNX-3626).
27
- */
17
+ /** Installed executable label at launch; provenance only, never account identity. */
28
18
  version?: string;
19
+ /** Credential account used at launch, independent of the installed executable. */
20
+ accountId?: string;
29
21
  /**
30
22
  * Custom harness / profile name when launched via `agents run <profile>`
31
23
  * (e.g. `deepseek`). Joined onto the session index at scan time so a
@@ -42,6 +42,7 @@ function hasRecordData(record) {
42
42
  || typeof record.phoenixId === 'string'
43
43
  || typeof record.mode === 'string'
44
44
  || typeof record.version === 'string'
45
+ || typeof record.accountId === 'string'
45
46
  || typeof record.harness === 'string'
46
47
  || (Array.isArray(record.aliases) && record.aliases.some(alias => typeof alias === 'string'));
47
48
  }
@@ -69,6 +70,7 @@ export function writeSessionActorRecord(record) {
69
70
  writeRecord({
70
71
  ...previous,
71
72
  ...record,
73
+ accountId: previous?.accountId ?? record.accountId,
72
74
  aliases: normalizedAliases([...(previous?.aliases ?? []), ...(record.aliases ?? [])]),
73
75
  });
74
76
  }
@@ -88,6 +90,7 @@ export function writeSessionAliasRecord(sessionId, alias) {
88
90
  phoenixId: previous?.phoenixId,
89
91
  mode: previous?.mode,
90
92
  version: previous?.version,
93
+ accountId: previous?.accountId,
91
94
  harness: previous?.harness,
92
95
  aliases: normalizedAliases([...(previous?.aliases ?? []), alias]),
93
96
  startedAtMs: previous?.startedAtMs ?? Date.now(),
@@ -1,63 +1,3 @@
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 insights cost` / `agents insights output` therefore reports Claude
59
- * accounts plus one bucket per other harness.
60
- */
61
1
  /** The account a transcript is attributed to. */
62
2
  export interface ClaudeAccountBucket {
63
3
  /**
@@ -84,7 +24,7 @@ interface HomeEntry {
84
24
  }
85
25
  /** Resolver over the Claude homes present on this machine. */
86
26
  export interface ClaudeAccountIndex {
87
- /** Version- and trash-home prefixes, longest first. Excludes the `~/.claude` symlink. */
27
+ /** Version-, account-slot-, and trash-home prefixes, longest first. Excludes the `~/.claude` symlink. */
88
28
  entries: HomeEntry[];
89
29
  /**
90
30
  * Config-dir prefixes of homes that exist but carry no `oauthAccount`. Kept
@@ -102,6 +42,15 @@ export interface ClaudeAccountIndex {
102
42
  * as dark rather than guessed.
103
43
  */
104
44
  byVersion: Map<string, ClaudeAccountBucket | 'ambiguous'>;
45
+ /**
46
+ * Account-slot id (`<historyDir>/accounts/claude/<accountId>/`, PHNX-3940) →
47
+ * the identity read from that slot's own `.claude.json`. A slot's identity is
48
+ * proven the same way a version home's is — tier 1 evidence, see
49
+ * {@link resolveClaudeAccount} — so this map exists only to let a launch-
50
+ * recorded accountId (once the actor sidecar carries one) resolve straight to
51
+ * a bucket without re-deriving it from a path.
52
+ */
53
+ byAccountId: Map<string, ClaudeAccountBucket>;
105
54
  /** Whatever `~/.claude` points at right now; tier-3 evidence only. */
106
55
  symlinkBucket: ClaudeAccountBucket | null;
107
56
  /** Literal prefix of the live symlinked config dir. */
@@ -113,16 +62,6 @@ export interface ClaudeAccountIndex {
113
62
  * its version was rotated out stays attributable.
114
63
  */
115
64
  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;
65
+ /** Resolve a quota bucket without replacing recorded login provenance. */
66
+ export declare function resolveClaudeAccount(index: ClaudeAccountIndex, filePath: string, recordedVersion?: string | null, launchAccountId?: string | null): ClaudeAccountBucket;
128
67
  export {};
@@ -1,62 +1,8 @@
1
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 insights cost` / `agents insights output` therefore reports Claude
59
- * accounts plus one bucket per other harness.
2
+ * Claude quota attribution, separate from the stable login identity in accountId.
3
+ * Launch-recorded identity wins over current credentials in a reused home.
4
+ * Older rows retain their evidence-labelled path/version inference for historical
5
+ * quota reports; recovery never treats that org-scoped accountKey as login proof.
60
6
  */
61
7
  import * as os from 'os';
62
8
  import * as path from 'path';
@@ -126,6 +72,7 @@ export function buildClaudeAccountIndex() {
126
72
  const darkHomes = [];
127
73
  const liveByVersion = new Map();
128
74
  const trashByVersion = new Map();
75
+ const byAccountId = new Map();
129
76
  const addHome = (home, version, retired) => {
130
77
  const bucket = bucketForHome(home, 'version-home');
131
78
  if (!bucket) {
@@ -161,6 +108,26 @@ export function buildClaudeAccountIndex() {
161
108
  addHome(path.join(trashBase, version, stamp, 'home'), version, true);
162
109
  }
163
110
  }
111
+ // Account slots (PHNX-3940): a named account gets its own HOME-shaped dir at
112
+ // <historyDir>/accounts/claude/<accountId>/, sharing the one managed install
113
+ // rather than owning a version home of its own (lib/accounts/slots.ts
114
+ // `slotDir`). Each slot's `.claude.json` proves ITS identity exactly as
115
+ // directly as a version home's — tier 1 evidence in `resolveClaudeAccount` —
116
+ // so without this, every transcript a slot-launched session wrote was
117
+ // discoverable (the account is registered and runnable) yet permanently
118
+ // unattributed here, and `isManagedSessionFile`/`getAgentSessionDirs` would
119
+ // never even have scanned it in the first place. An unconfigured slot (never
120
+ // signed in) yields no bucket and is simply skipped, not recorded dark — a
121
+ // slot with nothing written to it yet owns no transcript to misattribute.
122
+ const accountsBase = path.join(getHistoryDir(), 'accounts', 'claude');
123
+ for (const accountId of listDirs(accountsBase)) {
124
+ const home = path.join(accountsBase, accountId);
125
+ const bucket = bucketForHome(home, 'version-home');
126
+ if (!bucket)
127
+ continue;
128
+ entries.push({ prefix: path.join(home, '.claude'), bucket });
129
+ byAccountId.set(accountId, bucket);
130
+ }
164
131
  // A live home is authoritative for its version. Otherwise the retired snapshots
165
132
  // decide, but only when they agree — disagreement is reported, not resolved.
166
133
  const byVersion = new Map();
@@ -178,6 +145,7 @@ export function buildClaudeAccountIndex() {
178
145
  entries,
179
146
  darkHomes,
180
147
  byVersion,
148
+ byAccountId,
181
149
  symlinkBucket: bucketForHome(HOME, 'symlink-target'),
182
150
  symlinkPrefix: path.join(HOME, '.claude'),
183
151
  };
@@ -187,18 +155,12 @@ function versionFromPath(filePath) {
187
155
  const m = /[/\\]versions[/\\]claude[/\\]([^/\\]+)[/\\]/.exec(filePath);
188
156
  return m ? m[1] : null;
189
157
  }
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) {
158
+ /** Resolve a quota bucket without replacing recorded login provenance. */
159
+ export function resolveClaudeAccount(index, filePath, recordedVersion, launchAccountId) {
160
+ // Launch provenance survives credential changes and transcript relocation.
161
+ if (launchAccountId) {
162
+ return index.byAccountId.get(launchAccountId) ?? unattributed(`recorded account ${launchAccountId}`);
163
+ }
202
164
  // Tier 1 — the file physically lives in a home we can identify.
203
165
  for (const entry of index.entries) {
204
166
  if (filePath.startsWith(entry.prefix + path.sep))
@@ -14,7 +14,7 @@ import { type TimelineState } from './timeline.js';
14
14
  /** Current schema version; bumped when migrations are added. Exported so tests
15
15
  * assert against the constant instead of hardcoding a number that every bump
16
16
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
17
- export declare const SCHEMA_VERSION = 48;
17
+ export declare const SCHEMA_VERSION = 49;
18
18
  /**
19
19
  * Bump to force the content extractor (assistant-answer text, alongside the
20
20
  * user-prompt text every harness already accumulates) to re-derive on every
@@ -29,7 +29,7 @@ const DB_PATH = getSessionsDbPath();
29
29
  /** Current schema version; bumped when migrations are added. Exported so tests
30
30
  * assert against the constant instead of hardcoding a number that every bump
31
31
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
32
- export const SCHEMA_VERSION = 48;
32
+ export const SCHEMA_VERSION = 49;
33
33
  /**
34
34
  * Bump to force the content extractor (assistant-answer text, alongside the
35
35
  * user-prompt text every harness already accumulates) to re-derive on every
@@ -95,6 +95,7 @@ CREATE TABLE IF NOT EXISTS sessions (
95
95
  version TEXT,
96
96
  account TEXT,
97
97
  account_key TEXT,
98
+ account_id TEXT,
98
99
  account_org TEXT,
99
100
  mode TEXT,
100
101
  timestamp TEXT NOT NULL,
@@ -1309,6 +1310,11 @@ function migrateSchema(db, fromVersion) {
1309
1310
  // The idx_sessions_mirror_synced index is created unconditionally after this
1310
1311
  // block (fresh DBs skip migrations), alongside idx_sessions_last_activity.
1311
1312
  }
1313
+ if (fromVersion < 49) {
1314
+ const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map(c => c.name));
1315
+ if (!cols.has('account_id'))
1316
+ db.exec(`ALTER TABLE sessions ADD COLUMN account_id TEXT`);
1317
+ }
1312
1318
  if (fromVersion < 48) {
1313
1319
  // v47 -> v48: retain the LATEST genuine user turn beside the first
1314
1320
  // (PHNX-3939). For a /continue, a redirect, or an interrupted-and-restated
@@ -1365,7 +1371,7 @@ function backfillClaudeAccounts(db, scope = 'all') {
1365
1371
  // `account = excluded.account` unconditionally. A dark row reads NULL.
1366
1372
  const update = db.prepare(`UPDATE sessions SET account_key = ?, account_org = ?, account = ? WHERE id = ?`);
1367
1373
  for (const row of rows) {
1368
- const bucket = resolveClaudeAccount(index, row.file_path ?? '', row.version);
1374
+ const bucket = resolveClaudeAccount(index, row.file_path ?? '', row.version, readSessionActorRecord(row.id)?.accountId);
1369
1375
  update.run(bucket.key, bucket.orgName, bucket.email, row.id);
1370
1376
  }
1371
1377
  }
@@ -1418,6 +1424,9 @@ export function getDB() {
1418
1424
  // scan column here rather than in SCHEMA (PHNX-3792).
1419
1425
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_mirror_synced ON sessions(mirror_synced_at)`);
1420
1426
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_routine_run_id ON sessions(routine_run_id)`);
1427
+ const sessionColumns = db.prepare('PRAGMA table_info(sessions)').all();
1428
+ if (!sessionColumns.some(column => column.name === 'account_id'))
1429
+ db.exec('ALTER TABLE sessions ADD COLUMN account_id TEXT');
1421
1430
  // Account attribution repair. Two ways a Claude row ends up wrong even at v33:
1422
1431
  // an older CLI (whose INSERT does not name the column) writes NULL, and a DB
1423
1432
  // migrated by a build that predates the "clear the stale email on a dark row" fix
@@ -1883,7 +1892,7 @@ export function recordDirScans(entries) {
1883
1892
  const upsertSessionStmt = (db) => db.prepare(`
1884
1893
  INSERT INTO sessions (
1885
1894
  id, short_id, agent, harness, origin, routine_name, routine_run_id,
1886
- version, account, account_key, account_org, mode, timestamp, last_activity,
1895
+ version, account, account_key, account_id, account_org, mode, timestamp, last_activity,
1887
1896
  project, cwd, git_branch, topic, first_user_message, last_user_message, label, message_count, token_count,
1888
1897
  output_tokens, input_tokens, cache_read_tokens, cache_write_tokens,
1889
1898
  cost_usd, cost_usd_nocache, duration_ms, model, tool_call_count,
@@ -1894,7 +1903,7 @@ const upsertSessionStmt = (db) => db.prepare(`
1894
1903
  actor, initiated_by, phoenix_id, used_browser, used_computer
1895
1904
  ) VALUES (
1896
1905
  @id, @short_id, @agent, @harness, @origin, @routine_name, @routine_run_id,
1897
- @version, @account, @account_key, @account_org, @mode, @timestamp, @last_activity,
1906
+ @version, @account, @account_key, @account_id, @account_org, @mode, @timestamp, @last_activity,
1898
1907
  @project, @cwd, @git_branch, @topic, @first_user_message, @last_user_message, @label, @message_count, @token_count,
1899
1908
  @output_tokens, @input_tokens, @cache_read_tokens, @cache_write_tokens,
1900
1909
  @cost_usd, @cost_usd_nocache, @duration_ms, @model, @tool_call_count,
@@ -1925,6 +1934,7 @@ const upsertSessionStmt = (db) => db.prepare(`
1925
1934
  account = excluded.account,
1926
1935
  account_key = excluded.account_key,
1927
1936
  account_org = excluded.account_org,
1937
+ account_id = COALESCE(sessions.account_id, excluded.account_id),
1928
1938
  mode = COALESCE(excluded.mode, sessions.mode),
1929
1939
  timestamp = excluded.timestamp,
1930
1940
  last_activity = excluded.last_activity,
@@ -2220,7 +2230,7 @@ export function upsertSession(meta, content, scan, assistantContent = '') {
2220
2230
  // person. The ON CONFLICT COALESCEs actor/initiated_by, so this fills a fresh
2221
2231
  // row AND backfills one indexed null-first (before its sidecar existed), while a
2222
2232
  // rescan carrying no actor still keeps the stored owner.
2223
- const actorRec = meta.actor ? undefined : readSessionActorRecord(meta.id);
2233
+ const actorRec = meta.actor && meta.accountId ? undefined : readSessionActorRecord(meta.id);
2224
2234
  const toolUsage = detectToolUsage(meta.id);
2225
2235
  const db = getDB();
2226
2236
  const { upsert, delText, insText, readLabel } = stmts(db);
@@ -2239,6 +2249,7 @@ export function upsertSession(meta, content, scan, assistantContent = '') {
2239
2249
  version: meta.version ?? actorRec?.version ?? null,
2240
2250
  account: meta.account ?? null,
2241
2251
  account_key: meta.accountKey ?? null,
2252
+ account_id: meta.accountId ?? actorRec?.accountId ?? null,
2242
2253
  account_org: meta.accountOrg ?? null,
2243
2254
  mode: meta.mode ?? actorRec?.mode ?? null,
2244
2255
  timestamp: meta.timestamp,
@@ -2480,6 +2491,7 @@ export function upsertSessionsBatch(entries) {
2480
2491
  version: meta.version ?? actorIndex.get(meta.id)?.version ?? null,
2481
2492
  account: meta.account ?? null,
2482
2493
  account_key: meta.accountKey ?? null,
2494
+ account_id: meta.accountId ?? actorIndex.get(meta.id)?.accountId ?? null,
2483
2495
  account_org: meta.accountOrg ?? null,
2484
2496
  mode: meta.mode ?? actorIndex.get(meta.id)?.mode ?? null,
2485
2497
  timestamp: meta.timestamp,
@@ -2735,6 +2747,7 @@ function rowToMeta(row) {
2735
2747
  version: row.version ?? undefined,
2736
2748
  account: row.account ?? undefined,
2737
2749
  accountKey: row.account_key ?? undefined,
2750
+ accountId: row.account_id ?? undefined,
2738
2751
  accountOrg: row.account_org ?? undefined,
2739
2752
  mode: isSessionRunMode(row.mode) ? row.mode : undefined,
2740
2753
  topic: row.topic ?? undefined,
@@ -172,6 +172,10 @@ interface CodexSessionScan {
172
172
  * TTL, no external lock files needed.
173
173
  */
174
174
  export declare function discoverSessions(options?: DiscoverOptions): Promise<SessionMeta[]>;
175
+ /** Read only matching Claude/Codex transcripts on a cold ID lookup; indexing stays with the daemon. */
176
+ export declare function findLocalSessionTranscripts(selector: string, agent?: SessionAgentId): Promise<SessionMeta[]>;
177
+ /** Repair local metadata before deciding that a conversation has no context. */
178
+ export declare function hydrateSessionTranscript(session: SessionMeta): Promise<SessionMeta>;
175
179
  /** What one incremental scan actually did. */
176
180
  interface IncrementalScanResult {
177
181
  /** True when this process won the single-flight claim and ran the scan. */
@@ -15,7 +15,6 @@ import { execFile } from 'child_process';
15
15
  import { promisify } from 'util';
16
16
  import Database from '../sqlite.js';
17
17
  import { getAgentsDir, getUserAgentsDir, getHistoryDir, getRunsDir } from '../state.js';
18
- import { shortCodexHome } from '../codex-home.js';
19
18
  import { parseTimeFilter } from './relative-time.js';
20
19
  const execFileAsync = promisify(execFile);
21
20
  import { AGENTS, agentConfigDirName, getCliVersion, resolveOpenCodeAccountId } from '../agents.js';
@@ -31,6 +30,8 @@ import { isBackgroundShellStart, isSkillInvocation, extractSkills, extractSlashC
31
30
  import { parseAntigravity, parseCursor, splitSessionFilePath } from './parse.js';
32
31
  import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket, extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
33
32
  import { costOfUsage, costOfUsageNoCache } from '../pricing/index.js';
33
+ import { isSelfHost } from '../devices/self-host.js';
34
+ import { readSessionActorRecord } from './actor-sidecar.js';
34
35
  import { machineForSessionFile } from './origin-machine.js';
35
36
  export { machineForSessionFile } from './origin-machine.js';
36
37
  import { mapBounded } from '../concurrency.js';
@@ -170,6 +171,72 @@ export async function discoverSessions(options) {
170
171
  skipExistenceCheck: options?.skipExistenceCheck ?? false,
171
172
  });
172
173
  }
174
+ /** Read only matching Claude/Codex transcripts on a cold ID lookup; indexing stays with the daemon. */
175
+ export async function findLocalSessionTranscripts(selector, agent) {
176
+ const matches = new Map();
177
+ const seen = new Set();
178
+ for (const harness of ['claude', 'codex']) {
179
+ if (agent && agent !== harness)
180
+ continue;
181
+ const subdir = harness === 'claude' ? 'projects' : 'sessions';
182
+ const roots = [...getAgentSessionDirs(harness, subdir), ...getRoutineArchiveSessionDirs(harness, subdir)];
183
+ for (const root of roots) {
184
+ for (const file of walkForFilesWithStat(root, '.jsonl', Number.MAX_SAFE_INTEGER)) {
185
+ const name = path.basename(file.path, '.jsonl');
186
+ const id = harness === 'claude' ? name : name.match(/([0-9a-f]{8}-[0-9a-f-]{27})$/i)?.[1];
187
+ if (!id?.toLowerCase().startsWith(selector.toLowerCase()))
188
+ continue;
189
+ const real = fs.realpathSync(file.path);
190
+ if (seen.has(real))
191
+ continue;
192
+ seen.add(real);
193
+ const result = harness === 'claude'
194
+ ? await readClaudeMeta(real, id, { fileMtimeMs: Math.floor(file.mtimeMs), fileSize: file.size }, undefined)
195
+ : await readCodexMeta(real);
196
+ if (!result?.meta.id.toLowerCase().startsWith(selector.toLowerCase()) || !result.meta.messageCount)
197
+ continue;
198
+ const sidecar = readSessionActorRecord(result.meta.id);
199
+ const row = {
200
+ ...result.meta, accountId: sidecar?.accountId, mode: sidecar?.mode,
201
+ actor: sidecar?.actor, initiatedBy: sidecar?.initiatedBy,
202
+ phoenixId: sidecar?.phoenixId, harness: sidecar?.harness ?? result.meta.harness,
203
+ machine: machineForSessionFile(real, harness),
204
+ };
205
+ if (!matches.has(row.id))
206
+ matches.set(row.id, row);
207
+ }
208
+ }
209
+ }
210
+ return [...matches.values()];
211
+ }
212
+ /** Repair local metadata before deciding that a conversation has no context. */
213
+ export async function hydrateSessionTranscript(session) {
214
+ const accountId = session.accountId ?? readSessionActorRecord(session.id)?.accountId;
215
+ if (accountId && !session.accountId)
216
+ session = { ...session, accountId };
217
+ const file = splitSessionFilePath(session.filePath).container;
218
+ try {
219
+ if (file && fs.statSync(file).isFile() && fs.statSync(file).size > 0)
220
+ return session;
221
+ }
222
+ catch { /* A moved account home needs a canonical rescan. */ }
223
+ if (session.machine && !isSelfHost(session.machine))
224
+ return session;
225
+ if (session.agent === 'claude' || session.agent === 'codex') {
226
+ const local = (await findLocalSessionTranscripts(session.id, session.agent)).find(row => row.id === session.id);
227
+ return local ? { ...session, ...local, accountId: session.accountId ?? local.accountId } : session;
228
+ }
229
+ let { claimed } = await scanSessionsIncremental({ agent: session.agent });
230
+ if (!claimed) {
231
+ if (!await waitForScanToSettle())
232
+ throw new Error(`Transcript verification for ${session.shortId} is incomplete: another index scan is still running. Retry when it finishes.`);
233
+ ({ claimed } = await scanSessionsIncremental({ agent: session.agent }));
234
+ if (!claimed)
235
+ throw new Error(`Transcript verification for ${session.shortId} is incomplete: the index is busy. Retry shortly.`);
236
+ }
237
+ const indexed = getSessionById(session.id);
238
+ return indexed ? { ...session, ...indexed, accountId: session.accountId ?? indexed.accountId } : session;
239
+ }
173
240
  /** Separate write half so daemon warming does not pay for listing or external enrichment. */
174
241
  export async function scanSessionsIncremental(options) {
175
242
  // Touch the DB so the schema is ready and connection is cached for this run.
@@ -410,8 +477,16 @@ export function isManagedSessionFile(filePath) {
410
477
  path.join(getHistoryDir(), 'backups'),
411
478
  // Codex's managed home is not always under versions/. On macOS the versioned path
412
479
  // overflows SUN_LEN for codex's control socket, so the shim relocates it to
413
- // `<agentsUserDir>/.codex-homes/<version>/` (lib/codex-home.ts).
480
+ // `<agentsUserDir>/.codex-homes/<key>/` (lib/codex-home.ts) — keyed by version OR
481
+ // by account short key (`a-<accountId prefix>`, lib/codex-home.ts `codexShortKey`).
414
482
  path.join(getUserAgentsDir(), '.codex-homes'),
483
+ // Account slots (PHNX-3940): a named account's HOME-shaped dir under
484
+ // `<historyDir>/accounts/<agent>/<accountId>/` (lib/accounts/slots.ts
485
+ // `slotDir`), sharing the one managed install rather than owning a version
486
+ // home of its own. Without this root, every account-slot transcript read as
487
+ // unmanaged the moment any version was managed, hiding a fully registered,
488
+ // runnable account's entire history from the default listing.
489
+ path.join(getHistoryDir(), 'accounts'),
415
490
  // Routine archives are agents-cli's OWN run output — managed by definition.
416
491
  getRunsDir(),
417
492
  ];
@@ -809,21 +884,47 @@ export function getAgentSessionDirs(agent, subdir) {
809
884
  try {
810
885
  for (const version of fs.readdirSync(versionsBase)) {
811
886
  addDir(path.join(versionsBase, version, 'home', configDirName, subdir));
812
- // Codex's managed home is not always where the version layout says. On macOS
813
- // the versioned path overflows SUN_LEN (104 bytes) for codex's control
814
- // socket, so the shim relocates the home to
815
- // `<agentsUserDir>/.codex-homes/<version>/.codex` (lib/codex-home.ts). Every
816
- // transcript an isolated codex writes lands there, and nothing scanned it —
817
- // `agents sessions --roots` listed only the user's own ~/.codex, so a managed
818
- // copy's own history was invisible. addDir skips what does not exist, so this
819
- // is inert on Linux and for versions that never needed relocating.
820
- if (agent === 'codex') {
821
- addDir(path.join(shortCodexHome(getUserAgentsDir(), version), subdir));
822
- }
823
887
  }
824
888
  }
825
889
  catch { /* dir unreadable */ }
826
890
  }
891
+ // Codex's managed home is not always where the version layout says. On macOS
892
+ // the versioned path overflows SUN_LEN (104 bytes) for codex's control socket,
893
+ // so the shim relocates the home to `<agentsUserDir>/.codex-homes/<key>/.codex`
894
+ // (lib/codex-home.ts) — `<key>` is the version for a version home, or
895
+ // `a-<accountId prefix>` (`codexShortKey`) for an account slot under
896
+ // `<historyDir>/accounts/codex/`. Walking `.codex-homes/` directly — rather
897
+ // than deriving keys from the installed-version list — is what catches BOTH:
898
+ // an account short key is not a vendor version and never appears in
899
+ // `versions/codex/`, so iterating only installed versions silently dropped
900
+ // every transcript a codex account slot wrote. addDir skips what does not
901
+ // exist, so this is inert on Linux and for homes that never needed relocating.
902
+ if (agent === 'codex') {
903
+ const codexHomesBase = path.join(getUserAgentsDir(), '.codex-homes');
904
+ try {
905
+ for (const key of fs.readdirSync(codexHomesBase)) {
906
+ addDir(path.join(codexHomesBase, key, '.codex', subdir));
907
+ }
908
+ }
909
+ catch { /* dir absent or unreadable */ }
910
+ }
911
+ // Account slots (PHNX-3940): a named account gets its own HOME-shaped dir
912
+ // under `<historyDir>/accounts/<agent>/<accountId>/`, sharing the one managed
913
+ // binary install rather than owning a version home of its own
914
+ // (lib/accounts/slots.ts `slotDir`). A slot-launched transcript lives ONLY
915
+ // there — never under `versions/` — so without this root it is fully
916
+ // discoverable by the account machinery (the account is registered and
917
+ // runnable) yet invisible to `agents sessions`, reading as if the history had
918
+ // vanished. `addDir` follows the realpath, so a codex slot whose `.codex` is a
919
+ // symlink onto its `.codex-homes/<key>` short home (SUN_LEN relocation, above)
920
+ // is deduplicated against that same target rather than double-counted.
921
+ const accountsBase = path.join(getHistoryDir(), 'accounts', agent);
922
+ try {
923
+ for (const accountId of fs.readdirSync(accountsBase)) {
924
+ addDir(path.join(accountsBase, accountId, configDirName, subdir));
925
+ }
926
+ }
927
+ catch { /* dir absent or unreadable */ }
827
928
  const backupsBase = path.join(getHistoryDir(), 'backups', agent);
828
929
  if (fs.existsSync(backupsBase)) {
829
930
  try {
@@ -1180,7 +1281,7 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, label) {
1180
1281
  // Which account produced this transcript. Resolved from the path plus the version
1181
1282
  // recorded inside the file, so rows under the mutable ~/.claude symlink are
1182
1283
  // attributed to the version that actually wrote them. See claude-accounts.ts.
1183
- const acct = resolveClaudeAccount(claudeAccountIndex(), filePath, scan.version);
1284
+ const acct = resolveClaudeAccount(claudeAccountIndex(), filePath, scan.version, readSessionActorRecord(sessionId)?.accountId);
1184
1285
  let meta;
1185
1286
  if (scan.timestamp) {
1186
1287
  const cwd = normalizeCwd(scan.cwd || '');
@@ -4315,7 +4416,7 @@ export function extractVersionFromManagedPath(agent, sourcePath) {
4315
4416
  if (start === -1)
4316
4417
  continue;
4317
4418
  const version = normalized.slice(start + marker.length).split('/')[0];
4318
- if (version)
4419
+ if (version && !(marker.endsWith('/.codex-homes/') && version.startsWith('a-')))
4319
4420
  return version;
4320
4421
  }
4321
4422
  }