@phnx-labs/agents-cli 1.22.66 → 1.22.69

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 (161) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +21 -8
  3. package/dist/bootstrap.js +6 -3
  4. package/dist/commands/browser.js +46 -21
  5. package/dist/commands/daemon-test-harness.d.ts +1 -1
  6. package/dist/commands/daemon-test-harness.js +2 -2
  7. package/dist/commands/daemon.js +21 -22
  8. package/dist/commands/exec.js +50 -0
  9. package/dist/commands/feed.js +20 -7
  10. package/dist/commands/monitors.js +5 -2
  11. package/dist/commands/projects.d.ts +26 -6
  12. package/dist/commands/projects.js +55 -22
  13. package/dist/commands/repo.js +57 -19
  14. package/dist/commands/resume.d.ts +16 -0
  15. package/dist/commands/resume.js +41 -8
  16. package/dist/commands/routines.js +42 -21
  17. package/dist/commands/send.js +29 -2
  18. package/dist/commands/sessions-inject.d.ts +58 -0
  19. package/dist/commands/sessions-inject.js +143 -7
  20. package/dist/commands/sessions-optimize.js +1 -1
  21. package/dist/commands/sessions-picker.js +1 -0
  22. package/dist/commands/sessions.js +4 -11
  23. package/dist/commands/share.d.ts +5 -3
  24. package/dist/commands/share.js +73 -20
  25. package/dist/commands/ssh.js +205 -2
  26. package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
  27. package/dist/lib/accounting/account-pool-collect.js +6 -4
  28. package/dist/lib/accounting/usage-ingest.js +4 -2
  29. package/dist/lib/accounting/usage-sync.d.ts +38 -93
  30. package/dist/lib/accounting/usage-sync.js +66 -210
  31. package/dist/lib/accounting/usage.d.ts +18 -5
  32. package/dist/lib/accounting/usage.js +165 -20
  33. package/dist/lib/auth-health.d.ts +8 -0
  34. package/dist/lib/auth-health.js +4 -4
  35. package/dist/lib/boot-profile.d.ts +14 -0
  36. package/dist/lib/boot-profile.js +66 -0
  37. package/dist/lib/browser/caller-identity.d.ts +12 -0
  38. package/dist/lib/browser/caller-identity.js +19 -0
  39. package/dist/lib/browser/ipc.d.ts +37 -32
  40. package/dist/lib/browser/ipc.js +146 -94
  41. package/dist/lib/browser/task-index.d.ts +10 -2
  42. package/dist/lib/browser/task-index.js +22 -3
  43. package/dist/lib/channels/providers/desktop.d.ts +5 -4
  44. package/dist/lib/channels/providers/desktop.js +5 -4
  45. package/dist/lib/claude-account-token.js +108 -4
  46. package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
  47. package/dist/lib/daemon/account-state-daemon-service.js +81 -18
  48. package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
  49. package/dist/lib/daemon/auth-sync-service.js +17 -6
  50. package/dist/lib/daemon/catchup-service.d.ts +51 -0
  51. package/dist/lib/daemon/catchup-service.js +51 -0
  52. package/dist/lib/daemon/daemon.d.ts +12 -22
  53. package/dist/lib/daemon/daemon.js +463 -176
  54. package/dist/lib/daemon/runner.js +2 -0
  55. package/dist/lib/daemon/service.d.ts +22 -4
  56. package/dist/lib/daemon/service.js +2 -2
  57. package/dist/lib/daemon/supervisor.d.ts +55 -15
  58. package/dist/lib/daemon/supervisor.js +119 -29
  59. package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
  60. package/dist/lib/daemon/usage-sync-service.js +22 -18
  61. package/dist/lib/daemon-health.js +36 -31
  62. package/dist/lib/daemon-services.d.ts +1 -1
  63. package/dist/lib/daemon-services.js +12 -2
  64. package/dist/lib/daemon-ticks.d.ts +9 -6
  65. package/dist/lib/daemon-ticks.js +14 -8
  66. package/dist/lib/devices/health.d.ts +38 -2
  67. package/dist/lib/devices/health.js +43 -5
  68. package/dist/lib/devices/registry.js +2 -0
  69. package/dist/lib/devices/worker-pick.d.ts +1 -1
  70. package/dist/lib/devices/worker-pick.js +4 -1
  71. package/dist/lib/exec.js +15 -0
  72. package/dist/lib/feed/watch.d.ts +3 -0
  73. package/dist/lib/feed/watch.js +13 -3
  74. package/dist/lib/feed-broadcast.d.ts +64 -5
  75. package/dist/lib/feed-broadcast.js +124 -22
  76. package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
  77. package/dist/lib/fleet-shared-repo-sync.js +333 -0
  78. package/dist/lib/fleet-shared-state.d.ts +38 -0
  79. package/dist/lib/fleet-shared-state.js +105 -0
  80. package/dist/lib/hosts/remote-cmd.d.ts +2 -0
  81. package/dist/lib/hosts/remote-cmd.js +12 -3
  82. package/dist/lib/lock-compromise.d.ts +8 -0
  83. package/dist/lib/lock-compromise.js +12 -0
  84. package/dist/lib/monitors/engine.d.ts +2 -1
  85. package/dist/lib/monitors/engine.js +27 -2
  86. package/dist/lib/monitors/sources/command.js +13 -3
  87. package/dist/lib/monitors/sources/failure.d.ts +32 -0
  88. package/dist/lib/monitors/sources/failure.js +52 -0
  89. package/dist/lib/monitors/sources/types.d.ts +9 -0
  90. package/dist/lib/owner-message.d.ts +12 -0
  91. package/dist/lib/owner-message.js +44 -0
  92. package/dist/lib/refresh-coordinator.js +2 -0
  93. package/dist/lib/run-trace-sync.d.ts +28 -0
  94. package/dist/lib/run-trace-sync.js +99 -0
  95. package/dist/lib/secrets/filestore.d.ts +4 -0
  96. package/dist/lib/secrets/filestore.js +164 -3
  97. package/dist/lib/secrets/push.d.ts +10 -0
  98. package/dist/lib/secrets/push.js +86 -7
  99. package/dist/lib/secrets/remote.d.ts +18 -6
  100. package/dist/lib/secrets/remote.js +29 -4
  101. package/dist/lib/secrets/reserved-sync.d.ts +28 -27
  102. package/dist/lib/secrets/reserved-sync.js +119 -101
  103. package/dist/lib/session/active.d.ts +13 -1
  104. package/dist/lib/session/active.js +5 -0
  105. package/dist/lib/session/actor-sidecar.d.ts +12 -0
  106. package/dist/lib/session/actor-sidecar.js +2 -0
  107. package/dist/lib/session/db.d.ts +16 -2
  108. package/dist/lib/session/db.js +73 -22
  109. package/dist/lib/session/discover.d.ts +12 -3
  110. package/dist/lib/session/discover.js +187 -26
  111. package/dist/lib/session/linear.d.ts +13 -0
  112. package/dist/lib/session/linear.js +44 -0
  113. package/dist/lib/session/live-metadata.js +1 -0
  114. package/dist/lib/session/parse.js +2 -3
  115. package/dist/lib/session/prompt.d.ts +17 -0
  116. package/dist/lib/session/prompt.js +35 -0
  117. package/dist/lib/session/recovery.d.ts +43 -6
  118. package/dist/lib/session/recovery.js +80 -10
  119. package/dist/lib/session/remote/remote-list.d.ts +17 -1
  120. package/dist/lib/session/remote/remote-list.js +29 -4
  121. package/dist/lib/session/remote/watch.d.ts +25 -2
  122. package/dist/lib/session/remote/watch.js +188 -11
  123. package/dist/lib/session/session-cache.d.ts +2 -1
  124. package/dist/lib/session/session-cache.js +1 -0
  125. package/dist/lib/session/state.js +11 -13
  126. package/dist/lib/session/types.d.ts +2 -0
  127. package/dist/lib/share/backend.d.ts +2 -2
  128. package/dist/lib/share/backend.js +20 -9
  129. package/dist/lib/share/delete.d.ts +5 -1
  130. package/dist/lib/share/delete.js +7 -2
  131. package/dist/lib/share/http-error.d.ts +52 -0
  132. package/dist/lib/share/http-error.js +65 -0
  133. package/dist/lib/share/publish.d.ts +67 -11
  134. package/dist/lib/share/publish.js +98 -16
  135. package/dist/lib/share/worker-template.js +105 -9
  136. package/dist/lib/smart-launch.js +27 -4
  137. package/dist/lib/ssh-exec.d.ts +2 -0
  138. package/dist/lib/ssh-exec.js +20 -4
  139. package/dist/lib/storage/index.d.ts +14 -0
  140. package/dist/lib/storage/index.js +14 -0
  141. package/dist/lib/storage/selection.d.ts +48 -0
  142. package/dist/lib/storage/selection.js +39 -0
  143. package/dist/lib/storage/visibility.d.ts +82 -0
  144. package/dist/lib/storage/visibility.js +99 -0
  145. package/dist/lib/teams/agents.js +3 -1
  146. package/dist/lib/teams/placement-probe.js +1 -0
  147. package/dist/lib/teams/registry.js +2 -0
  148. package/dist/lib/teams/scheduler.d.ts +8 -1
  149. package/dist/lib/teams/scheduler.js +4 -1
  150. package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
  151. package/dist/lib/testdata/daemon-health-writer.js +8 -0
  152. package/dist/lib/traces/backend.js +13 -2
  153. package/dist/lib/traces/sync.d.ts +7 -0
  154. package/dist/lib/traces/sync.js +9 -0
  155. package/dist/lib/usage-refresh.d.ts +8 -2
  156. package/dist/lib/usage-refresh.js +3 -3
  157. package/dist/lib/worktree/held.d.ts +166 -0
  158. package/dist/lib/worktree/held.js +368 -0
  159. package/package.json +2 -2
  160. package/dist/lib/account-state-service.d.ts +0 -21
  161. package/dist/lib/account-state-service.js +0 -60
