@phnx-labs/agents-cli 1.22.27 → 1.22.29

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 (89) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +62 -26
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +54 -10
  5. package/dist/commands/hosts.js +4 -3
  6. package/dist/commands/perf.d.ts +10 -0
  7. package/dist/commands/perf.js +14 -6
  8. package/dist/commands/projects.js +24 -10
  9. package/dist/commands/routines.js +31 -4
  10. package/dist/commands/run-account-picker.d.ts +37 -0
  11. package/dist/commands/run-account-picker.js +101 -17
  12. package/dist/commands/secrets.js +86 -2
  13. package/dist/commands/sessions.d.ts +26 -6
  14. package/dist/commands/sessions.js +46 -12
  15. package/dist/commands/teams.js +19 -8
  16. package/dist/lib/activity.js +45 -99
  17. package/dist/lib/claude-account-token.js +38 -11
  18. package/dist/lib/cloud/host.js +1 -0
  19. package/dist/lib/devices/resolve-target.d.ts +1 -0
  20. package/dist/lib/devices/resolve-target.js +9 -2
  21. package/dist/lib/events.d.ts +1 -1
  22. package/dist/lib/events.js +2 -2
  23. package/dist/lib/git.d.ts +9 -3
  24. package/dist/lib/git.js +39 -22
  25. package/dist/lib/hooks/profile.d.ts +12 -1
  26. package/dist/lib/hooks/profile.js +6 -1
  27. package/dist/lib/hooks.js +4 -1
  28. package/dist/lib/hosts/dispatch.d.ts +10 -0
  29. package/dist/lib/hosts/dispatch.js +78 -22
  30. package/dist/lib/hosts/logs.js +11 -1
  31. package/dist/lib/hosts/passthrough.d.ts +5 -0
  32. package/dist/lib/hosts/passthrough.js +5 -2
  33. package/dist/lib/hosts/progress.d.ts +10 -0
  34. package/dist/lib/hosts/progress.js +37 -5
  35. package/dist/lib/hosts/ready.d.ts +41 -2
  36. package/dist/lib/hosts/ready.js +100 -12
  37. package/dist/lib/hosts/reconcile.d.ts +2 -1
  38. package/dist/lib/hosts/reconcile.js +13 -5
  39. package/dist/lib/hosts/reconnect.js +5 -3
  40. package/dist/lib/hosts/registry.js +1 -0
  41. package/dist/lib/hosts/tasks.d.ts +1 -0
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  43. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  44. package/dist/lib/menubar/snapshot.d.ts +16 -0
  45. package/dist/lib/menubar/snapshot.js +37 -3
  46. package/dist/lib/models.d.ts +18 -11
  47. package/dist/lib/models.js +19 -15
  48. package/dist/lib/perf/db.js +16 -3
  49. package/dist/lib/perf/types.d.ts +12 -1
  50. package/dist/lib/project-probe.d.ts +22 -3
  51. package/dist/lib/project-probe.js +105 -17
  52. package/dist/lib/project-status.d.ts +9 -0
  53. package/dist/lib/project-status.js +15 -0
  54. package/dist/lib/refresh.js +22 -11
  55. package/dist/lib/rotate.d.ts +22 -0
  56. package/dist/lib/rotate.js +26 -0
  57. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  58. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  59. package/dist/lib/secrets/agent.d.ts +19 -3
  60. package/dist/lib/secrets/agent.js +58 -20
  61. package/dist/lib/secrets/audit.d.ts +1 -1
  62. package/dist/lib/secrets/audit.js +2 -0
  63. package/dist/lib/secrets/bundles.d.ts +3 -3
  64. package/dist/lib/secrets/bundles.js +15 -5
  65. package/dist/lib/secrets/filestore.d.ts +2 -0
  66. package/dist/lib/secrets/filestore.js +4 -0
  67. package/dist/lib/secrets/session-store.d.ts +7 -0
  68. package/dist/lib/secrets/session-store.js +21 -0
  69. package/dist/lib/session/active.d.ts +49 -0
  70. package/dist/lib/session/active.js +175 -11
  71. package/dist/lib/session/bash-command.d.ts +16 -0
  72. package/dist/lib/session/bash-command.js +65 -0
  73. package/dist/lib/session/db.d.ts +4 -0
  74. package/dist/lib/session/db.js +32 -15
  75. package/dist/lib/session/remote-bundle.js +1 -1
  76. package/dist/lib/staleness/detectors/skills.d.ts +2 -0
  77. package/dist/lib/staleness/detectors/skills.js +17 -2
  78. package/dist/lib/staleness/index.d.ts +10 -1
  79. package/dist/lib/staleness/index.js +28 -3
  80. package/dist/lib/teams/agents.d.ts +1 -0
  81. package/dist/lib/teams/agents.js +38 -11
  82. package/dist/lib/teams/remoteWorktree.d.ts +11 -7
  83. package/dist/lib/teams/remoteWorktree.js +29 -27
  84. package/dist/lib/usage.js +18 -10
  85. package/dist/lib/versions.d.ts +1 -0
  86. package/dist/lib/versions.js +40 -22
  87. package/package.json +3 -1
  88. package/dist/lib/exec.bench.d.ts +0 -1
  89. package/dist/lib/exec.bench.js +0 -186
