@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.
- package/CHANGELOG.md +32 -0
- package/README.md +28 -6
- package/dist/browser.js +0 -0
- package/dist/commands/exec.d.ts +78 -8
- package/dist/commands/exec.js +407 -284
- package/dist/commands/resume.d.ts +6 -21
- package/dist/commands/resume.js +18 -55
- package/dist/commands/run-account-picker.d.ts +11 -0
- package/dist/commands/run-account-picker.js +11 -1
- 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/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/hosts/dispatch.d.ts +1 -1
- package/dist/lib/hosts/dispatch.js +1 -1
- 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,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. */
|
|
@@ -4,9 +4,13 @@ import { AGENTS, agentConfigDirName } from '../agents.js';
|
|
|
4
4
|
import { isSelfHost } from '../devices/self-host.js';
|
|
5
5
|
import { nativeResume } from '../exec.js';
|
|
6
6
|
import { machineId, normalizeHost } from '../machine-id.js';
|
|
7
|
-
import {
|
|
7
|
+
import { readSlots, slotDir } from '../accounts/slots.js';
|
|
8
|
+
import { readMeta } from '../state.js';
|
|
9
|
+
import { formatNoHealthyAccountError, matchAccountCandidate, pickBalancedCandidate, readinessFromCandidate, } from '../accounting/rotate.js';
|
|
8
10
|
import { collectRunCandidatesForRun } from '../accounting/account-pool-collect.js';
|
|
9
|
-
import { getVersionHomePath } from '../installations/store.js';
|
|
11
|
+
import { getVersionHomePath, resolveManagedInstallation } from '../installations/store.js';
|
|
12
|
+
import { readSessionContent } from './db.js';
|
|
13
|
+
import { parseOpenCode, splitSessionFilePath } from './parse.js';
|
|
10
14
|
const RESUMABLE_SESSION_AGENTS = new Set(['claude', 'codex', 'muse', 'opencode']);
|
|
11
15
|
/** One capability boundary for every surface that advertises faithful Resume. */
|
|
12
16
|
export function sessionAgentSupportsResume(agent) {
|
|
@@ -39,16 +43,13 @@ function runnableSessionAgent(session) {
|
|
|
39
43
|
}
|
|
40
44
|
return session.agent;
|
|
41
45
|
}
|
|
42
|
-
function sourceReason(session,
|
|
43
|
-
if (!session.version)
|
|
44
|
-
return 'the origin version was not recorded';
|
|
45
|
-
const source = candidates.find((candidate) => candidate.version === session.version);
|
|
46
|
+
function sourceReason(session, source, model) {
|
|
46
47
|
if (!source)
|
|
47
|
-
return
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
return session.accountId
|
|
49
|
+
? 'the recorded account is not available on this device'
|
|
50
|
+
: 'the original account attribution is unknown';
|
|
51
|
+
const readiness = readinessFromCandidate(source, undefined, model);
|
|
52
|
+
return readiness.ready ? 'the account has no usable native resume context' : `the original account is ${readiness.reason}`;
|
|
52
53
|
}
|
|
53
54
|
function recoveryAccountFromCandidate(candidate) {
|
|
54
55
|
const providerAccount = candidate.providerAccount;
|
|
@@ -74,6 +75,83 @@ function existingDirectory(dir) {
|
|
|
74
75
|
return undefined;
|
|
75
76
|
}
|
|
76
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Realpath of `filePath` when it is genuinely reachable from `homeRoot` (or
|
|
80
|
+
* that root's agent config subdir), else `null`. The one place transcript
|
|
81
|
+
* ownership is decided — reused by native-resume inspection, account
|
|
82
|
+
* attribution, and disambiguation between several accounts sharing one
|
|
83
|
+
* managed binary. Retained trash/backup transcripts are intentionally
|
|
84
|
+
* rejected: they remain readable by `/continue`, but a home must not claim to
|
|
85
|
+
* natively own a transcript it does not.
|
|
86
|
+
*/
|
|
87
|
+
function resolveOwnedTranscriptRealpath(filePath, homeRoot, agent) {
|
|
88
|
+
let realFile;
|
|
89
|
+
try {
|
|
90
|
+
realFile = fs.realpathSync(splitSessionFilePath(filePath).container);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const roots = [path.join(homeRoot, agentConfigDirName(agent))];
|
|
96
|
+
if (agent === 'muse' || agent === 'opencode')
|
|
97
|
+
roots.push(path.join(homeRoot, '.local', 'share', agent));
|
|
98
|
+
const owned = roots.some((root) => {
|
|
99
|
+
try {
|
|
100
|
+
return isPathInside(realFile, fs.realpathSync(root));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
return owned ? realFile : null;
|
|
107
|
+
}
|
|
108
|
+
function transcriptOwnedByHome(filePath, homeRoot, agent) {
|
|
109
|
+
return resolveOwnedTranscriptRealpath(filePath, homeRoot, agent) !== null;
|
|
110
|
+
}
|
|
111
|
+
/** History filtering uses login identity, never an organization quota key. */
|
|
112
|
+
export function sessionMatchesAccount(session, account, home) {
|
|
113
|
+
if (account.kind === 'native' && session.agent !== account.agent)
|
|
114
|
+
return false;
|
|
115
|
+
if (session.accountId)
|
|
116
|
+
return session.accountId === account.id;
|
|
117
|
+
if (account.kind !== 'native')
|
|
118
|
+
return false;
|
|
119
|
+
const slot = readSlots(readMeta())[account.id];
|
|
120
|
+
const context = home ?? slot?.slotDir ?? slotDir(session.agent, account.id);
|
|
121
|
+
return transcriptOwnedByHome(session.filePath, context, session.agent);
|
|
122
|
+
}
|
|
123
|
+
function candidateAccountId(candidate) {
|
|
124
|
+
return candidate.providerAccountId ?? candidate.nativeAccountId ?? (candidate.fromSlot && candidate.slotDir ? path.basename(candidate.slotDir) : undefined);
|
|
125
|
+
}
|
|
126
|
+
/** The actual native context root for a candidate: its account slot dir when it has one, else the managed version home. */
|
|
127
|
+
function candidateHome(candidate) {
|
|
128
|
+
return candidate.slotDir || getVersionHomePath(candidate.agent, candidate.version);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Whether `candidate` is the proven native owner of `session`'s transcript.
|
|
132
|
+
* A provider (injected credential) candidate never qualifies: it has no
|
|
133
|
+
* isolated context of its own, so a transcript sitting in the version home it
|
|
134
|
+
* happens to ride in is not evidence it produced that transcript (the exact
|
|
135
|
+
* "current credentials in an old home as historical proof" mistake this must
|
|
136
|
+
* not repeat).
|
|
137
|
+
*/
|
|
138
|
+
function candidateOwnsTranscript(candidate, session) {
|
|
139
|
+
if (candidate.providerAccount && !session.accountId)
|
|
140
|
+
return false;
|
|
141
|
+
const home = candidateHome(candidate);
|
|
142
|
+
const acctId = candidateAccountId(candidate);
|
|
143
|
+
if (session.accountId)
|
|
144
|
+
return session.accountId === acctId;
|
|
145
|
+
return transcriptOwnedByHome(session.filePath, home, candidate.agent);
|
|
146
|
+
}
|
|
147
|
+
/** Stored login identity wins; otherwise require actual native context ownership. */
|
|
148
|
+
function pickOriginCandidate(session, candidates) {
|
|
149
|
+
if (session.accountId)
|
|
150
|
+
return candidates.find(candidate => candidateAccountId(candidate) === session.accountId);
|
|
151
|
+
const native = candidates.filter(candidate => !candidate.providerAccount);
|
|
152
|
+
const owners = native.filter(candidate => candidateOwnsTranscript(candidate, session));
|
|
153
|
+
return owners.length === 1 ? owners[0] : undefined;
|
|
154
|
+
}
|
|
77
155
|
/** Read the launch cwd Claude used to choose its projects/<cwd-key> directory.
|
|
78
156
|
* Claude can record attachment envelopes before the first user turn, and those
|
|
79
157
|
* envelopes retain the actual launch cwd even after the session changes dirs. */
|
|
@@ -111,30 +189,24 @@ function readClaudeLaunchCwd(filePath) {
|
|
|
111
189
|
return undefined;
|
|
112
190
|
}
|
|
113
191
|
/**
|
|
114
|
-
* Prove that the indexed transcript is reachable from the exact active
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* isolated home.
|
|
192
|
+
* Prove that the indexed transcript is reachable from the exact active
|
|
193
|
+
* native context root that would receive native resume (a version home, or
|
|
194
|
+
* an account slot dir). Retained trash/backup transcripts are intentionally
|
|
195
|
+
* rejected here: they remain readable by `/continue`, but a new installation
|
|
196
|
+
* or account slot must not native-resume an empty isolated home.
|
|
119
197
|
*/
|
|
120
198
|
export function inspectNativeResumeSession(session, versionHome) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
realFile = fs.realpathSync(session.filePath);
|
|
124
|
-
}
|
|
125
|
-
catch {
|
|
126
|
-
return { available: false, reason: 'the indexed transcript is no longer present in the origin home' };
|
|
199
|
+
if (session.agent === 'opencode' && (!fs.existsSync(splitSessionFilePath(session.filePath).container) || parseOpenCode(session.filePath).length === 0)) {
|
|
200
|
+
return { available: false, reason: 'the native database has no readable conversation for this session' };
|
|
127
201
|
}
|
|
128
|
-
const
|
|
129
|
-
|
|
202
|
+
const realFile = resolveOwnedTranscriptRealpath(session.filePath, versionHome, session.agent);
|
|
203
|
+
if (!realFile) {
|
|
130
204
|
try {
|
|
131
|
-
|
|
205
|
+
fs.realpathSync(splitSessionFilePath(session.filePath).container);
|
|
132
206
|
}
|
|
133
207
|
catch {
|
|
134
|
-
return false;
|
|
208
|
+
return { available: false, reason: 'the indexed transcript is no longer present in the origin home' };
|
|
135
209
|
}
|
|
136
|
-
});
|
|
137
|
-
if (!owned) {
|
|
138
210
|
return {
|
|
139
211
|
available: false,
|
|
140
212
|
reason: `the indexed transcript is retained outside the active ${session.agent}@${session.version ?? 'unknown'} home`,
|
|
@@ -153,43 +225,73 @@ export function inspectNativeResumeSession(session, versionHome) {
|
|
|
153
225
|
const cwd = existingDirectory(session.cwd);
|
|
154
226
|
return { available: true, cwd };
|
|
155
227
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
228
|
+
function resolveExplicitAccountRecovery(session, candidates, agent, device, supportsNative, nativeInspection, options) {
|
|
229
|
+
const requested = options.account.trim();
|
|
230
|
+
const matched = matchAccountCandidate(candidates, requested);
|
|
231
|
+
if (!matched) {
|
|
232
|
+
throw new SessionRecoveryError(`Cannot recover session ${session.shortId} on ${device} as '${requested}': no signed-in ${agent} account matches that name/email/key.`);
|
|
233
|
+
}
|
|
234
|
+
const readiness = readinessFromCandidate(matched, undefined, options.model ?? session.model);
|
|
235
|
+
if (!readiness.ready) {
|
|
236
|
+
throw new SessionRecoveryError(`Cannot recover session ${session.shortId} on ${device} as '${requested}': ${matched.accountLabel} is ${readiness.reason}.`);
|
|
237
|
+
}
|
|
238
|
+
const account = recoveryAccountFromCandidate(matched);
|
|
239
|
+
const modelSuffix = options.model ? ` on model ${options.model}` : '';
|
|
240
|
+
const isOrigin = candidateOwnsTranscript(matched, session);
|
|
241
|
+
if (isOrigin && supportsNative(agent, options.executableVersion ?? matched.version)) {
|
|
242
|
+
const home = candidateHome(matched);
|
|
243
|
+
const inspection = nativeInspection ?? inspectNativeResumeSession(session, home);
|
|
244
|
+
if (inspection.available) {
|
|
245
|
+
return {
|
|
246
|
+
mode: 'native',
|
|
247
|
+
agent,
|
|
248
|
+
version: options.executableVersion ?? matched.version,
|
|
249
|
+
cwd: inspection.cwd,
|
|
250
|
+
execHome: home,
|
|
251
|
+
configVersion: matched.fromSlot ? undefined : matched.version,
|
|
252
|
+
candidate: matched,
|
|
253
|
+
...(account ? { account } : {}),
|
|
254
|
+
reason: `explicit account '${requested}' is the session origin; resuming natively${modelSuffix}`,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
mode: 'continue',
|
|
260
|
+
agent,
|
|
261
|
+
version: options.executableVersion ?? matched.version,
|
|
262
|
+
candidate: matched,
|
|
263
|
+
...(account ? { account } : {}),
|
|
264
|
+
reason: isOrigin
|
|
265
|
+
? `explicit account '${requested}' is the session origin but has no usable native context; continuing${modelSuffix}`
|
|
266
|
+
: `explicit account '${requested}' differs from the session's recorded origin; continuing on ${matched.accountLabel}${modelSuffix} requires interactive confirmation before replay`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/** Keep the native conversation where possible; replay remains a separate choice. */
|
|
270
|
+
export function resolveSessionRecoveryFromCandidates(session, candidates, supportsNative = nativeResume, nativeInspection, options = {}) {
|
|
170
271
|
const agent = runnableSessionAgent(session);
|
|
171
272
|
const device = sessionOriginDevice(session);
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
273
|
+
if (options.account) {
|
|
274
|
+
return resolveExplicitAccountRecovery(session, candidates, agent, device, supportsNative, nativeInspection, options);
|
|
275
|
+
}
|
|
276
|
+
const source = pickOriginCandidate(session, candidates);
|
|
277
|
+
const sourceReady = source ? readinessFromCandidate(source, undefined, options.model ?? session.model).ready : false;
|
|
176
278
|
// Native-first with account rotation (PHNX-3626). When the origin login is
|
|
177
279
|
// usage/rate/session-LIMITED (not signed-out or revoked — those need a login,
|
|
178
|
-
// not a rotation, so they keep going to /continue per SES-39) but its
|
|
179
|
-
//
|
|
280
|
+
// not a rotation, so they keep going to /continue per SES-39) but its native
|
|
281
|
+
// context is installed, native-resume-capable, and still owns the indexed
|
|
180
282
|
// transcript, keep resume NATIVE by rotating to a healthy INJECTABLE (provider)
|
|
181
|
-
// account in that SAME
|
|
182
|
-
// version. Only a provider token/key qualifies: a native login
|
|
183
|
-
// isolated
|
|
184
|
-
// resume that must read the origin
|
|
185
|
-
const originReadiness = source ? readinessFromCandidate(source) : null;
|
|
283
|
+
// account in that SAME context — rather than dropping to /continue on a
|
|
284
|
+
// different version. Only a provider token/key qualifies: a native login
|
|
285
|
+
// lives in its own isolated context and cannot be forwarded, so it could
|
|
286
|
+
// never authenticate a resume that must read the origin's transcript (see §11).
|
|
287
|
+
const originReadiness = source ? readinessFromCandidate(source, undefined, options.model ?? session.model) : null;
|
|
186
288
|
const originLimited = !!originReadiness && !originReadiness.ready
|
|
187
|
-
&& (originReadiness.reason === 'rate_limited' || originReadiness.reason === 'out_of_credits');
|
|
188
|
-
if (originLimited && source &&
|
|
189
|
-
const
|
|
190
|
-
|
|
289
|
+
&& (originReadiness.reason === 'rate_limited' || originReadiness.reason === 'out_of_credits' || originReadiness.reason === 'model_limited');
|
|
290
|
+
if (originLimited && source && supportsNative(agent, options.executableVersion ?? source.version)) {
|
|
291
|
+
const originHome = candidateHome(source);
|
|
292
|
+
const inspection = nativeInspection ?? inspectNativeResumeSession(session, originHome);
|
|
191
293
|
if (inspection.available) {
|
|
192
|
-
const rotated = pickBalancedCandidate(candidates.filter((c) => c.providerAccount && c.accountKey !== source.accountKey));
|
|
294
|
+
const rotated = pickBalancedCandidate(candidates.filter((c) => c.providerAccount && c.accountKey !== source.accountKey), undefined, options.model ?? session.model);
|
|
193
295
|
const account = rotated ? recoveryAccountFromCandidate(rotated.picked) : undefined;
|
|
194
296
|
if (account) {
|
|
195
297
|
// `originLimited` guarantees the origin is unhealthy with a limit reason.
|
|
@@ -197,48 +299,59 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
|
|
|
197
299
|
return {
|
|
198
300
|
mode: 'native',
|
|
199
301
|
agent,
|
|
200
|
-
version:
|
|
302
|
+
version: options.executableVersion ?? source.version,
|
|
201
303
|
cwd: inspection.cwd,
|
|
304
|
+
execHome: originHome,
|
|
305
|
+
configVersion: source.fromSlot ? undefined : source.version,
|
|
306
|
+
candidate: rotated.picked,
|
|
202
307
|
account,
|
|
203
|
-
reason: `
|
|
308
|
+
reason: `the original account is ${why}; using ${account.label} in the same native context`,
|
|
204
309
|
};
|
|
205
310
|
}
|
|
206
311
|
}
|
|
207
312
|
}
|
|
208
|
-
// An exact healthy origin is deterministic: preserve its isolated
|
|
209
|
-
// native resume is unavailable for that harness, /continue still
|
|
210
|
-
//
|
|
313
|
+
// An exact healthy origin is deterministic: preserve its isolated context.
|
|
314
|
+
// If native resume is unavailable for that harness, /continue still
|
|
315
|
+
// launches there. Only an unusable/missing/ambiguous origin enters balanced
|
|
211
316
|
// account selection.
|
|
212
317
|
const selection = sourceReady
|
|
213
318
|
? { picked: source }
|
|
214
|
-
: pickBalancedCandidate(candidates);
|
|
319
|
+
: pickBalancedCandidate(candidates, undefined, options.model ?? session.model);
|
|
215
320
|
if (!selection) {
|
|
216
321
|
const detail = formatNoHealthyAccountError(agent, 'balanced', candidates);
|
|
217
322
|
throw new SessionRecoveryError(`Cannot recover session ${session.shortId} on ${device}; origin ${agent}@${session.version ?? 'unknown'}. ${detail}`);
|
|
218
323
|
}
|
|
219
|
-
const version = selection.picked.version;
|
|
324
|
+
const version = options.executableVersion ?? selection.picked.version;
|
|
220
325
|
const account = recoveryAccountFromCandidate(selection.picked);
|
|
221
|
-
const continueWith = account ? `healthy ${account.label}` : `
|
|
326
|
+
const continueWith = account ? `healthy ${account.label}` : `the selected ${agent} account`;
|
|
222
327
|
// Native resume without an injected RecoveryAccount is valid only for the
|
|
223
|
-
// exact healthy origin login
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
328
|
+
// exact healthy origin login — when sourceReady, `selection.picked` IS
|
|
329
|
+
// `source` by construction above, so no separate version comparison is
|
|
330
|
+
// needed (and none would be safe: an accountId-matched origin can carry a
|
|
331
|
+
// version different from the session's recorded one after a vendor
|
|
332
|
+
// relabel). A balanced same-version provider selected for a signed-out/
|
|
333
|
+
// revoked origin must stay on /continue; otherwise we would open the origin
|
|
334
|
+
// context with no usable credential and fail (or fork state).
|
|
335
|
+
if (sourceReady && supportsNative(agent, version)) {
|
|
336
|
+
const home = candidateHome(selection.picked);
|
|
337
|
+
const inspection = nativeInspection ?? inspectNativeResumeSession(session, home);
|
|
229
338
|
if (inspection.available) {
|
|
230
339
|
return {
|
|
231
340
|
mode: 'native',
|
|
232
341
|
agent,
|
|
233
342
|
version,
|
|
234
343
|
cwd: inspection.cwd,
|
|
235
|
-
|
|
344
|
+
execHome: home,
|
|
345
|
+
configVersion: selection.picked.fromSlot ? undefined : selection.picked.version,
|
|
346
|
+
candidate: selection.picked,
|
|
347
|
+
reason: `the selected account owns the native transcript and has no known launch restriction`,
|
|
236
348
|
};
|
|
237
349
|
}
|
|
238
350
|
return {
|
|
239
351
|
mode: 'continue',
|
|
240
352
|
agent,
|
|
241
353
|
version,
|
|
354
|
+
candidate: selection.picked,
|
|
242
355
|
...(account ? { account } : {}),
|
|
243
356
|
reason: `${inspection.reason}; continuing with ${continueWith}`,
|
|
244
357
|
};
|
|
@@ -247,10 +360,25 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
|
|
|
247
360
|
mode: 'continue',
|
|
248
361
|
agent,
|
|
249
362
|
version,
|
|
363
|
+
candidate: selection.picked,
|
|
250
364
|
...(account ? { account } : {}),
|
|
251
|
-
reason: `${sourceReason(session,
|
|
365
|
+
reason: `${sourceReason(session, source, options.model ?? session.model)}; continuing with ${continueWith}`,
|
|
252
366
|
};
|
|
253
367
|
}
|
|
368
|
+
/** A mirror digest or live registry entry is not conversation content. */
|
|
369
|
+
export function assertRecoverableTranscript(session) {
|
|
370
|
+
const file = splitSessionFilePath(session.filePath).container;
|
|
371
|
+
try {
|
|
372
|
+
if (file && fs.statSync(file).isFile() && fs.statSync(file).size > 0
|
|
373
|
+
&& (session.agent !== 'opencode' || parseOpenCode(session.filePath).length > 0))
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
catch { /* The canonical scan already tried to repair this path. */ }
|
|
377
|
+
if (!session.mirrorSyncedAt && !session.mirrorSource && readSessionContent(session.id)?.trim())
|
|
378
|
+
return;
|
|
379
|
+
throw new SessionRecoveryError(`Session ${session.shortId} has no readable transcript after checking its account homes and index. ` +
|
|
380
|
+
`No agent was started. Start a new conversation with: agents run ${session.agent}`);
|
|
381
|
+
}
|
|
254
382
|
/**
|
|
255
383
|
* Resolve recovery for a durable session, reading the live account pool.
|
|
256
384
|
*
|
|
@@ -260,9 +388,17 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
|
|
|
260
388
|
* healthy provider account and stay NATIVE (PHNX-3626). `collect` is injectable
|
|
261
389
|
* for tests and for callers that must stay native-only.
|
|
262
390
|
*/
|
|
263
|
-
export async function resolveSessionRecovery(session, collect = collectRunCandidatesForRun) {
|
|
391
|
+
export async function resolveSessionRecovery(session, collect = collectRunCandidatesForRun, options = {}) {
|
|
264
392
|
const agent = runnableSessionAgent(session);
|
|
265
|
-
|
|
393
|
+
const { hydrateSessionTranscript } = await import('./discover.js');
|
|
394
|
+
session = await hydrateSessionTranscript(session);
|
|
395
|
+
assertRecoverableTranscript(session);
|
|
396
|
+
const installation = resolveManagedInstallation(agent);
|
|
397
|
+
if (!installation)
|
|
398
|
+
throw new SessionRecoveryError(`No managed ${agent} installation is available. Install it with: agents add ${agent}`);
|
|
399
|
+
return resolveSessionRecoveryFromCandidates(session, await collect(agent), undefined, undefined, {
|
|
400
|
+
...options, executableVersion: installation.label,
|
|
401
|
+
});
|
|
266
402
|
}
|
|
267
403
|
/** Stable self-command used by focus, resume, and attach. The owning device runs
|
|
268
404
|
* the recovery resolver above; callers must not native-resume another version's
|
|
@@ -395,6 +395,8 @@ export interface SessionMeta {
|
|
|
395
395
|
* See lib/session/claude-accounts.ts for how a transcript is attributed.
|
|
396
396
|
*/
|
|
397
397
|
accountKey?: string;
|
|
398
|
+
/** Stable credential account recorded at launch; independent of organization quota and binary release. */
|
|
399
|
+
accountId?: string;
|
|
398
400
|
/** Organization display name of the producing account, when known. */
|
|
399
401
|
accountOrg?: string;
|
|
400
402
|
/** Effective normalized launch mode captured by the SessionStart hook. */
|
|
@@ -118,7 +118,7 @@ export async function probePoolSignals(pool, agent, opts = {}) {
|
|
|
118
118
|
if (!candidates) {
|
|
119
119
|
return [d.name, { installed: true, signedIn: undefined, pickerEligible: undefined }];
|
|
120
120
|
}
|
|
121
|
-
const readiness = candidates.map(readinessFromCandidate);
|
|
121
|
+
const readiness = candidates.map((candidate) => readinessFromCandidate(candidate));
|
|
122
122
|
return [d.name, {
|
|
123
123
|
installed: true,
|
|
124
124
|
signedIn: readiness.some((candidate) => candidate.ready),
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const CLAUDE_PROJECTS_ROOT: string;
|
|
2
|
+
export declare function claudeWorkspaceFolder(cwd: string): string;
|
|
3
|
+
export declare function claudeSessionDir(cwd: string): string;
|
|
4
|
+
export declare function snapshotSessions(cwd: string): Promise<Set<string>>;
|
|
5
|
+
export interface NewSession {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
latencyMs: number;
|
|
8
|
+
file: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function awaitNewSession(cwd: string, before: Set<string>, timeoutMs: number, pollMs?: number): Promise<NewSession | null>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
export const CLAUDE_PROJECTS_ROOT = path.join(os.homedir(), '.claude', 'projects');
|
|
5
|
+
// Mirrors swarmify's workspaceToClaudeFolder: replace / and . with -
|
|
6
|
+
export function claudeWorkspaceFolder(cwd) {
|
|
7
|
+
return cwd.replace(/[\/.]/g, '-');
|
|
8
|
+
}
|
|
9
|
+
export function claudeSessionDir(cwd) {
|
|
10
|
+
return path.join(CLAUDE_PROJECTS_ROOT, claudeWorkspaceFolder(cwd));
|
|
11
|
+
}
|
|
12
|
+
export async function snapshotSessions(cwd) {
|
|
13
|
+
try {
|
|
14
|
+
const files = await fs.promises.readdir(claudeSessionDir(cwd));
|
|
15
|
+
return new Set(files.filter((f) => f.endsWith('.jsonl')));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return new Set();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export async function awaitNewSession(cwd, before, timeoutMs, pollMs = 50) {
|
|
22
|
+
const start = Date.now();
|
|
23
|
+
const dir = claudeSessionDir(cwd);
|
|
24
|
+
while (Date.now() - start < timeoutMs) {
|
|
25
|
+
try {
|
|
26
|
+
const files = await fs.promises.readdir(dir);
|
|
27
|
+
for (const f of files) {
|
|
28
|
+
if (!f.endsWith('.jsonl'))
|
|
29
|
+
continue;
|
|
30
|
+
if (before.has(f))
|
|
31
|
+
continue;
|
|
32
|
+
return {
|
|
33
|
+
sessionId: f.replace(/\.jsonl$/, ''),
|
|
34
|
+
latencyMs: Date.now() - start,
|
|
35
|
+
file: path.join(dir, f),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* dir may not exist yet */
|
|
41
|
+
}
|
|
42
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|