@phnx-labs/agents-cli 1.20.68 → 1.20.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 (48) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +11 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/computer.d.ts +26 -0
  5. package/dist/commands/computer.js +173 -149
  6. package/dist/commands/exec.d.ts +18 -0
  7. package/dist/commands/exec.js +88 -6
  8. package/dist/commands/fork.d.ts +9 -0
  9. package/dist/commands/fork.js +67 -0
  10. package/dist/commands/run-account-picker.d.ts +14 -0
  11. package/dist/commands/run-account-picker.js +131 -0
  12. package/dist/commands/setup-browser.d.ts +18 -0
  13. package/dist/commands/setup-browser.js +142 -0
  14. package/dist/commands/setup-computer.d.ts +19 -0
  15. package/dist/commands/setup-computer.js +133 -0
  16. package/dist/commands/setup-share.d.ts +17 -0
  17. package/dist/commands/setup-share.js +85 -0
  18. package/dist/commands/setup.d.ts +1 -1
  19. package/dist/commands/setup.js +58 -1
  20. package/dist/commands/share.d.ts +15 -0
  21. package/dist/commands/share.js +10 -4
  22. package/dist/commands/ssh.js +164 -0
  23. package/dist/commands/view.d.ts +3 -14
  24. package/dist/commands/view.js +27 -28
  25. package/dist/index.js +2 -1
  26. package/dist/lib/agents.d.ts +10 -0
  27. package/dist/lib/agents.js +32 -0
  28. package/dist/lib/auth-health.d.ts +103 -0
  29. package/dist/lib/auth-health.js +232 -0
  30. package/dist/lib/browser/chrome.d.ts +9 -0
  31. package/dist/lib/browser/chrome.js +22 -0
  32. package/dist/lib/computer/download.d.ts +51 -0
  33. package/dist/lib/computer/download.js +145 -0
  34. package/dist/lib/computer-rpc.js +9 -3
  35. package/dist/lib/devices/connect.d.ts +15 -0
  36. package/dist/lib/devices/connect.js +17 -5
  37. package/dist/lib/devices/terminfo.d.ts +44 -0
  38. package/dist/lib/devices/terminfo.js +167 -0
  39. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  40. package/dist/lib/rotate.d.ts +12 -8
  41. package/dist/lib/rotate.js +22 -23
  42. package/dist/lib/session/fork.d.ts +32 -0
  43. package/dist/lib/session/fork.js +101 -0
  44. package/dist/lib/startup/command-registry.d.ts +1 -0
  45. package/dist/lib/startup/command-registry.js +2 -0
  46. package/dist/lib/usage.d.ts +23 -0
  47. package/dist/lib/usage.js +84 -0
  48. package/package.json +1 -1
