@phnx-labs/agents-cli 1.22.104 → 1.22.106
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 +18 -0
- package/README.md +19 -1
- package/dist/browser.js +0 -0
- package/dist/commands/exec.js +135 -233
- package/dist/commands/resume.d.ts +6 -21
- package/dist/commands/resume.js +18 -55
- package/dist/commands/run-device-picker.d.ts +58 -0
- package/dist/commands/run-device-picker.js +222 -0
- package/dist/commands/sessions-resume.d.ts +4 -0
- package/dist/commands/sessions-resume.js +132 -49
- package/dist/commands/sessions.js +32 -5
- package/dist/commands/setup-computer.js +2 -2
- package/dist/index.js +0 -0
- package/dist/lib/accounting/account-launch.d.ts +54 -0
- package/dist/lib/accounting/account-launch.js +117 -0
- package/dist/lib/accounting/account-pool-collect.js +2 -1
- package/dist/lib/accounting/account-pool.d.ts +2 -0
- package/dist/lib/accounting/account-pool.js +1 -0
- package/dist/lib/accounting/rotate.d.ts +32 -6
- package/dist/lib/accounting/rotate.js +70 -42
- package/dist/lib/accounting/usage.d.ts +71 -0
- package/dist/lib/accounting/usage.js +160 -11
- package/dist/lib/exec-account-home.d.ts +3 -1
- package/dist/lib/exec-account-home.js +2 -2
- package/dist/lib/exec.d.ts +27 -1
- package/dist/lib/exec.js +150 -27
- package/dist/lib/models.d.ts +1 -1
- package/dist/lib/models.js +4 -4
- package/dist/lib/session/actor-sidecar.d.ts +3 -11
- package/dist/lib/session/actor-sidecar.js +3 -0
- package/dist/lib/session/claude-accounts.d.ts +12 -73
- package/dist/lib/session/claude-accounts.js +32 -70
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +18 -5
- package/dist/lib/session/discover.d.ts +4 -0
- package/dist/lib/session/discover.js +116 -15
- package/dist/lib/session/recovery.d.ts +30 -34
- package/dist/lib/session/recovery.js +212 -76
- package/dist/lib/session/types.d.ts +2 -0
- package/dist/lib/teams/placement-probe.js +1 -1
- package/dist/session-tracker/dist/adapters/claude.d.ts +10 -0
- package/dist/session-tracker/dist/adapters/claude.js +45 -0
- package/dist/session-tracker/dist/hook.sh +191 -0
- package/dist/session-tracker/dist/index.d.ts +19 -0
- package/dist/session-tracker/dist/index.js +67 -0
- package/dist/session-tracker/dist/install-hook.d.ts +19 -0
- package/dist/session-tracker/dist/install-hook.js +245 -0
- package/dist/session-tracker/dist/prune-state.d.ts +2 -0
- package/dist/session-tracker/dist/prune-state.js +7 -0
- package/dist/session-tracker/dist/reader.d.ts +7 -0
- package/dist/session-tracker/dist/reader.js +151 -0
- package/dist/session-tracker/dist/state-file.d.ts +10 -0
- package/dist/session-tracker/dist/state-file.js +119 -0
- package/dist/session-tracker/dist/types.d.ts +32 -0
- package/dist/session-tracker/dist/types.js +1 -0
- package/dist/session-tracker/dist/writer.d.ts +12 -0
- package/dist/session-tracker/dist/writer.js +27 -0
- package/package.json +1 -1
|
@@ -1,62 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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))
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -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 =
|
|
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
|
package/dist/lib/session/db.js
CHANGED
|
@@ -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 =
|
|
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/<
|
|
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
|
}
|
|
@@ -1,38 +1,40 @@
|
|
|
1
|
+
import type { UnifiedAccount } from '../account-registry.js';
|
|
1
2
|
import { type RotateCandidate } from '../accounting/rotate.js';
|
|
2
3
|
import type { AgentId } from '../types.js';
|
|
3
4
|
import type { SessionAgentId, SessionMeta } from './types.js';
|
|
4
5
|
/** One capability boundary for every surface that advertises faithful Resume. */
|
|
5
6
|
export declare function sessionAgentSupportsResume(agent: SessionAgentId): boolean;
|
|
6
|
-
/**
|
|
7
|
-
* The account a recovery should authenticate as. Present when resume rotates
|
|
8
|
-
* AWAY from the session's original login (an account limit) to a healthy
|
|
9
|
-
* sibling of the SAME harness. A `providerAccount` is a durable setup-token /
|
|
10
|
-
* API-key account (RUSH-3182) injected via the `--account` spawn path
|
|
11
|
-
* (`resolveSpawnAccount` → `accountEnv`); it is the only kind that can
|
|
12
|
-
* authenticate a NATIVE resume in the origin version home, because a native
|
|
13
|
-
* login lives in its own isolated home and cannot be forwarded. It is also
|
|
14
|
-
* required on `/continue` when the balanced pick is a provider: exec only
|
|
15
|
-
* injects from this field, so omitting it would launch the version home's
|
|
16
|
-
* native login — the exhausted origin in the PHNX-3674 fixture. Absent means
|
|
17
|
-
* "use the launched version home's own native login" (the healthy-origin happy
|
|
18
|
-
* path, or `/continue` on a healthy native sibling).
|
|
19
|
-
*/
|
|
7
|
+
/** Injectable credentials can authenticate the existing native context. */
|
|
20
8
|
export interface RecoveryAccount {
|
|
21
9
|
providerAccount: string;
|
|
22
10
|
label: string;
|
|
23
11
|
email: string | null;
|
|
24
12
|
}
|
|
13
|
+
export interface SessionRecoverySelection {
|
|
14
|
+
/** Installed binary only; account homes retain their own context label. */
|
|
15
|
+
executableVersion?: string;
|
|
16
|
+
/** Explicit account selector resolved against the local candidate pool. */
|
|
17
|
+
account?: string;
|
|
18
|
+
/** Effective model for readiness checks. */
|
|
19
|
+
model?: string;
|
|
20
|
+
}
|
|
25
21
|
export type SessionRecoveryTarget = {
|
|
26
22
|
mode: 'native';
|
|
27
23
|
agent: AgentId;
|
|
28
24
|
version: string;
|
|
29
25
|
cwd?: string;
|
|
26
|
+
/** The actual native context root used (a version home, or an account slot dir). */
|
|
27
|
+
execHome?: string;
|
|
28
|
+
configVersion?: string;
|
|
29
|
+
/** The exact account/version candidate recovery resolved to — never re-derive from `version` alone (PHNX-3940: several accounts can share one managed binary). */
|
|
30
|
+
candidate: RotateCandidate;
|
|
30
31
|
account?: RecoveryAccount;
|
|
31
32
|
reason: string;
|
|
32
33
|
} | {
|
|
33
34
|
mode: 'continue';
|
|
34
35
|
agent: AgentId;
|
|
35
36
|
version: string;
|
|
37
|
+
candidate: RotateCandidate;
|
|
36
38
|
account?: RecoveryAccount;
|
|
37
39
|
reason: string;
|
|
38
40
|
};
|
|
@@ -52,28 +54,22 @@ export declare function sessionOriginDevice(session: Pick<SessionMeta, 'machine'
|
|
|
52
54
|
export declare function sessionRecoveryPeer(session: Pick<SessionMeta, 'machine'>, selfCheck?: (host: string) => boolean): string | undefined;
|
|
53
55
|
/** Whether an explicit placement names the session's origin device. */
|
|
54
56
|
export declare function sessionRecoveryDestinationMatches(session: Pick<SessionMeta, 'machine'>, requestedHost: string, self?: string): boolean;
|
|
57
|
+
/** History filtering uses login identity, never an organization quota key. */
|
|
58
|
+
export declare function sessionMatchesAccount(session: Pick<SessionMeta, 'agent' | 'filePath' | 'accountId'>, account: Pick<UnifiedAccount, 'id' | 'kind'> & {
|
|
59
|
+
agent?: AgentId;
|
|
60
|
+
}, home?: string): boolean;
|
|
55
61
|
/**
|
|
56
|
-
* Prove that the indexed transcript is reachable from the exact active
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* isolated home.
|
|
62
|
+
* Prove that the indexed transcript is reachable from the exact active
|
|
63
|
+
* native context root that would receive native resume (a version home, or
|
|
64
|
+
* an account slot dir). Retained trash/backup transcripts are intentionally
|
|
65
|
+
* rejected here: they remain readable by `/continue`, but a new installation
|
|
66
|
+
* or account slot must not native-resume an empty isolated home.
|
|
61
67
|
*/
|
|
62
68
|
export declare function inspectNativeResumeSession(session: SessionMeta, versionHome: string): NativeResumeInspection;
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
* and only while that home owns the indexed transcript AND some injectable
|
|
68
|
-
* credential for this harness is healthy: the origin login itself, or a
|
|
69
|
-
* provider account rotated in when the origin is usage-limited (PHNX-3626).
|
|
70
|
-
* Every other successful path stays on the same harness and uses `/continue`,
|
|
71
|
-
* whose indexed transcript reader can reach retained version trash. A
|
|
72
|
-
* `/continue` pick of a provider account carries RecoveryAccount so exec
|
|
73
|
-
* injects it instead of launching the version home's native login
|
|
74
|
-
* (PHNX-3674). No healthy same-harness account is a loud failure.
|
|
75
|
-
*/
|
|
76
|
-
export declare function resolveSessionRecoveryFromCandidates(session: SessionMeta, candidates: RotateCandidate[], supportsNative?: (agent: AgentId, version?: string) => boolean, nativeInspection?: NativeResumeInspection): SessionRecoveryTarget;
|
|
69
|
+
/** Keep the native conversation where possible; replay remains a separate choice. */
|
|
70
|
+
export declare function resolveSessionRecoveryFromCandidates(session: SessionMeta, candidates: RotateCandidate[], supportsNative?: (agent: AgentId, version?: string) => boolean, nativeInspection?: NativeResumeInspection, options?: SessionRecoverySelection): SessionRecoveryTarget;
|
|
71
|
+
/** A mirror digest or live registry entry is not conversation content. */
|
|
72
|
+
export declare function assertRecoverableTranscript(session: SessionMeta): void;
|
|
77
73
|
/**
|
|
78
74
|
* Resolve recovery for a durable session, reading the live account pool.
|
|
79
75
|
*
|
|
@@ -83,7 +79,7 @@ export declare function resolveSessionRecoveryFromCandidates(session: SessionMet
|
|
|
83
79
|
* healthy provider account and stay NATIVE (PHNX-3626). `collect` is injectable
|
|
84
80
|
* for tests and for callers that must stay native-only.
|
|
85
81
|
*/
|
|
86
|
-
export declare function resolveSessionRecovery(session: SessionMeta, collect?: (agent: AgentId) => Promise<RotateCandidate[]
|
|
82
|
+
export declare function resolveSessionRecovery(session: SessionMeta, collect?: (agent: AgentId) => Promise<RotateCandidate[]>, options?: SessionRecoverySelection): Promise<SessionRecoveryTarget>;
|
|
87
83
|
/** Stable self-command used by focus, resume, and attach. The owning device runs
|
|
88
84
|
* the recovery resolver above; callers must not native-resume another version's
|
|
89
85
|
* isolated home themselves. */
|