@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
@@ -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 version
57
- * home that would receive native resume. Retained trash/backup transcripts are
58
- * intentionally rejected here: they remain readable by `/continue`, but a new
59
- * installation with the same version number must not native-resume an empty
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
- * Decide how a durable session resumes on the device that owns it.
65
- *
66
- * Native resume is legal only in the exact origin version's isolated home,
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[]>): Promise<SessionRecoveryTarget>;
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 { formatNoHealthyAccountError, pickBalancedCandidate, readinessFromCandidate, } from '../accounting/rotate.js';
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, candidates) {
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 `origin ${session.agent}@${session.version} is not installed`;
48
- const readiness = readinessFromCandidate(source);
49
- return readiness.ready
50
- ? `origin ${session.agent}@${session.version} has no native resume form`
51
- : `origin ${session.agent}@${session.version} is ${readiness.reason}`;
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 version
115
- * home that would receive native resume. Retained trash/backup transcripts are
116
- * intentionally rejected here: they remain readable by `/continue`, but a new
117
- * installation with the same version number must not native-resume an empty
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
- let realFile;
122
- try {
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 roots = [versionHome, path.join(versionHome, agentConfigDirName(session.agent))];
129
- const owned = roots.some((root) => {
202
+ const realFile = resolveOwnedTranscriptRealpath(session.filePath, versionHome, session.agent);
203
+ if (!realFile) {
130
204
  try {
131
- return isPathInside(realFile, fs.realpathSync(root));
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
- * Decide how a durable session resumes on the device that owns it.
158
- *
159
- * Native resume is legal only in the exact origin version's isolated home,
160
- * and only while that home owns the indexed transcript AND some injectable
161
- * credential for this harness is healthy: the origin login itself, or a
162
- * provider account rotated in when the origin is usage-limited (PHNX-3626).
163
- * Every other successful path stays on the same harness and uses `/continue`,
164
- * whose indexed transcript reader can reach retained version trash. A
165
- * `/continue` pick of a provider account carries RecoveryAccount so exec
166
- * injects it instead of launching the version home's native login
167
- * (PHNX-3674). No healthy same-harness account is a loud failure.
168
- */
169
- export function resolveSessionRecoveryFromCandidates(session, candidates, supportsNative = nativeResume, nativeInspection) {
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
- const source = session.version
173
- ? candidates.find((candidate) => candidate.version === session.version)
174
- : undefined;
175
- const sourceReady = source ? readinessFromCandidate(source).ready : false;
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 version
179
- // home is installed, native-resume-capable, and still owns the indexed
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 home — rather than dropping to /continue on a different
182
- // version. Only a provider token/key qualifies: a native login lives in its own
183
- // isolated home and cannot be forwarded, so it could never authenticate a
184
- // resume that must read the origin home's transcript (see §11).
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 && session.version && supportsNative(agent, session.version)) {
189
- const inspection = nativeInspection
190
- ?? inspectNativeResumeSession(session, getVersionHomePath(agent, session.version));
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: session.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: `origin ${agent}@${session.version} account is ${why}; rotated to healthy ${account.label} and resuming natively in the same home`,
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 home. If
209
- // native resume is unavailable for that harness, /continue still launches in
210
- // that same healthy home. Only an unusable/missing origin enters balanced
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}` : `healthy ${agent}@${version}`;
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. A balanced same-version provider selected for
224
- // a signed-out/revoked origin must stay on /continue; otherwise we would open
225
- // the origin home with no usable credential and fail (or fork state).
226
- if (sourceReady && session.version === version && supportsNative(agent, version)) {
227
- const inspection = nativeInspection
228
- ?? inspectNativeResumeSession(session, getVersionHomePath(agent, version));
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
- reason: `origin ${agent}@${version} is installed, healthy, and owns the indexed transcript`,
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, candidates)}; continuing with ${continueWith}`,
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
- return resolveSessionRecoveryFromCandidates(session, await collect(agent));
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
+ }