@@ -16,6 +16,50 @@ import os from 'node:os';
16
16
  import path from 'node:path';
17
17
  /** A Linear tracker key: `TEAM-N`, e.g. `RUSH-1234`. Mirrors the detector's shape. */
18
18
  const LINEAR_KEY_RE = /^[A-Z]{2,6}-\d{1,6}$/;
19
+ /**
20
+ * The same key, matched inside free text on word boundaries. Uppercase-only team
21
+ * key (2–6 letters) so a lowercase `utf-8` can't masquerade as a ticket. Global
22
+ * so {@link linearIssueKeys} can pull every mention out of a body.
23
+ */
24
+ const LINEAR_KEY_IN_TEXT_RE = /\b([A-Z]{2,6}-\d{1,6})\b/g;
25
+ /**
26
+ * Team prefixes that match the key shape but are not trackers — unit strings and
27
+ * common acronyms. Canonical here (the Linear module) so both the URL linkifier
28
+ * and the session-transcript ticket detector (`detectTicket` in `state.ts`) agree
29
+ * on what is a real key rather than each keeping its own copy.
30
+ */
31
+ export const LINEAR_KEY_DENYLIST = new Set([
32
+ 'UTF',
33
+ 'SHA',
34
+ 'ISO',
35
+ 'RFC',
36
+ 'IPV',
37
+ 'X86',
38
+ 'ARM',
39
+ 'MP',
40
+ 'H',
41
+ ]);
42
+ /**
43
+ * Every distinct real Linear key mentioned in free text, in first-seen order.
44
+ * Honours {@link LINEAR_KEY_DENYLIST} so `UTF-8` / `X86-64` never count. Used to
45
+ * linkify ticket ids an owner ping only names in prose (no `session.ticketId`).
46
+ */
47
+ export function linearIssueKeys(text) {
48
+ if (!text)
49
+ return [];
50
+ const seen = new Set();
51
+ const keys = [];
52
+ for (const m of text.matchAll(LINEAR_KEY_IN_TEXT_RE)) {
53
+ const key = m[1];
54
+ if (LINEAR_KEY_DENYLIST.has(key.split('-')[0]))
55
+ continue;
56
+ if (seen.has(key))
57
+ continue;
58
+ seen.add(key);
59
+ keys.push(key);
60
+ }
61
+ return keys;
62
+ }
19
63
  // `undefined` = not yet resolved; `null` = resolved-but-unknown (skip re-reading).