@@ -0,0 +1,145 @@
1
+ /**
2
+ * On-demand download + verification of the macOS `agents computer` helper
3
+ * ("ComputerHelper.app").
4
+ *
5
+ * The helper is a signed + notarized universal `.app` bundle published as a
6
+ * GitHub release asset per tagged CLI version — the same distribution model as
7
+ * the Windows helper (see `lib/ssh-tunnel.ts`). A fresh `npm i -g` machine has
8
+ * no local build, so `agents computer setup` / `agents setup computer` fetch the
9
+ * asset for the running CLI version, verify its sha256 against the published
10
+ * `.sha256`, then verify the code signature (Developer ID Team + notarization)
11
+ * before it is ever copied to /Applications.
12
+ *
13
+ * A `.app` is a directory, so the asset is a zip (`ditto -c -k --keepParent`);
14
+ * we extract it with `ditto -x -k` after the checksum passes.
15
+ */
16
+ import { execFileSync } from 'node:child_process';
17
+ import * as fs from 'node:fs';
18
+ import * as os from 'node:os';
19
+ import * as path from 'node:path';
20
+ import { Readable } from 'node:stream';
21
+ import { pipeline } from 'node:stream/promises';
22
+ import { getCacheDir } from '../state.js';
23
+ import { getCliVersion } from '../version.js';
24
+ import { parseSha256Asset, sha256File } from '../ssh-tunnel.js';
25
+ import { resolveHelperApp } from '../computer-rpc.js';
26
+ /** GitHub repo whose `v<version>` releases carry the helper asset. */
27
+ export const HELPER_RELEASE_REPO = 'phnx-labs/agents-cli';
28
+ /** The zipped `.app` release asset name. */
29
+ export const MAC_HELPER_ASSET = 'ComputerHelper.app.zip';
30
+ /** The bundle directory name once extracted. */
31
+ export const MAC_HELPER_APP_NAME = 'ComputerHelper.app';
32
+ /** Apple Developer ID Team the helper must be signed by (defense in depth on top
33
+ * of `spctl` notarization assessment). "Developer ID Application: Muqit Nawaz". */
34
+ export const EXPECTED_TEAM_ID = '2HTP252L87';
35
+ /** Cache dir for the downloaded helper, one subdir per release tag. */
36
+ export function macHelperCacheDir(version) {
37
+ return path.join(getCacheDir(), 'computer', 'mac-helper', `v${version}`);
38
+ }
39
+ /** Release-asset URLs for the helper zip + its checksum at one `v<version>` tag. */
40
+ export function macHelperAssetUrls(version) {
41
+ const base = `https://github.com/${HELPER_RELEASE_REPO}/releases/download/v${version}`;
42
+ return { zip: `${base}/${MAC_HELPER_ASSET}`, sha256: `${base}/${MAC_HELPER_ASSET}.sha256` };
43
+ }
44
+ /**
45
+ * Verify a helper `.app` bundle is intact, signed by the expected Developer ID
46
+ * Team, and notarized (Gatekeeper-accepted). Throws with an actionable message
47
+ * on any failure — a downloaded bundle is never trusted without this.
48
+ */
49
+ export function verifyMacHelper(appPath) {
50
+ // 1. Structural + signature integrity.
51
+ try {
52
+ execFileSync('/usr/bin/codesign', ['--verify', '--deep', '--strict', appPath], { stdio: 'pipe' });
53
+ }
54
+ catch (e) {
55
+ throw new Error(`code signature invalid for ${appPath}: ${e.message}`);
56
+ }
57
+ // 2. Team identity — must be our Developer ID, not some other valid signer.
58
+ let info = '';
59
+ try {
60
+ info = execFileSync('/usr/bin/codesign', ['-dv', '--verbose=4', appPath], { stdio: 'pipe' }).toString();
61
+ }
62
+ catch (e) {
63
+ // codesign -dv writes to stderr; capture whatever it emitted.
64
+ info = (e.stderr?.toString() ?? '') + (e.stdout?.toString() ?? '');
65
+ if (!info)
66
+ throw new Error(`could not read code signature of ${appPath}: ${e.message}`);
67
+ }
68
+ const team = info.match(/TeamIdentifier=([A-Z0-9]+)/)?.[1];
69
+ if (team !== EXPECTED_TEAM_ID) {
70
+ throw new Error(`helper signed by unexpected Team (${team ?? 'none'}), expected ${EXPECTED_TEAM_ID}. Refusing to install.`);
71
+ }
72
+ // 3. Notarization / Gatekeeper — confirms Apple stapled a notarization ticket.
73
+ try {
74
+ execFileSync('/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose', appPath], { stdio: 'pipe' });
75
+ }
76
+ catch (e) {
77
+ throw new Error(`helper is not notarized / rejected by Gatekeeper: ${e.message}. Refusing to install.`);
78
+ }
79
+ }
80
+ /**
81
+ * Download the helper release asset for `version`, verify sha256, extract the
82
+ * `.app`, and verify its signature. Returns the path to the extracted
83
+ * `ComputerHelper.app`. A missing asset is a hard error naming the exact tag —
84
+ * never a silent fallback to another release.
85
+ */
86
+ export async function downloadMacHelperApp(version) {
87
+ const dir = macHelperCacheDir(version);
88
+ const cachedApp = path.join(dir, MAC_HELPER_APP_NAME);
89
+ if (fs.existsSync(cachedApp)) {
90
+ // Re-verify a cached bundle cheaply; a tampered cache must not be trusted.
91
+ verifyMacHelper(cachedApp);
92
+ return cachedApp;
93
+ }
94
+ const tag = `v${version}`;
95
+ const { zip: zipUrl, sha256: shaUrl } = macHelperAssetUrls(version);
96
+ const missing = (status, url) => new Error(`no ${MAC_HELPER_ASSET} release asset for tag ${tag} (HTTP ${status} on ${url}). ` +
97
+ `The macOS helper ships as a GitHub release asset per tagged CLI version; ` +
98
+ `from a repo checkout you can build it locally instead: ` +
99
+ `bash native/computer-mac/scripts/build.sh release`);
100
+ // Checksum first: it is tiny and 404s fast when the tag has no assets.
101
+ const shaRes = await fetch(shaUrl, { signal: AbortSignal.timeout(30_000) });
102
+ if (!shaRes.ok)
103
+ throw missing(shaRes.status, shaUrl);
104
+ const expected = parseSha256Asset(await shaRes.text());
105
+ console.error(`Downloading ${MAC_HELPER_ASSET} ${tag} from GitHub releases...`);
106
+ const zipRes = await fetch(zipUrl, { signal: AbortSignal.timeout(15 * 60_000) });
107
+ if (!zipRes.ok || !zipRes.body)
108
+ throw missing(zipRes.status, zipUrl);
109
+ fs.mkdirSync(dir, { recursive: true });
110
+ const partial = path.join(dir, `${MAC_HELPER_ASSET}.download`);
111
+ try {
112
+ await pipeline(Readable.fromWeb(zipRes.body), fs.createWriteStream(partial));
113
+ const actual = await sha256File(partial);
114
+ if (actual !== expected) {
115
+ throw new Error(`sha256 mismatch for ${zipUrl}: expected ${expected}, got ${actual}`);
116
+ }
117
+ // Extract the zip (created with `ditto -c -k --keepParent`, so it contains
118
+ // ComputerHelper.app/ at top level) into the version cache dir.
119
+ fs.rmSync(cachedApp, { recursive: true, force: true });
120
+ execFileSync('/usr/bin/ditto', ['-x', '-k', partial, dir], { stdio: 'pipe' });
121
+ if (!fs.existsSync(cachedApp)) {
122
+ throw new Error(`extracted asset did not contain ${MAC_HELPER_APP_NAME}`);
123
+ }
124
+ verifyMacHelper(cachedApp);
125
+ }
126
+ finally {
127
+ fs.rmSync(partial, { force: true });
128
+ }
129
+ return cachedApp;
130
+ }
131
+ /**
132
+ * Resolve the helper `.app` to install from: a local build / bundled copy first
133
+ * (repo checkout), else the checksum + signature-verified release-asset download
134
+ * for the running CLI version. Throws with the tag it checked when neither
135
+ * exists. macOS only.
136
+ */
137
+ export async function ensureMacHelperApp(version = getCliVersion()) {
138
+ if (os.platform() !== 'darwin') {
139
+ throw new Error('The macOS computer helper is only available on macOS.');
140
+ }
141
+ const local = resolveHelperApp();
142
+ if (local)
143
+ return local;
144
+ return downloadMacHelperApp(version);
145
+ }
@@ -163,14 +163,20 @@ export function writeComputerPeers(allowedExecPaths) {
163
163
  }