@@ -1,6 +1,8 @@
1
1
  import { select } from '@inquirer/prompts';
2
+ import chalk from 'chalk';
2
3
  import { agentLabel } from '../lib/agents.js';
3
- import { collectRunCandidates, readinessFromCandidate, } from '../lib/rotate.js';
4
+ import { loginHint } from '../lib/signin-badge.js';
5
+ import { collectRunCandidates, readinessFromCandidate, isSignInRecoverable, } from '../lib/rotate.js';
4
6
  import { compareVersions, getGlobalDefault } from '../lib/versions.js';
5
7
  import { isInteractiveTerminal, isPromptCancelled, requireInteractiveSelection } from './utils.js';
6
8
  const CANCEL_SELECTION = '__agents_cancel_account_selection__';
@@ -30,14 +32,21 @@ export function formatAccountLimits(candidate) {
30
32
  })
31
33
  .join(' · ');
32
34
  }
33
- function disabledReason(candidate) {
34
- const readiness = readinessFromCandidate(candidate);
35
+ /**
36
+ * Why a row cannot be picked. Only a THROTTLE disables a row: the account is
37
+ * signed in and out of capacity, so launching it just hammers an exhausted
38
+ * account (RUSH-2132) and nothing the user does at this prompt helps.
39
+ *
40
+ * An AUTH exclusion (`signed_out` / `revoked`) is deliberately NOT disabled —
41
+ * the harness's own TUI is the login surface, so picking that row and launching
42
+ * is the only way to sign in through agents-cli. Disabling it left a fully
43
+ * logged-out harness with no reachable account at all (RUSH-2334).
44
+ */
45
+ function disabledReason(candidate, readiness) {
35
46
  if (readiness.ready)
36
47
  return undefined;
37
- if (readiness.reason === 'signed_out')
38
- return 'logged out';
39
- if (readiness.reason === 'revoked')
40
- return 'needs re-login';
48
+ if (isSignInRecoverable(readiness))
49
+ return undefined;
41
50
  if (readiness.reason === 'out_of_credits')
42
51
  return 'out of credits';
43
52
  const windows = candidate.usageSnapshot?.windows ?? [];
@@ -53,24 +62,44 @@ function disabledReason(candidate) {
53
62
  /** Build aligned picker rows with usable accounts first and unsafe rows disabled. */
54
63
  export function buildRunAccountChoices(candidates, globalDefault) {
55
64
  const rows = candidates.map((candidate) => {
56
- const disabled = disabledReason(candidate);
65
+ const readiness = readinessFromCandidate(candidate);
66
+ const disabled = disabledReason(candidate, readiness);
67
+ const signInRequired = isSignInRecoverable(readiness);
57
68
  const version = candidate.version === globalDefault
58
69
  ? `${candidate.version} (default)`
59
70
  : candidate.version;
71
+ // `revoked` is signed-in-but-rejected, so it reads as a re-login rather
72
+ // than "logged out"; every throttle is still a signed-in account.
73
+ const authReason = readiness.ready ? null : readiness.reason;
74
+ const status = authReason === 'revoked'
75
+ ? 'needs re-login'
76
+ : authReason === 'signed_out'
77
+ ? 'logged out'
78
+ : 'logged in';
60
79
  return {
61
80
  candidate,
62
81
  account: candidate.accountLabel || 'account unavailable',
63
82
  version,
64
- status: candidate.signedIn ? 'logged in' : 'logged out',
83
+ status,
65
84
  plan: candidate.usageSnapshot?.plan ?? candidate.plan ?? 'plan unavailable',
66
- limits: formatAccountLimits(candidate),
85
+ // An auth-blocked row shows what picking it DOES; its quota is moot until
86
+ // there is a credential to spend it with.
87
+ limits: authReason === 'revoked'
88
+ ? 'launch to re-authenticate'
89
+ : authReason === 'signed_out'
90
+ ? 'launch to sign in'
91
+ : formatAccountLimits(candidate),
67
92
  disabled,
68
- ready: disabled === undefined,
93
+ ready: readiness.ready,
94
+ signInRequired,
69
95
  };
70
96
  });
97
+ // Ready accounts first, then the ones a login would unlock (actionable), then
98
+ // the throttled rows the user can do nothing about at this prompt.
99
+ const rank = (row) => row.ready ? 0 : row.signInRequired ? 1 : 2;
71
100
  rows.sort((a, b) => {
72
- if (a.ready !== b.ready)
73
- return a.ready ? -1 : 1;
101
+ if (rank(a) !== rank(b))
102
+ return rank(a) - rank(b);
74
103
  const aDefault = a.candidate.version === globalDefault;
75
104
  const bDefault = b.candidate.version === globalDefault;
76
105
  if (aDefault !== bDefault)
@@ -92,8 +121,56 @@ export function buildRunAccountChoices(candidates, globalDefault) {
92
121
  value: row.candidate.version,
93
122
  disabled: row.disabled,
94
123
  ready: row.ready,
124
+ signInRequired: row.signInRequired,
95
125
  }));
96
126
  }
127
+ /**
128
+ * Whether a zero-healthy run may recover by launching for a login, or must keep
129
+ * failing loud. Three inputs, all of which have to hold:
130
+ *
131
+ * - `recoverable` — at least one excluded account is only auth-blocked. An
132
+ * all-throttled set is never launched (RUSH-2132): only a window reset clears it.
133
+ * - `tty` — a login needs a human present; off a TTY nobody can complete one.
134
+ * - `json` — `--json` marks a MACHINE consumer, which must never be handed a
135
+ * picker or dropped into a login TUI. This mirrors the canonical
136
+ * `Surface.interactive = tty && !json` in `commands/utils.ts`; a `--json` caller
137
+ * gets the parseable fail-loud error instead.
138
+ */
139
+ export function signInLaunchDecision(input) {
140
+ const humanPresent = input.tty && !input.json;
141
+ return input.recoverable > 0 && humanPresent ? 'launch' : 'fail-loud';
142
+ }
143
+ /**
144
+ * Choose which installed version to launch so the user can authenticate, when a
145
+ * strategy found zero healthy accounts but at least one is merely signed out
146
+ * (RUSH-2334). Returns the version to launch, or null if the user cancelled.
147
+ *
148
+ * A single candidate does NOT prompt — a one-item picker is pure noise, and the
149
+ * only thing to decide has one answer. Several candidates fall through to the
150
+ * normal account picker, which shows every account with its state so the choice
151
+ * is informed (throttled rows stay disabled there).
152
+ *
153
+ * Callers MUST have already confirmed an interactive terminal: off a TTY there
154
+ * is nobody to complete the login, and the run should fail loud instead.
155
+ */
156
+ export async function pickSignInLaunchVersion(agent, recoverable, quiet = false) {
157
+ if (recoverable.length === 0)
158
+ return null;
159
+ if (recoverable.length > 1) {
160
+ const selected = await pickRunAccountCandidate(agent);
161
+ return selected?.version ?? null;
162
+ }
163
+ const [only] = recoverable;
164
+ if (!quiet) {
165
+ const readiness = readinessFromCandidate(only);
166
+ const why = !readiness.ready && readiness.reason === 'revoked'
167
+ ? 'has no valid credential (the server rejected its token)'
168
+ : 'has no signed-in account';
169
+ process.stderr.write(chalk.yellow(`${agentLabel(agent)} ${why} — launching ${agent}@${only.version} so you can sign in.\n`));
170
+ process.stderr.write(chalk.gray(`Sign in with: ${loginHint(agent)}\n`));
171
+ }
172
+ return only.version;
173
+ }
97
174
  /** Prompt for one safe installed account/version. A cancelled picker launches nothing. */
98
175
  export async function pickRunAccountCandidate(agent) {
99
176
  if (!isInteractiveTerminal()) {
@@ -107,9 +184,14 @@ export async function pickRunAccountCandidate(agent) {
107
184
  throw new Error(`No installed ${agentLabel(agent)} versions are available. Run: agents add ${agent}@latest`);
108
185
  }
109
186
  const choices = buildRunAccountChoices(candidates, getGlobalDefault(agent));
110
- const hasReadyAccount = choices.some((choice) => choice.ready);
111
- const promptChoices = choices.map(({ ready: _ready, ...choice }) => choice);
112
- if (!hasReadyAccount) {
187
+ // "Selectable" is broader than "ready": an auth-blocked row is pickable so the
188
+ // launch can carry you into the harness's login (RUSH-2334). Only offer the
189
+ // bail-out row when literally nothing can be chosen — i.e. every account is
190
+ // throttled, which no amount of signing in fixes.
191
+ const hasSelectableAccount = choices.some((choice) => !choice.disabled);
192
+ const needsSignIn = choices.some((choice) => choice.signInRequired);
193
+ const promptChoices = choices.map(({ ready: _ready, signInRequired: _signInRequired, ...choice }) => choice);
194
+ if (!hasSelectableAccount) {
113
195
  promptChoices.push({
114
196
  name: 'No usable accounts — cancel',
115
197
  value: CANCEL_SELECTION,
@@ -117,7 +199,9 @@ export async function pickRunAccountCandidate(agent) {
117
199
  }
118
200
  try {
119
201
  const version = await select({
120
- message: `Select a ${agentLabel(agent)} account for this run:`,
202
+ message: needsSignIn
203
+ ? `Select a ${agentLabel(agent)} account for this run (pick a logged-out one to sign in):`
204
+ : `Select a ${agentLabel(agent)} account for this run:`,
121
205
  choices: promptChoices,
122
206
  loop: false,
123
207
  });
@@ -24,8 +24,9 @@ import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore
24
24
  import { getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, maybeAutoRekey, } from '../lib/secrets/index.js';
25
25
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
26
26
  import { GLOBAL_HARNESS } from '../lib/secrets/scope.js';
27
- import { secretsHoldMs, secretsAgentDurable, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
28
- import { saveSession, deleteBundleSessions, deleteAllSessions } from '../lib/secrets/session-store.js';
27
+ import { createSecretLease, selectLeasedEnv } from '../lib/secrets/lease.js';
28
+ import { secretsHoldMs, secretsAgentDurable, agentLoad, agentLock, agentPing, agentSocketExists, agentStatus, agentRevoke, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
29
+ import { activeLeaseSessions, saveSession, deleteBundleSessions, deleteAllSessions, deleteLeaseSession } from '../lib/secrets/session-store.js';
29
30
  import { getCliVersionFresh } from '../lib/version.js';
30
31
  import { readMeta } from '../lib/state.js';
31
32
  import { parseDuration } from '../lib/hooks/cache.js';
@@ -2426,6 +2427,89 @@ Examples:
2426
2427
  console.log(password);
2427
2428
  }
2428
2429
  });
2430
+ cmd
2431
+ .command('lease <bundle>')
2432
+ .description('Hold only an explicit subset of a bundle until an independent expiry.')
2433
+ .requiredOption('--keys <keys>', 'Comma-separated key subset')
2434
+ .requiredOption('--for <duration>', 'Lease duration, for example 30m or 8h')
2435
+ .option('--agent <agent>', 'Narrow the lease to one harness; default is global')
2436
+ .option('--durable', 'Keep the lease across sleep as well as broker restart')
2437
+ .action(async (name, opts) => {
2438
+ if (process.platform !== 'darwin') {
2439
+ throw new Error('Scoped lease brokering is not available on this platform yet.');
2440
+ }
2441
+ const seconds = parseDuration(opts.for);
2442
+ if (seconds === null)
2443
+ throw new Error(`Invalid lease duration '${opts.for}'.`);
2444
+ const ttlMs = seconds * 1000;
2445
+ const harness = opts.agent || GLOBAL_HARNESS;
2446
+ const { bundle, env } = readAndResolveBundleEnv(name, {
2447
+ noAgent: true,
2448
+ interactiveUnlock: true,
2449
+ caller: 'lease secrets',
2450
+ agent: harness,
2451
+ keyMode: 'storage',
2452
+ });
2453
+ const lease = createSecretLease({
2454
+ bundle: name,
2455
+ keys: opts.keys.split(','),
2456
+ availableKeys: Object.keys(env),
2457
+ ttlMs,
2458
+ harness,
2459
+ sleepPersist: opts.durable,
2460
+ });
2461
+ const leasedEnv = selectLeasedEnv(lease, env);
2462
+ if (!(await ensureAgentRunning()) || !(await agentLoad(name, bundle, leasedEnv, ttlMs, harness, lease))) {
2463
+ throw new Error('Could not load the scoped lease into the secrets broker.');
2464
+ }
2465
+ ensureDaemonStarted();
2466
+ saveSession(name, {
2467
+ bundle,
2468
+ env: leasedEnv,
2469
+ expiresAt: lease.expiresAt,
2470
+ sleepPersist: lease.sleepPersist,
2471
+ harness,
2472
+ lease,
2473
+ });
2474
+ emitSecretAudit({
2475
+ event: 'secrets.unlocked', bundle: name, operation: 'lease', source: lease.sleepPersist ? 'broker+durable' : 'broker',
2476
+ status: 'success', keys: lease.keys, keyCount: lease.keys.length, agent: harness, ttlMs: lease.expiresAt - lease.createdAt,
2477
+ });
2478
+ console.log(`${chalk.green('leased')} ${chalk.cyan(lease.id)} ${chalk.gray(`(${name}: ${lease.keys.join(', ')}, ${humanRemaining(lease.expiresAt)})`)}`);
2479
+ });
2480
+ cmd
2481
+ .command('leases')
2482
+ .description('List active scoped secret leases.')
2483
+ .action(async () => {
2484
+ const brokerLeases = (await agentStatus()).filter((entry) => entry.leaseId);
2485
+ const byId = new Map(brokerLeases.map((entry) => [entry.leaseId, entry]));
2486
+ for (const { name, lease } of activeLeaseSessions()) {
2487
+ if (!byId.has(lease.id))
2488
+ byId.set(lease.id, { name, expiresAt: lease.expiresAt, keyCount: lease.keys.length, harness: lease.harness, leaseId: lease.id, keys: lease.keys });
2489
+ }
2490
+ const leases = [...byId.values()];
2491
+ if (leases.length === 0) {
2492
+ console.log(chalk.gray('No active secret leases.'));
2493
+ return;
2494
+ }
2495
+ console.log(`${'ID'.padEnd(24)} ${'BUNDLE'.padEnd(24)} ${'KEYS'.padEnd(24)} EXPIRES IN`);
2496
+ for (const lease of leases) {
2497
+ console.log(`${lease.leaseId.padEnd(24)} ${lease.name.padEnd(24)} ${(lease.keys ?? []).join(',').padEnd(24)} ${humanRemaining(lease.expiresAt)}`);
2498
+ }
2499
+ });
2500
+ cmd
2501
+ .command('revoke <lease-id>')
2502
+ .description('Revoke one scoped secret lease immediately.')
2503
+ .action(async (leaseId) => {
2504
+ if (agentSocketExists() && !(await agentPing()).reachable) {
2505
+ throw new Error('Cannot revoke while the secrets broker is using an incompatible protocol. Restart the broker and retry.');
2506
+ }
2507
+ const persisted = deleteLeaseSession(leaseId);
2508
+ const wiped = await agentRevoke(leaseId);
2509
+ if (wiped + persisted === 0)
2510
+ throw new Error(`Secret lease '${leaseId}' is not active.`);
2511
+ console.log(chalk.green(`Revoked secret lease ${leaseId}.`));
2512
+ });
2429
2513
  cmd
2430
2514
  .command('unlock [names...]')
2431
2515
  .description('Hold a bundle in the secrets-agent after one Touch ID, so concurrent runs read it without re-prompting (macOS). With --host, unlock FILE-backed bundle(s) on a remote (the passphrase prompt surfaces over the SSH TTY); keychain/biometry bundles are GUI-only and can\'t be remote-unlocked.')
@@ -187,13 +187,33 @@ export declare function liveStatusWord(a: ActiveSession | undefined): string;
187
187
  */
188
188
  export declare function isAwaitingUser(s: ActiveSession): boolean;
189
189
  /**
190
- * Whether a row belongs in an unqualified `--active` view.
190
+ * Whether a row belongs in an unqualified `--active` view (RUSH-2336) — the
191
+ * ONE canonical selector every bare-active surface shares: the CLI's grouped
192
+ * table and `--json` (`renderActiveSessions`), the interactive browser's
193
+ * `--active` filter ({@link applyFilters} in `sessions-browser.ts`), `focus`'s
194
+ * attach gate (`isAttachableLiveSession`), and the menubar snapshot
195
+ * ({@link computeMenubarSnapshot}).
191
196
  *
192
- * The live registry deliberately retains terminally-dead rows long enough for
193
- * `--closed` / `--crashed` and recovery to find them. Presence in that registry
194
- * therefore is not, by itself, evidence that a session is still active.
195
- * Explicit lifecycle filters bypass this predicate and select those retained
196
- * rows through {@link matchesLiveStatus} instead.
197
+ * The live registry deliberately retains terminally-dead AND not-yet-started
198
+ * rows long enough for `--closed` / `--crashed` / `--queued` and recovery to
199
+ * find them. Presence in that registry therefore is not, by itself, evidence
200
+ * that a session is CURRENTLY active. Explicit lifecycle filters bypass this
201
+ * predicate and select those retained rows through {@link matchesLiveStatus}
202
+ * instead — this function only decides the unqualified default.
203
+ *
204
+ * - `queued` / `closed` / `crashed` are never active here: queued hasn't
205
+ * started yet, closed/crashed are unconditionally dead. All three stay
206
+ * reachable through their explicit filter.
207
+ * - A cloud row carries no local pid at all — it is active on the
208
+ * provider's own word (`cloudProvider` + `cloudTaskId`), never a
209
+ * fabricated pid.
210
+ * - Every other row is a real OS process (terminal/tmux/headless/team). It
211
+ * is active only when the row names the machine it runs on, carries a
212
+ * positive pid, AND has POSITIVELY verified that pid is alive
213
+ * (`pidAlive === true` — not merely "not known dead"). A live orphaned
214
+ * row, or a live-but-stuck abandoned row, still qualifies this way since
215
+ * both carry a genuinely alive pid; unknown liveness (an older peer's
216
+ * row, or a pid that could never be resolved) does not.
197
217
  */
198
218
  export declare function isRunningLiveSession(s: ActiveSession): boolean;
199
219
  /**
@@ -342,10 +342,9 @@ export function formatActiveRowDescription(s) {
342
342
  if (todo)
343
343
  parts.push(todo);
344
344
  // Latest-turn snippet — avoid repeating label/topic when already used as identity.
345
- if (s.context === 'cloud') {
346
- pushText(s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`);
347
- }
348
- else if (s.preview) {
345
+ // A cloud row with no preview yet says nothing here; its provider + task id
346
+ // ride the locator badge instead (RUSH-2336), not duplicated on this line.
347
+ if (s.preview) {
349
348
  pushText(s.preview);
350
349
  }
351
350
  else if (!s.label && s.topic) {
@@ -540,18 +539,40 @@ export function isAwaitingUser(s) {
540
539
  return s.status === 'input_required' || s.activity === 'waiting_input';
541
540
  }
542
541
  /**
543
- * Whether a row belongs in an unqualified `--active` view.
542
+ * Whether a row belongs in an unqualified `--active` view (RUSH-2336) — the
543
+ * ONE canonical selector every bare-active surface shares: the CLI's grouped
544
+ * table and `--json` (`renderActiveSessions`), the interactive browser's
545
+ * `--active` filter ({@link applyFilters} in `sessions-browser.ts`), `focus`'s
546
+ * attach gate (`isAttachableLiveSession`), and the menubar snapshot
547
+ * ({@link computeMenubarSnapshot}).
544
548
  *
545
- * The live registry deliberately retains terminally-dead rows long enough for
546
- * `--closed` / `--crashed` and recovery to find them. Presence in that registry
547
- * therefore is not, by itself, evidence that a session is still active.
548
- * Explicit lifecycle filters bypass this predicate and select those retained
549
- * rows through {@link matchesLiveStatus} instead.
549
+ * The live registry deliberately retains terminally-dead AND not-yet-started
550
+ * rows long enough for `--closed` / `--crashed` / `--queued` and recovery to
551
+ * find them. Presence in that registry therefore is not, by itself, evidence
552
+ * that a session is CURRENTLY active. Explicit lifecycle filters bypass this
553
+ * predicate and select those retained rows through {@link matchesLiveStatus}
554
+ * instead — this function only decides the unqualified default.
555
+ *
556
+ * - `queued` / `closed` / `crashed` are never active here: queued hasn't
557
+ * started yet, closed/crashed are unconditionally dead. All three stay
558
+ * reachable through their explicit filter.
559
+ * - A cloud row carries no local pid at all — it is active on the
560
+ * provider's own word (`cloudProvider` + `cloudTaskId`), never a
561
+ * fabricated pid.
562
+ * - Every other row is a real OS process (terminal/tmux/headless/team). It
563
+ * is active only when the row names the machine it runs on, carries a
564
+ * positive pid, AND has POSITIVELY verified that pid is alive
565
+ * (`pidAlive === true` — not merely "not known dead"). A live orphaned
566
+ * row, or a live-but-stuck abandoned row, still qualifies this way since
567
+ * both carry a genuinely alive pid; unknown liveness (an older peer's
568
+ * row, or a pid that could never be resolved) does not.
550
569
  */
551
570
  export function isRunningLiveSession(s) {
552
- if (s.status === 'closed' || s.status === 'crashed')
571
+ if (s.status === 'queued' || s.status === 'closed' || s.status === 'crashed')
553
572
  return false;
554
- return s.pidAlive !== false;
573
+ if (s.context === 'cloud')
574
+ return Boolean(s.cloudProvider) && Boolean(s.cloudTaskId);
575
+ return Boolean(s.machine) && typeof s.pid === 'number' && s.pid > 0 && s.pidAlive === true;
555
576
  }
556
577
  /** Width of the live status column — `crashed` is the longest word it renders. */
557
578
  const LIVE_STATUS_W = 8;
@@ -640,6 +661,11 @@ function signalBadges(s) {
640
661
  * (a real `tmux attach -t <session:window>` target) over the raw `%pane` id. For
641
662
  * a local Ghostty session we know the tab, show `tab N`. Local, unlocatable
642
663
  * sessions add nothing (the common case).
664
+ *
665
+ * RUSH-2336: every row also carries its raw process/provider handle — a
666
+ * `machine:pid` for a real OS process (terminal/tmux/headless/team), or
667
+ * `provider · taskId` for a cloud row with no local pid — so the human view
668
+ * exposes the same locator `--json` now guarantees on every active row.
643
669
  */
644
670
  function locatorBadge(s) {
645
671
  const p = s.provenance;
@@ -662,6 +688,14 @@ function locatorBadge(s) {
662
688
  }
663
689
  if (s.ghosttyTab != null)
664
690
  parts.push(chalk.green(`tab ${s.ghosttyTab}`));
691
+ if (s.context === 'cloud') {
692
+ const bits = [s.cloudProvider, s.cloudTaskId ? s.cloudTaskId.slice(0, 12) : undefined].filter(Boolean);
693
+ if (bits.length)
694
+ parts.push(chalk.dim(bits.join(' · ')));
695
+ }
696
+ else if (typeof s.pid === 'number' && s.pid > 0) {
697
+ parts.push(chalk.dim(`${s.machine ? `${s.machine}:` : ''}pid ${s.pid}`));
698
+ }
665
699
  return parts.join(' ');
666
700
  }
667
701
  /**
@@ -1395,10 +1395,17 @@ export function registerTeamsCommands(program) {
1395
1395
  catch (err) {
1396
1396
  dieFriction('teams', 'ssh-target-unresolvable', `Can't resolve an ssh target for "${host.name}": ${err.message}`);
1397
1397
  }
1398
- // Ensure agents-cli is present + version-matched on the host; surface
1399
- // (not fail on) an agent-not-installed warning, like the run --host path.
1398
+ // Ensure agents-cli is present + the pin is installed on the host.
1399
+ // Bare agent names still only warn; a concrete pin fails loud so
1400
+ // `teams add --device` never stages a teammate the box cannot run
1401
+ // (RUSH-2313). parseTeammate already resolves @latest/etc. to a concrete
1402
+ // version when one is pinned on the teammate spec.
1400
1403
  try {
1401
- const { warnings } = ensureHostReady(host, { agent: parseTeammate(teammate).agent });
1404
+ const parsed = parseTeammate(teammate);
1405
+ const { warnings } = ensureHostReady(host, {
1406
+ agent: parsed.agent,
1407
+ version: parsed.version ?? undefined,
1408
+ });
1402
1409
  for (const w of warnings)
1403
1410
  process.stderr.write(chalk.yellow(`[teams] warning: ${w}\n`));
1404
1411
  }
@@ -1417,7 +1424,9 @@ export function registerTeamsCommands(program) {
1417
1424
  effectiveRepo = (await getRemoteUrl(process.cwd())) ?? '';
1418
1425
  }
1419
1426
  try {
1420
- hostRepoPath = ensureRemoteRepo(hostTarget, effectiveRepo, team);
1427
+ hostRepoPath = ensureRemoteRepo(hostTarget, effectiveRepo, team, {
1428
+ extraSshArgs: host.identityFile ? ['-i', host.identityFile, '-o', 'IdentitiesOnly=yes'] : [],
1429
+ });
1421
1430
  }
1422
1431
  catch (err) {
1423
1432
  dieFriction('teams', 'repo-provision-failed', `Couldn't provision the repo on "${host.name}": ${err.message}\n` +
@@ -2018,11 +2027,12 @@ export function registerTeamsCommands(program) {
2018
2027
  try {
2019
2028
  if (agent.hostName && agent.hostTarget && agent.repoPath) {
2020
2029
  // Distributed teammate: guard + remove the worktree ON THE HOST.
2021
- if (remoteWorktreeDirty(agent.hostTarget, agent.worktreePath)) {
2030
+ const ssh = { extraSshArgs: agent.hostIdentityFile ? ['-i', agent.hostIdentityFile, '-o', 'IdentitiesOnly=yes'] : [] };
2031
+ if (remoteWorktreeDirty(agent.hostTarget, agent.worktreePath, ssh)) {
2022
2032
  worktreeKept = true;
2023
2033
  }
2024
2034
  else {
2025
- removeRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName);
2035
+ removeRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName, true, ssh);
2026
2036
  }
2027
2037
  }
2028
2038
  else {
@@ -2231,11 +2241,12 @@ export function registerTeamsCommands(program) {
2231
2241
  try {
2232
2242
  if (agent.hostName && agent.hostTarget && agent.repoPath) {
2233
2243
  // Distributed teammate: guard + remove the worktree ON THE HOST.
2234
- if (remoteWorktreeDirty(agent.hostTarget, agent.worktreePath)) {
2244
+ const ssh = { extraSshArgs: agent.hostIdentityFile ? ['-i', agent.hostIdentityFile, '-o', 'IdentitiesOnly=yes'] : [] };
2245
+ if (remoteWorktreeDirty(agent.hostTarget, agent.worktreePath, ssh)) {
2235
2246
  keptWorktrees.push(agent.worktreeName);
2236
2247
  }
2237
2248
  else {
2238
- removeRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName);
2249
+ removeRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName, true, ssh);
2239
2250
  }
2240
2251
  }
2241
2252
  else {