20
64
  let workspaceCache;
21
65
  /** The configured Linear workspace slug, or undefined when unknown. Cached per process. */
@@ -49,6 +49,7 @@ export function activeSessionToSessionMeta(active, self, nowMs) {
49
49
  project: active.project ?? undefined,
50
50
  label: active.label,
51
51
  topic: active.topic,
52
+ firstUserMessage: active.firstUserMessage,
52
53
  version: active.version,
53
54
  messageCount: undefined,
54
55
  machine: active.machine ?? self,
@@ -10,7 +10,7 @@ import { truncate } from '../format.js';
10
10
  import { sanitizeForTerminal } from '../redact.js';
11
11
  import * as path from 'path';
12
12
  import Database from '../sqlite.js';
13
- import { isSyntheticUserMessage, extractSlashCommandName, extractSlashCommandFromToolInput } from './prompt.js';
13
+ import { isSyntheticUserMessage, extractSlashCommandName, extractSlashCommandFromToolInput, unwrapUserQuery } from './prompt.js';
14
14
  import { structuredToolResult, commandsFromCodexExec } from './tool-calls.js';
15
15
  /**
16
16
  * Largest session file we will load into memory. Above this we throw a clean
@@ -1989,9 +1989,8 @@ export function parseCursor(filePath) {
1989
1989
  * differs from the one that wrote the transcript.
1990
1990
  */
1991
1991
  function parseCursorUserText(text) {
1992
- const query = text.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/);
1993
1992
  const stamp = text.match(/<timestamp>\s*([\s\S]*?)\s*<\/timestamp>/)?.[1]?.trim();
1994
- return { text: (query?.[1] ?? text).trim(), timestamp: parseCursorTimestamp(stamp) };
1993
+ return { text: unwrapUserQuery(text), timestamp: parseCursorTimestamp(stamp) };
1995
1994
  }