164
164
  fs.writeFileSync(resolvePeersPath(), JSON.stringify({ allow: allowedExecPaths }, null, 2), { mode: 0o600 });
165
165
  }
166
- // Resolve the helper executable inside the dist .app bundle. Used by the
167
- // stdio fallback and by install-helper to find the source bundle.
166
+ // Resolve the helper executable inside the dist .app bundle. Used by the stdio
167
+ // fallback and by install-helper to find the source bundle. Only TRUSTED sources
168
+ // are listed here: a local checkout build and the bundled tarball copy. The
169
+ // download cache is deliberately NOT a candidate — a cached bundle is
170
+ // user-writable and must only ever be read through downloadMacHelperApp(), which
171
+ // re-verifies its signature + Team ID + notarization on every cache hit
172
+ // (lib/computer/download.ts). Listing the cache here would let a same-user
173
+ // process plant a differently-signed .app that install-helper trusts.
168
174
  export function resolveHelperExec() {
169
175
  const here = path.dirname(fileURLToPath(import.meta.url));
170
176
  const candidates = [
171
177
  // Local build (running from the agents-cli checkout). apps/cli/dist/lib -> repo root (4 up) -> native/computer-mac.
172
178
  path.resolve(here, '..', '..', '..', '..', 'native', 'computer-mac', 'dist', 'ComputerHelper.app', 'Contents', 'MacOS', 'ComputerHelper'),
173
- // Bundled with the npm package (later: CDN download lands here).
179
+ // Bundled with the npm package.
174
180
  path.resolve(here, '..', 'computer-helper', 'ComputerHelper.app', 'Contents', 'MacOS', 'ComputerHelper'),
175
181
  ];
176
182
  for (const c of candidates) {
@@ -43,6 +43,21 @@ export declare function buildSshInvocation(device: DeviceProfile, cmd: string[],
43
43
  args: string[];
44
44
  env: Record<string, string>;
45
45
  };
46
+ /**
47
+ * Build the askpass shim's `#!/bin/sh` body: a script that re-invokes this CLI
48
+ * as `agents ssh __askpass`. The relaunch argv comes from {@link getCliLaunch},
49
+ * never a hand-rolled `[process.execPath, process.argv[1], …]` — on a Bun
50
+ * standalone binary `process.argv[1]` is the *virtual* embedded entry
51
+ * `/$bunfs/root/agents`, which the CLI would then receive as a bogus subcommand
52
+ * (`unknown command '/$bunfs/root/agents'`), print nothing, and hand ssh an
53
+ * empty password. `getCliLaunch` resolves the physical executable so the shim
54
+ * works on both the standalone and JS/dev builds. Every argv element is
55
+ * shell-quoted. Pure (takes the launch as a parameter) so it is unit-testable.
56
+ */
57
+ export declare function buildAskpassShimBody(launch?: {
58
+ command: string;
59
+ args: string[];
60
+ }): string;
46
61
  /**
47
62
  * Write (idempotently) the askpass shim — a tiny executable that re-invokes
48
63
  * this CLI as `agents ssh __askpass`. ssh execs `SSH_ASKPASS` with no usable
@@ -16,6 +16,7 @@
16
16
  import * as fs from 'fs';
17
17
  import * as path from 'path';
18
18
  import { assertValidSshTarget, shellQuote } from '../ssh-exec.js';
19
+ import { getCliLaunch } from '../cli-entry.js';
19
20
  import { encodePwshBase64 } from '../pwsh.js';
20
21
  import { getCacheDir } from '../state.js';
21
22
  import { hostKeyCheckingOpts } from './known-hosts.js';
@@ -92,6 +93,21 @@ export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}) {
92
93
  args.push(remote);
93
94
  return { args, env };
94
95
  }
96
+ /**
97
+ * Build the askpass shim's `#!/bin/sh` body: a script that re-invokes this CLI
98
+ * as `agents ssh __askpass`. The relaunch argv comes from {@link getCliLaunch},
99
+ * never a hand-rolled `[process.execPath, process.argv[1], …]` — on a Bun
100
+ * standalone binary `process.argv[1]` is the *virtual* embedded entry
101
+ * `/$bunfs/root/agents`, which the CLI would then receive as a bogus subcommand
102
+ * (`unknown command '/$bunfs/root/agents'`), print nothing, and hand ssh an
103
+ * empty password. `getCliLaunch` resolves the physical executable so the shim
104
+ * works on both the standalone and JS/dev builds. Every argv element is
105
+ * shell-quoted. Pure (takes the launch as a parameter) so it is unit-testable.
106
+ */
107
+ export function buildAskpassShimBody(launch = getCliLaunch(['ssh', '__askpass'])) {
108
+ const exec = [launch.command, ...launch.args].map(shellQuote).join(' ');
109
+ return `#!/bin/sh\n# Generated by agents-cli — bridges ssh SSH_ASKPASS back into the CLI.\nexec ${exec}\n`;
110
+ }
95
111
  /**
96
112
  * Write (idempotently) the askpass shim — a tiny executable that re-invokes
97
113
  * this CLI as `agents ssh __askpass`. ssh execs `SSH_ASKPASS` with no usable
@@ -102,10 +118,6 @@ export function writeAskpassShim() {
102
118
  const dir = path.join(getCacheDir(), 'devices');
103
119
  fs.mkdirSync(dir, { recursive: true });
104
120
  const shimPath = path.join(dir, 'askpass.sh');
105
- // process.execPath = the node/bun binary; argv[1] = this CLI's entry script.
106
- const node = process.execPath;
107
- const entry = process.argv[1] ?? '';
108
- const body = `#!/bin/sh\n# Generated by agents-cli — bridges ssh SSH_ASKPASS back into the CLI.\nexec ${shellQuote(node)} ${shellQuote(entry)} ssh __askpass\n`;
109
- fs.writeFileSync(shimPath, body, { mode: 0o700 });
121
+ fs.writeFileSync(shimPath, buildAskpassShimBody(), { mode: 0o700 });
110
122
  return shimPath;
111
123
  }
@@ -0,0 +1,44 @@
1
+ import { type DeviceProfile } from './registry.js';
2
+ /**
3
+ * Decide whether an interactive login warrants a terminfo push. Pure so the
4
+ * gating logic is unit-testable without touching ssh or the filesystem.
5
+ *
6
+ * @param interactive true only for a real login (no remote command) on a human tty.
7
+ */
8
+ export declare function shouldSyncTerminfo(params: {
9
+ term?: string;
10
+ shell: DeviceProfile['shell'];
11
+ interactive: boolean;
12
+ }): boolean;
13
+ /**
14
+ * Cache key for a device: the remote **user@host**, not just the host. terminfo
15
+ * is compiled into the *per-user* `~/.terminfo`, so `alice@box` and `bob@box`
16
+ * are distinct sync targets — keying on host alone would let the first user's
17
+ * stamp suppress the second user's (never-installed) sync. Mirrors
18
+ * {@link sshTargetFor}'s user handling; falls back to the device name when the
19
+ * address is unresolved.
20
+ */
21
+ export declare function terminfoHostKey(device: Pick<DeviceProfile, 'user' | 'name'>, addr: string | undefined): string;
22
+ /** True when a fresh successful-sync stamp exists for this host+TERM. */
23
+ export declare function terminfoSynced(host: string, term: string, cacheRoot?: string): boolean;
24
+ /** Record a successful sync so repeat logins skip the push. Best-effort. */
25
+ export declare function markTerminfoSynced(host: string, term: string, cacheRoot?: string): void;
26
+ /** Export the local terminfo source for a TERM, or null if it can't be produced. */
27
+ export declare function localTerminfoSource(term: string): string | null;
28
+ /**
29
+ * Best-effort: ensure `device` has the terminfo entry for the local `$TERM`
30
+ * before an interactive login. Never throws; returns whether a push succeeded
31
+ * (false when skipped, already-cached, or failed). `sshArgs`/`sshEnv` are the
32
+ * SAME host-key + auth options the real login uses, minus the interactive tty —
33
+ * so a password-auth device resolves through the askpass shim without a second
34
+ * human prompt.
35
+ */
36
+ export declare function syncTerminfoToDevice(opts: {
37
+ device: DeviceProfile;
38
+ host: string;
39
+ term: string | undefined;
40
+ /** ssh argv for a non-interactive `tic -x -` exec against this device. */
41
+ sshArgs: string[];
42
+ /** Env overlay for that ssh (askpass wiring for password devices). */
43
+ sshEnv: Record<string, string>;
44
+ }): boolean;
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Terminfo propagation for `agents ssh` interactive logins.
3
+ *
4
+ * Modern terminals (Ghostty, kitty, Alacritty, WezTerm, foot, rio) advertise a
5
+ * custom `TERM` (e.g. `xterm-ghostty`) whose terminfo entry ships with the
6
+ * terminal, not with the remote host's ncurses. SSH into a box that lacks the
7
+ * entry and the session is subtly broken: wrong backspace, missing colors, a
8
+ * garbled clear/alt-screen. Ghostty's own shell integration fixes this for the
9
+ * bare `ssh` command, but `agents ssh` (Tailscale-relayed, spawned directly)
10
+ * bypasses that wrapper — so we handle it here.
11
+ *
12
+ * Strategy: on an interactive POSIX login, if the local `$TERM` is one the
13
+ * remote is unlikely to have, export it locally (`infocmp -x`) and compile it on
14
+ * the remote (`tic -x -`, writes to the user's `~/.terminfo`, no sudo). This is
15
+ * the canonical, terminal-agnostic technique.
16
+ *
17
+ * Two invariants keep it safe and cheap:
18
+ * - **Fail-safe.** Every failure is swallowed. A push that errors, times out,
19
+ * or hits a remote without `tic` leaves the user exactly where they are today
20
+ * — it never blocks or delays the actual login beyond the short push timeout.
21
+ * - **Cached.** A successful sync stamps a local marker keyed by host+TERM, so
22
+ * only the first login to each host pays the one extra round-trip; every
23
+ * repeat login is zero-cost.
24
+ */
25
+ import { spawnSync } from 'child_process';
26
+ import * as fs from 'fs';
27
+ import * as path from 'path';
28
+ import { getCacheDir } from '../state.js';
29
+ /**
30
+ * Terminfo names that ncurses ships essentially everywhere, so pushing them is
31
+ * wasted work. Anything NOT in this set (the exotic terminal entries) is a sync
32
+ * candidate. Kept deliberately conservative: when unsure, we push — `tic` is
33
+ * idempotent and the result is cached.
34
+ */
35
+ const UNIVERSAL_TERMS = new Set([
36
+ 'dumb',
37
+ 'ansi',
38
+ 'vt100',
39
+ 'vt102',
40
+ 'vt220',
41
+ 'linux',
42
+ 'cygwin',
43
+ 'xterm',
44
+ 'xterm-color',
45
+ 'xterm-16color',
46
+ 'xterm-256color',
47
+ 'screen',
48
+ 'screen-256color',
49
+ 'tmux',
50
+ 'tmux-256color',
51
+ 'rxvt',
52
+ 'rxvt-unicode',
53
+ 'rxvt-unicode-256color',
54
+ ]);
55
+ /** How long a successful sync suppresses re-syncing the same host+TERM. */
56
+ const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
57
+ /** Cap on the push so a stalled remote can't delay the login indefinitely. */
58
+ const PUSH_TIMEOUT_MS = 8000;
59
+ /**
60
+ * Decide whether an interactive login warrants a terminfo push. Pure so the
61
+ * gating logic is unit-testable without touching ssh or the filesystem.
62
+ *
63
+ * @param interactive true only for a real login (no remote command) on a human tty.
64
+ */
65
+ export function shouldSyncTerminfo(params) {
66
+ if (!params.interactive)
67
+ return false;
68
+ if (params.shell === 'powershell')
69
+ return false; // Windows console ignores terminfo
70
+ const term = params.term?.trim();
71
+ if (!term)
72
+ return false;
73
+ if (UNIVERSAL_TERMS.has(term))
74
+ return false;
75
+ return true;
76
+ }
77
+ /**
78
+ * Cache key for a device: the remote **user@host**, not just the host. terminfo
79
+ * is compiled into the *per-user* `~/.terminfo`, so `alice@box` and `bob@box`
80
+ * are distinct sync targets — keying on host alone would let the first user's
81
+ * stamp suppress the second user's (never-installed) sync. Mirrors
82
+ * {@link sshTargetFor}'s user handling; falls back to the device name when the
83
+ * address is unresolved.
84
+ */
85
+ export function terminfoHostKey(device, addr) {
86
+ const host = addr ?? device.name;
87
+ return device.user ? `${device.user}@${host}` : host;
88
+ }
89
+ /** Directory holding per-host+TERM sync stamps. `cacheRoot` override is for tests. */
90
+ function stampDir(cacheRoot) {
91
+ return path.join(cacheRoot ?? getCacheDir(), 'devices', 'terminfo');
92
+ }
93
+ /** Filesystem-safe stamp name for a host+TERM pair. */
94
+ function stampFile(host, term, cacheRoot) {
95
+ const safe = `${host}__${term}`.replace(/[^A-Za-z0-9._-]/g, '_');
96
+ return path.join(stampDir(cacheRoot), safe);
97
+ }
98
+ /** True when a fresh successful-sync stamp exists for this host+TERM. */
99
+ export function terminfoSynced(host, term, cacheRoot) {
100
+ try {
101
+ const st = fs.statSync(stampFile(host, term, cacheRoot));
102
+ return Date.now() - st.mtimeMs < CACHE_TTL_MS;
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ }
108
+ /** Record a successful sync so repeat logins skip the push. Best-effort. */
109
+ export function markTerminfoSynced(host, term, cacheRoot) {
110
+ try {
111
+ fs.mkdirSync(stampDir(cacheRoot), { recursive: true });
112
+ fs.writeFileSync(stampFile(host, term, cacheRoot), `${new Date().toISOString()}\n`);
113
+ }
114
+ catch {
115
+ /* a cache write failure just means we re-sync next time — never fatal */
116
+ }
117
+ }
118
+ /** Export the local terminfo source for a TERM, or null if it can't be produced. */
119
+ export function localTerminfoSource(term) {
120
+ try {
121
+ const res = spawnSync('infocmp', ['-x', term], {
122
+ encoding: 'utf8',
123
+ timeout: 4000,
124
+ });
125
+ if (res.status === 0 && res.stdout && res.stdout.trim().length > 0) {
126
+ return res.stdout;
127
+ }
128
+ }
129
+ catch {
130
+ /* infocmp missing or errored — nothing we can push */
131
+ }
132
+ return null;
133
+ }
134
+ /**
135
+ * Best-effort: ensure `device` has the terminfo entry for the local `$TERM`
136
+ * before an interactive login. Never throws; returns whether a push succeeded
137
+ * (false when skipped, already-cached, or failed). `sshArgs`/`sshEnv` are the
138
+ * SAME host-key + auth options the real login uses, minus the interactive tty —
139
+ * so a password-auth device resolves through the askpass shim without a second
140
+ * human prompt.
141
+ */
142
+ export function syncTerminfoToDevice(opts) {
143
+ const term = opts.term?.trim();
144
+ if (!term)
145
+ return false;
146
+ if (terminfoSynced(opts.host, term))
147
+ return false;
148
+ const source = localTerminfoSource(term);
149
+ if (!source)
150
+ return false;
151
+ try {
152
+ const res = spawnSync('ssh', opts.sshArgs, {
153
+ input: source,
154
+ env: { ...process.env, ...opts.sshEnv },
155
+ timeout: PUSH_TIMEOUT_MS,
156
+ stdio: ['pipe', 'ignore', 'ignore'],
157
+ });
158
+ if (res.status === 0) {
159
+ markTerminfoSynced(opts.host, term);
160
+ return true;
161
+ }
162
+ }
163
+ catch {
164
+ /* connection failed / timed out — fail-safe, login proceeds unaffected */
165
+ }
166
+ return false;
167
+ }
@@ -11,6 +11,8 @@ import { type UsageSnapshot } from './usage.js';
11
11
  export interface RotateCandidate {
12
12
  agent: AgentId;
13
13
  version: string;
14
+ accountKey: string | null;
15
+ accountLabel: string;
14
16
  email: string | null;
15
17
  /**
16
18
  * Per-org usage/quota key (e.g. `claude:org=<orgUuid>`) — the unit rate
@@ -21,7 +23,9 @@ export interface RotateCandidate {
21
23
  usageKey: string | null;
22
24
  usageStatus: AccountInfo['usageStatus'];
23
25
  usageSnapshot: UsageSnapshot | null;
24
- authValid: boolean;
26
+ usageError: string | null;
27
+ plan: string | null;
28
+ signedIn: boolean;
25
29
  lastActive: Date | null;
26
30
  }
27
31
  export interface RotateResult {
@@ -58,7 +62,7 @@ export declare function getConfiguredRunStrategy(agent: AgentId, startPath?: str
58
62
  export declare function setGlobalRunStrategy(agent: AgentId, strategy: RunStrategy): void;
59
63
  /**
60
64
  * Whether a specific account can serve a run right now, and — when it can't —
61
- * why. `signed_out` covers no-email / invalid-auth; `rate_limited` and
65
+ * why. `signed_out` covers a missing usable credential; `rate_limited` and
62
66
  * `out_of_credits` name the throttle. Used to pre-warn on a version-pinned
63
67
  * teammate whose account rotation won't route around (a pin IS the target).
64
68
  */
@@ -71,7 +75,7 @@ export type AccountReadiness = {
71
75
  };
72
76
  /**
73
77
  * Pure decision reusing the router's own eligibility gate (`hasUsageAvailable`
74
- * + email/auth, i.e. `isRotationEligible`), so a pre-flight warning can NEVER
78
+ * + canonical signed-in state, i.e. `isRotationEligible`), so a pre-flight warning can NEVER
75
79
  * disagree with what rotation would actually do. The `reason` combines the two
76
80
  * signals `hasUsageAvailable` reads: the live snapshot (session-inclusive
77
81
  * rate-limit) and the coarse cached `usageStatus` (out-of-credits, which a
@@ -100,17 +104,16 @@ export declare function checkRunAccountReadiness(agent: AgentId, version: string
100
104
  * headroom, with no stampede on the lowest-usage one. Stateless — parallel
101
105
  * callers naturally fan out via the random roll.
102
106
  *
103
- * Eligibility: signed in (email present), auth valid, and not currently
107
+ * Eligibility: signed in according to AccountInfo and not currently
104
108
  * rate-limited — no blocking window (session OR weekly) at 100%, matching the
105
109
  * `agents view` badge; or the local cached status is usable when no live
106
110
  * snapshot exists. Note the split: eligibility considers the session window
107
111
  * (a session-maxed account can't run now), but the capacity *weight* above is
108
112
  * driven by weekly headroom so a brief session spike doesn't distort routing.
109
113
  *
110
- * Dedupe: when multiple versions share an email, collapse to one candidate
111
- * per email (the least-recently-active version). Prevents two parallel pods
112
- * from "balancing" to different versions but hitting the same Anthropic
113
- * account and both 429ing.
114
+ * Dedupe: when multiple versions share a usage/account identity, collapse to
115
+ * one candidate (the least-recently-active version). The org-scoped usage key
116
+ * wins over email so same-email personal and Team accounts remain distinct.
114
117
  *
115
118
  * Returns null if no candidate is eligible — callers fall back to the pinned
116
119
  * version so behavior stays predictable.
@@ -122,6 +125,7 @@ export declare function pickBalancedCandidate(candidates: RotateCandidate[]): Ro
122
125
  * usage headroom.
123
126
  */
124
127
  export declare function pickAvailableCandidate(candidates: RotateCandidate[], preferredVersion?: string | null): RotateResult | null;
128
+ export declare function collectRunCandidates(agent: AgentId): Promise<RotateCandidate[]>;
125
129
  /**
126
130
  * Pick a healthy version for `agent` using weighted random by remaining
127
131
  * capacity. See `pickBalancedCandidate` for algorithm details.
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
- import { getAccountInfo } from './agents.js';
9
+ import { accountDisplayLabel, getAccountInfo } from './agents.js';
10
10
  import { readMeta, writeMeta, getHelpersDir } from './state.js';
11
11
  import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
12
12
  import { getProjectRunConfigs } from './run-config.js';
@@ -65,14 +65,10 @@ export function setGlobalRunStrategy(agent, strategy) {
65
65
  writeMeta(meta);
66
66
  }
67
67
  function isRotationEligible(candidate) {
68
- return !!candidate.email
69
- && candidate.authValid
70
- && hasUsageAvailable(candidate);
68
+ return candidate.signedIn && hasUsageAvailable(candidate);
71
69
  }
72
70
  function isAvailableEligible(candidate) {
73
- return !!candidate.email
74
- && candidate.authValid
75
- && hasUsageAvailable(candidate);
71
+ return isRotationEligible(candidate);
76
72
  }
77
73
  function hasUsageAvailable(candidate) {
78
74
  const snapshot = candidate.usageSnapshot;
@@ -95,7 +91,7 @@ function hasUsageAvailable(candidate) {
95
91
  }
96
92
  /**
97
93
  * Pure decision reusing the router's own eligibility gate (`hasUsageAvailable`
98
- * + email/auth, i.e. `isRotationEligible`), so a pre-flight warning can NEVER
94
+ * + canonical signed-in state, i.e. `isRotationEligible`), so a pre-flight warning can NEVER
99
95
  * disagree with what rotation would actually do. The `reason` combines the two
100
96
  * signals `hasUsageAvailable` reads: the live snapshot (session-inclusive
101
97
  * rate-limit) and the coarse cached `usageStatus` (out-of-credits, which a
@@ -104,7 +100,7 @@ function hasUsageAvailable(candidate) {
104
100
  * reported while the account is actually serving requests.
105
101
  */
106
102
  export function readinessFromCandidate(candidate) {
107
- if (!candidate.email || !candidate.authValid) {
103
+ if (!candidate.signedIn) {
108
104
  return { ready: false, reason: 'signed_out', email: candidate.email };
109
105
  }
110
106
  if (hasUsageAvailable(candidate)) {
@@ -162,7 +158,7 @@ function compareCandidates(a, b) {
162
158
  * key; fall back to email only when no usage identity is available.
163
159
  */
164
160
  function candidateIdentity(c) {
165
- return c.usageKey ?? c.email;
161
+ return c.usageKey ?? c.accountKey ?? c.email ?? `${c.agent}@${c.version}`;
166
162
  }
167
163
  function dedupeAndSortCandidates(candidates) {
168
164
  const byIdentity = new Map();
@@ -189,17 +185,16 @@ function dedupeAndSortCandidates(candidates) {
189
185
  * headroom, with no stampede on the lowest-usage one. Stateless — parallel
190
186
  * callers naturally fan out via the random roll.
191
187
  *
192
- * Eligibility: signed in (email present), auth valid, and not currently
188
+ * Eligibility: signed in according to AccountInfo and not currently
193
189
  * rate-limited — no blocking window (session OR weekly) at 100%, matching the
194
190
  * `agents view` badge; or the local cached status is usable when no live
195
191
  * snapshot exists. Note the split: eligibility considers the session window
196
192
  * (a session-maxed account can't run now), but the capacity *weight* above is
197
193
  * driven by weekly headroom so a brief session spike doesn't distort routing.
198
194
  *
199
- * Dedupe: when multiple versions share an email, collapse to one candidate
200
- * per email (the least-recently-active version). Prevents two parallel pods
201
- * from "balancing" to different versions but hitting the same Anthropic
202
- * account and both 429ing.
195
+ * Dedupe: when multiple versions share a usage/account identity, collapse to
196
+ * one candidate (the least-recently-active version). The org-scoped usage key
197
+ * wins over email so same-email personal and Team accounts remain distinct.
203
198
  *
204
199
  * Returns null if no candidate is eligible — callers fall back to the pinned
205
200
  * version so behavior stays predictable.
@@ -278,12 +273,11 @@ export function pickAvailableCandidate(candidates, preferredVersion) {
278
273
  : undefined;
279
274
  return { picked: preferred ?? sorted[0], healthy: sorted, excluded };
280
275
  }
281
- async function collectRunCandidates(agent) {
276
+ export async function collectRunCandidates(agent) {
282
277
  const versions = listInstalledVersions(agent);
283
278
  const rows = await Promise.all(versions.map(async (version) => {
284
279
  const home = getVersionHomePath(agent, version);
285
280
  const info = await getAccountInfo(agent, home);
286
- // `info.email` (from .claude.json's oauthAccount) is the auth heuristic.
287
281
  // We used to additionally call isClaudeAuthValid(home), which reads
288
282
  // "Claude Code-credentials-<hash>" from the system keychain. That item is
289
283
  // written by Claude Code itself with its own process in the ACL, so our
@@ -291,15 +285,17 @@ async function collectRunCandidates(agent) {
291
285
  // one per installed version, every time `agents run` cold-starts. If
292
286
  // claude's stored token has actually expired, the spawned agent detects
293
287
  // it at its own startup and re-auths; that's the correct UX.
294
- const authValid = info.email != null;
295
288
  return {
296
289
  agent,
297
290
  version,
298
291
  home,
299
292
  info,
293
+ accountKey: info.accountKey,
294
+ accountLabel: accountDisplayLabel(info),
300
295
  email: info.email,
301
296
  usageStatus: info.usageStatus,
302
- authValid,
297
+ plan: info.plan,
298
+ signedIn: info.signedIn,
303
299
  lastActive: info.lastActive,
304
300
  };
305
301
  }));
@@ -311,10 +307,13 @@ async function collectRunCandidates(agent) {
311
307
  })));
312
308
  return rows.map(({ home: _home, info, ...candidate }) => {
313
309
  const usageKey = getUsageLookupKey(info);
314
- const usageSnapshot = usageKey
315
- ? usageByKey.get(usageKey)?.snapshot ?? null
316
- : null;
317
- return { ...candidate, usageKey, usageSnapshot };
310
+ const usage = usageKey ? usageByKey.get(usageKey) : undefined;
311
+ return {
312
+ ...candidate,
313
+ usageKey,
314
+ usageSnapshot: usage?.snapshot ?? null,
315
+ usageError: usage?.error ?? null,
316
+ };
318
317
  });
319
318
  }
320
319
  /**