1996
1995
  function parseCursorTimestamp(stamp) {
1997
1996
  if (!stamp)
@@ -5,6 +5,7 @@
5
5
  * wrapper tags, team-spawn boilerplate) so that only the real user intent
6
6
  * remains. Used by the session picker to show a human-readable topic line.
7
7
  */
8
+ import type { SessionEvent } from './types.js';
8
9
  /**
9
10
  * True when a `role=user` message is harness-injected scaffolding, not a genuine
10
11
  * user turn. Text-based so it is cross-harness by construction (the markers are
@@ -12,6 +13,22 @@
12
13
  * own scaffolding at parse time simply never reach here.
13
14
  */
14
15
  export declare function isSyntheticUserMessage(raw: string | undefined): boolean;
16
+ /**
17
+ * Inner text of a `<user_query>` wrapper, or the original string if none.
18
+ * Same extraction Cursor already applies in `parseCursorUserText`.
19
+ */
20
+ export declare function unwrapUserQuery(text: string): string;
21
+ /**
22
+ * Return one genuine user turn in full.
23
+ * Synthetic scaffolding is rejected as a whole so an injected Apps/AGENTS
24
+ * preamble can never become the session's displayed request. Unlike the topic
25
+ * cleaner, this intentionally preserves internal whitespace and every line of
26
+ * the user's actual request. A Grok/Cursor `<user_query>` wrapper is unwrapped
27
+ * so the stored turn is the originating request, not the harness tags.
28
+ */
29
+ export declare function cleanFirstUserMessage(raw: string | undefined): string | undefined;
30
+ /** First genuine user message from an already-normalized event stream. */
31
+ export declare function firstUserMessageFromEvents(events: SessionEvent[]): string | undefined;
15
32
  /**
16
33
  * Extract the invoked slash-command name from a raw `role=user` message, or
17
34
  * undefined when the message carries no `<command-name>` wrapper. Used by
@@ -47,7 +47,9 @@ const SYNTHETIC_USER_MESSAGE_PATTERNS = [
47
47
  /^\s*<\/?persisted-output>/i,
48
48
  /^\s*<permissions instructions>/i,
49
49
  /^\s*<(?:apps|plugins|skills)_instructions>/i,
50
+ /^\s*<recommended_plugins>/i,
50
51
  /^\s*<(?:multi_agent_mode|environment_context)>/i,
52
+ /^\s*<user_info>/i,
51
53
  /^\s*## (?:In-flight in this repo|Host & Fleet)/i,
52
54
  /^\s*Your current session id is\b/i,
53
55
  /^\s*Linear context skipped:/i,
@@ -68,6 +70,39 @@ export function isSyntheticUserMessage(raw) {
68
70
  return false;
69
71
  return SYNTHETIC_USER_MESSAGE_PATTERNS.some(pattern => pattern.test(raw));
70
72
  }
73
+ /**
74
+ * Inner text of a `<user_query>` wrapper, or the original string if none.
75
+ * Same extraction Cursor already applies in `parseCursorUserText`.
76
+ */
77
+ export function unwrapUserQuery(text) {
78
+ const query = text.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/);
79
+ return (query?.[1] ?? text).trim();
80
+ }
81
+ /**
82
+ * Return one genuine user turn in full.
83
+ * Synthetic scaffolding is rejected as a whole so an injected Apps/AGENTS
84
+ * preamble can never become the session's displayed request. Unlike the topic
85
+ * cleaner, this intentionally preserves internal whitespace and every line of
86
+ * the user's actual request. A Grok/Cursor `<user_query>` wrapper is unwrapped
87
+ * so the stored turn is the originating request, not the harness tags.
88
+ */
89
+ export function cleanFirstUserMessage(raw) {
90
+ if (!raw || isSyntheticUserMessage(raw))
91
+ return undefined;
92
+ const clean = unwrapUserQuery(raw);
93
+ return clean || undefined;
94
+ }
95
+ /** First genuine user message from an already-normalized event stream. */
96
+ export function firstUserMessageFromEvents(events) {
97
+ for (const event of events) {
98
+ if (event.type !== 'message' || event.role !== 'user' || event._synthetic)
99
+ continue;
100
+ const first = cleanFirstUserMessage(event.content);
101
+ if (first)
102
+ return first;
103
+ }
104
+ return undefined;
105
+ }
71
106
  /**
72
107
  * The `<command-name>` wrapper Claude injects into a `role=user` message when
73
108
  * a typed slash command is invoked, e.g.:
@@ -1,16 +1,39 @@
1
1
  import { type RotateCandidate } from '../accounting/rotate.js';
2
2
  import type { AgentId } from '../types.js';
3
- import type { SessionMeta } from './types.js';
3
+ import type { SessionAgentId, SessionMeta } from './types.js';
4
+ /** One capability boundary for every surface that advertises faithful Resume. */
5
+ 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
+ */
20
+ export interface RecoveryAccount {
21
+ providerAccount: string;
22
+ label: string;
23
+ email: string | null;
24
+ }
4
25
  export type SessionRecoveryTarget = {
5
26
  mode: 'native';
6
27
  agent: AgentId;
7
28
  version: string;
8
29
  cwd?: string;
30
+ account?: RecoveryAccount;
9
31
  reason: string;
10
32
  } | {
11
33
  mode: 'continue';
12
34
  agent: AgentId;
13
35
  version: string;
36
+ account?: RecoveryAccount;
14
37
  reason: string;
15
38
  };
16
39
  export type NativeResumeInspection = {
@@ -40,13 +63,27 @@ export declare function inspectNativeResumeSession(session: SessionMeta, version
40
63
  /**
41
64
  * Decide how a durable session resumes on the device that owns it.
42
65
  *
43
- * Native resume is legal only in the exact origin version's isolated home and
44
- * only while that account is healthy. Every other successful path stays on the
45
- * same harness and uses `/continue`, whose indexed transcript reader can reach
46
- * retained version trash. No healthy same-harness account is a loud failure.
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.
47
75
  */
48
76
  export declare function resolveSessionRecoveryFromCandidates(session: SessionMeta, candidates: RotateCandidate[], supportsNative?: (agent: AgentId, version?: string) => boolean, nativeInspection?: NativeResumeInspection): SessionRecoveryTarget;
49
- export declare function resolveSessionRecovery(session: SessionMeta): Promise<SessionRecoveryTarget>;
77
+ /**
78
+ * Resolve recovery for a durable session, reading the live account pool.
79
+ *
80
+ * Uses {@link collectRunCandidatesForRun} (native version-home logins PLUS
81
+ * durable provider accounts, RUSH-3182) rather than the native-only
82
+ * {@link collectRunCandidates}, so an origin-account limit can rotate to a
83
+ * healthy provider account and stay NATIVE (PHNX-3626). `collect` is injectable
84
+ * for tests and for callers that must stay native-only.
85
+ */
86
+ export declare function resolveSessionRecovery(session: SessionMeta, collect?: (agent: AgentId) => Promise<RotateCandidate[]>): Promise<SessionRecoveryTarget>;
50
87
  /** Stable self-command used by focus, resume, and attach. The owning device runs
51
88
  * the recovery resolver above; callers must not native-resume another version's
52
89
  * isolated home themselves. */
@@ -4,8 +4,14 @@ 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 { collectRunCandidates, formatNoHealthyAccountError, pickBalancedCandidate, readinessFromCandidate, } from '../accounting/rotate.js';
7
+ import { formatNoHealthyAccountError, pickBalancedCandidate, readinessFromCandidate, } from '../accounting/rotate.js';
8
+ import { collectRunCandidatesForRun } from '../accounting/account-pool-collect.js';
8
9
  import { getVersionHomePath } from '../installations/store.js';
10
+ const RESUMABLE_SESSION_AGENTS = new Set(['claude', 'codex', 'muse', 'opencode']);
11
+ /** One capability boundary for every surface that advertises faithful Resume. */
12
+ export function sessionAgentSupportsResume(agent) {
13
+ return RESUMABLE_SESSION_AGENTS.has(agent);
14
+ }
9
15
  export class SessionRecoveryError extends Error {
10
16
  constructor(message) {
11
17
  super(message);
@@ -44,6 +50,16 @@ function sourceReason(session, candidates) {
44
50
  ? `origin ${session.agent}@${session.version} has no native resume form`
45
51
  : `origin ${session.agent}@${session.version} is ${readiness.reason}`;
46
52
  }
53
+ function recoveryAccountFromCandidate(candidate) {
54
+ const providerAccount = candidate.providerAccount;
55
+ if (!providerAccount)
56
+ return undefined;
57
+ return {
58
+ providerAccount,
59
+ label: candidate.accountLabel || providerAccount,
60
+ email: candidate.email,
61
+ };
62
+ }
47
63
  function isPathInside(candidate, dir) {
48
64
  const rel = path.relative(dir, candidate);
49
65
  return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
@@ -140,10 +156,15 @@ export function inspectNativeResumeSession(session, versionHome) {
140
156
  /**
141
157
  * Decide how a durable session resumes on the device that owns it.
142
158
  *
143
- * Native resume is legal only in the exact origin version's isolated home and
144
- * only while that account is healthy. Every other successful path stays on the
145
- * same harness and uses `/continue`, whose indexed transcript reader can reach
146
- * retained version trash. No healthy same-harness account is a loud failure.
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.
147
168
  */
148
169
  export function resolveSessionRecoveryFromCandidates(session, candidates, supportsNative = nativeResume, nativeInspection) {
149
170
  const agent = runnableSessionAgent(session);
@@ -152,6 +173,38 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
152
173
  ? candidates.find((candidate) => candidate.version === session.version)
153
174
  : undefined;
154
175
  const sourceReady = source ? readinessFromCandidate(source).ready : false;
176
+ // Native-first with account rotation (PHNX-3626). When the origin login is
177
+ // 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
180
+ // 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;
186
+ 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));
191
+ if (inspection.available) {
192
+ const rotated = pickBalancedCandidate(candidates.filter((c) => c.providerAccount && c.accountKey !== source.accountKey));
193
+ const account = rotated ? recoveryAccountFromCandidate(rotated.picked) : undefined;
194
+ if (account) {
195
+ // `originLimited` guarantees the origin is unhealthy with a limit reason.
196
+ const why = originReadiness.ready ? 'limited' : originReadiness.reason;
197
+ return {
198
+ mode: 'native',
199
+ agent,
200
+ version: session.version,
201
+ cwd: inspection.cwd,
202
+ account,
203
+ reason: `origin ${agent}@${session.version} account is ${why}; rotated to healthy ${account.label} and resuming natively in the same home`,
204
+ };
205
+ }
206
+ }
207
+ }
155
208
  // An exact healthy origin is deterministic: preserve its isolated home. If
156
209
  // native resume is unavailable for that harness, /continue still launches in
157
210
  // that same healthy home. Only an unusable/missing origin enters balanced
@@ -164,7 +217,13 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
164
217
  throw new SessionRecoveryError(`Cannot recover session ${session.shortId} on ${device}; origin ${agent}@${session.version ?? 'unknown'}. ${detail}`);
165
218
  }
166
219
  const version = selection.picked.version;
167
- if (session.version === version && supportsNative(agent, version)) {
220
+ const account = recoveryAccountFromCandidate(selection.picked);
221
+ const continueWith = account ? `healthy ${account.label}` : `healthy ${agent}@${version}`;
222
+ // 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)) {
168
227
  const inspection = nativeInspection
169
228
  ?? inspectNativeResumeSession(session, getVersionHomePath(agent, version));
170
229
  if (inspection.available) {
@@ -180,19 +239,30 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
180
239
  mode: 'continue',
181
240
  agent,
182
241
  version,
183
- reason: `${inspection.reason}; continuing with healthy ${agent}@${version}`,
242
+ ...(account ? { account } : {}),
243
+ reason: `${inspection.reason}; continuing with ${continueWith}`,
184
244
  };
185
245
  }
186
246
  return {
187
247
  mode: 'continue',
188
248
  agent,
189
249
  version,
190
- reason: `${sourceReason(session, candidates)}; continuing with healthy ${agent}@${version}`,
250
+ ...(account ? { account } : {}),
251
+ reason: `${sourceReason(session, candidates)}; continuing with ${continueWith}`,
191
252
  };
192
253
  }
193
- export async function resolveSessionRecovery(session) {
254
+ /**
255
+ * Resolve recovery for a durable session, reading the live account pool.
256
+ *
257
+ * Uses {@link collectRunCandidatesForRun} (native version-home logins PLUS
258
+ * durable provider accounts, RUSH-3182) rather than the native-only
259
+ * {@link collectRunCandidates}, so an origin-account limit can rotate to a
260
+ * healthy provider account and stay NATIVE (PHNX-3626). `collect` is injectable
261
+ * for tests and for callers that must stay native-only.
262
+ */
263
+ export async function resolveSessionRecovery(session, collect = collectRunCandidatesForRun) {
194
264
  const agent = runnableSessionAgent(session);
195
- return resolveSessionRecoveryFromCandidates(session, await collectRunCandidates(agent));
265
+ return resolveSessionRecoveryFromCandidates(session, await collect(agent));
196
266
  }
197
267
  /** Stable self-command used by focus, resume, and attach. The owning device runs
198
268
  * the recovery resolver above; callers must not native-resume another version's
@@ -145,7 +145,23 @@ export declare function fetchPeerPreviewDigest(sessionId: string, machine: strin
145
145
  * `opts.sessionId` (with `tty`) prints the session id and resume command when
146
146
  * the SSH hop ends, so OpenSSH's `Shared connection … closed.` is not the last
147
147
  * thing on the local shell (RUSH-3227). Omit it for one-shot non-TTY renders.
148
+ *
149
+ * Resolves `'unreachable'` when the SSH connection itself failed — ssh could not
150
+ * launch (spawn error) or exited with {@link SSH_CONN_FAILURE_CODE} (255, its
151
+ * connect-failure convention) — as opposed to `'ok'` for a hop that actually
152
+ * reached the peer (whatever the remote command's own exit code). This lets a
153
+ * caller prefer the recorded device yet fall back locally when it is genuinely
154
+ * offline (PHNX-3626); callers that ignore `'unreachable'` behave exactly as
155
+ * before (it was `'ok'`).
156
+ */
157
+ /**
158
+ * Classify a finished SSH hop by its exit code: `'unreachable'` when the
159
+ * connection itself failed (ssh's {@link SSH_CONN_FAILURE_CODE} = 255, or a null
160
+ * code from a killed/never-launched child), else `'ok'` — the remote command ran,
161
+ * whatever its own exit status. Pure so the offline-device fallback is unit-tested
162
+ * without a live SSH hop (PHNX-3626).
148
163
  */
164
+ export declare function peerHopOutcome(code: number | null): 'ok' | 'unreachable';
149
165
  export declare function peerHopCloseNotice(opts: {
150
166
  tty?: boolean;
151
167
  sessionId?: string;
@@ -154,4 +170,4 @@ export declare function runOnPeer(args: string[], machine: string, opts?: {
154
170
  tty?: boolean;
155
171
  env?: Record<string, string>;
156
172
  sessionId?: string;
157
- }): Promise<'ok' | 'no-target'>;
173
+ }): Promise<'ok' | 'no-target' | 'unreachable'>;
@@ -582,7 +582,25 @@ export async function fetchPeerPreviewDigest(sessionId, machine, timeoutMs = PEE
582
582
  * `opts.sessionId` (with `tty`) prints the session id and resume command when
583
583
  * the SSH hop ends, so OpenSSH's `Shared connection … closed.` is not the last
584
584
  * thing on the local shell (RUSH-3227). Omit it for one-shot non-TTY renders.
585
+ *
586
+ * Resolves `'unreachable'` when the SSH connection itself failed — ssh could not
587
+ * launch (spawn error) or exited with {@link SSH_CONN_FAILURE_CODE} (255, its
588
+ * connect-failure convention) — as opposed to `'ok'` for a hop that actually
589
+ * reached the peer (whatever the remote command's own exit code). This lets a
590
+ * caller prefer the recorded device yet fall back locally when it is genuinely
591
+ * offline (PHNX-3626); callers that ignore `'unreachable'` behave exactly as
592
+ * before (it was `'ok'`).
593
+ */
594
+ /**
595
+ * Classify a finished SSH hop by its exit code: `'unreachable'` when the
596
+ * connection itself failed (ssh's {@link SSH_CONN_FAILURE_CODE} = 255, or a null
597
+ * code from a killed/never-launched child), else `'ok'` — the remote command ran,
598
+ * whatever its own exit status. Pure so the offline-device fallback is unit-tested
599
+ * without a live SSH hop (PHNX-3626).
585
600
  */
601
+ export function peerHopOutcome(code) {
602
+ return code === SSH_CONN_FAILURE_CODE || code === null ? 'unreachable' : 'ok';
603
+ }
586
604
  export function peerHopCloseNotice(opts, machine, code) {
587
605
  if (!opts.tty || !opts.sessionId)
588
606
  return undefined;
@@ -596,9 +614,13 @@ export async function runOnPeer(args, machine, opts = {}) {
596
614
  const cols = terminalWidth();
597
615
  const env = { ...(cols > 0 ? { COLUMNS: String(cols) } : {}), ...opts.env };
598
616
  const assignments = Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`);
617
+ const invocation = assignments.concat(['agents', ...args].map(shellQuote)).join(' ');
618
+ // OpenSSH uses 255 for transport failure, but also propagates a reached remote
619
+ // program's 255 verbatim. Remap only the remote program's 255 inside the peer
620
+ // shell so the outer SSH status remains an unambiguous connectivity signal.
599
621
  const remoteCmd = remoteShellFor(peer.os) === 'powershell'
600
- ? buildWindowsAgentsCommand({ args, env: assignments.length ? env : undefined })
601
- : `bash -lc ${shellQuote(assignments.concat(['agents', ...args].map(shellQuote)).join(' '))}`;
622
+ ? buildWindowsAgentsCommand({ args, env: assignments.length ? env : undefined, remapExit255: true })
623
+ : `bash -lc ${shellQuote(`${invocation}; agents_rc=$?; if [ "$agents_rc" -eq 255 ]; then exit 254; fi; exit "$agents_rc"`)}`;
602
624
  const sshArgs = [...SSH_OPTS, ...controlOpts()];
603
625
  if (opts.tty)
604
626
  sshArgs.push('-tt'); // force a PTY so the resumed agent is interactive
@@ -610,7 +632,7 @@ export async function runOnPeer(args, machine, opts = {}) {
610
632
  // we resolve once it settles so the picker flow completes.
611
633
  child.on('error', (err) => {
612
634
  process.stderr.write(chalk.red(`Failed to reach ${machine}: ${err?.message ?? 'ssh failed to launch'}\n`));
613
- resolve('ok');
635
+ resolve('unreachable');
614
636
  });
615
637
  child.on('close', (code) => {
616
638
  // Interactive TTY hop: OpenSSH prints "Shared connection … closed." and
@@ -618,7 +640,10 @@ export async function runOnPeer(args, machine, opts = {}) {
618
640
  const notice = peerHopCloseNotice(opts, machine, code);
619
641
  if (notice)
620
642
  process.stderr.write(notice);
621
- resolve('ok');
643
+ // ssh exits 255 only when the connection itself failed (host down/asleep),
644
+ // distinct from the remote command's own non-zero exit. Report that so a
645
+ // caller can fall back locally instead of silently completing.
646
+ resolve(peerHopOutcome(code));
622
647
  });
623
648
  });
624
649
  }
@@ -1,7 +1,9 @@
1
1
  import { type ActiveSession } from '../active.js';
2
+ import type { SessionMeta } from '../types.js';
2
3
  import { readActiveSessionsCache } from '../session-cache.js';
3
4
  export declare const SESSION_WATCH_VERSION: 1;
4
5
  export declare const SESSION_WATCH_HEARTBEAT_MS = 15000;
6
+ export declare const SESSION_WATCH_PREVIOUS_LIMIT = 50;
5
7
  export type SessionWatchScopeStatus = 'available' | 'unavailable';
6
8
  export type SessionWatchEnvelope = {
7
9
  version: 1;
@@ -45,9 +47,15 @@ export type SessionWatchEnvelope = {
45
47
  scope: string;
46
48
  capturedAt: number;
47
49
  };
48
- export interface SessionWatchRow extends Omit<ActiveSession, 'viewingIn'> {
50
+ export interface SessionWatchRow extends Omit<ActiveSession, 'viewingIn' | 'context'> {
51
+ context: ActiveSession['context'] | 'recent';
52
+ /** Flat durable branch for history rows that are not currently in a worktree. */
53
+ branch?: string;
49
54
  rowKey: string;
50
55
  sourceDevice: string;
56
+ /** Durable index rows are kept on the stream under a distinct identity so a
57
+ * live row can replace/disappear without erasing its recoverable history. */
58
+ previous: boolean;
51
59
  resumable: boolean;
52
60
  unwatched: boolean;
53
61
  viewingIn: string | null;
@@ -60,6 +68,13 @@ export interface SessionWatchRow extends Omit<ActiveSession, 'viewingIn'> {
60
68
  /** Stable, opaque identity for one row within one device scope. */
61
69
  export declare function sessionWatchRowKey(scope: string, row: ActiveSession): string;
62
70
  export declare function toSessionWatchRow(scope: string, row: ActiveSession): SessionWatchRow;
71
+ /** Stable identity for a durable Previous row. It is deliberately distinct
72
+ * from the live row key for the same session id: both may coexist on the one
73
+ * stream, and the presentation layer lets the live row win while it exists. */
74
+ export declare function previousSessionWatchRowKey(scope: string, sessionId: string): string;
75
+ /** Project one durable indexed session into the same canonical watch contract
76
+ * as live sessions. This is the only history backfill consumed by AGI EXT. */
77
+ export declare function toPreviousSessionWatchRow(scope: string, session: SessionMeta): SessionWatchRow;
63
78
  /** Stream-local sequencer and convergent row diff. */
64
79
  export declare class SessionWatchState {
65
80
  readonly streamId: string;
@@ -67,7 +82,10 @@ export declare class SessionWatchState {
67
82
  private readonly rows;
68
83
  constructor(streamId?: string);
69
84
  private base;
70
- reset(scope: string, sourceRows: ActiveSession[]): SessionWatchEnvelope;
85
+ /** Keep one visible Previous row per session and the newest bounded window.
86
+ * Returns removed keys so delta callers can converge already-connected clients. */
87
+ private prunePrevious;
88
+ reset(scope: string, sourceRows: ActiveSession[], indexedRows?: SessionMeta[]): SessionWatchEnvelope;
71
89
  update(scope: string, sourceRows: ActiveSession[]): SessionWatchEnvelope[];
72
90
  patch(scope: string, upserts: ActiveSession[], removes: string[]): SessionWatchEnvelope[];
73
91
  scope(scope: string, status: SessionWatchScopeStatus, reason?: string): SessionWatchEnvelope;
@@ -80,9 +98,14 @@ export interface WatchLocalOptions {
80
98
  refreshMs?: number;
81
99
  heartbeatMs?: number;
82
100
  readCache?: typeof readActiveSessionsCache;
101
+ readPrevious?: (scope: string) => SessionMeta[];
83
102
  journalPath?: string;
84
103
  journalPollMs?: number;
85
104
  }
105
+ /** One bounded index read when the watch starts. The stream then owns this
106
+ * projection: active rows update through the journal, and removals demote into
107
+ * Previous rows without another transcript/index poll. */
108
+ export declare function readPreviousSessionsForWatch(scope: string): SessionMeta[];
86
109
  /**
87
110
  * Keep one local subscription alive. Startup reads one canonical reset snapshot;
88
111
  * steady state tails the canonical writer journal and never invokes a gather.