@phnx-labs/agents-cli 1.22.52 → 1.22.54

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 (180) hide show
  1. package/CHANGELOG.md +336 -0
  2. package/README.md +42 -9
  3. package/dist/bootstrap.js +55 -154
  4. package/dist/cli/command-registry.d.ts +5 -0
  5. package/dist/cli/command-registry.js +8 -1
  6. package/dist/commands/accounts.js +220 -174
  7. package/dist/commands/apply.js +6 -3
  8. package/dist/commands/auth-mint.d.ts +8 -0
  9. package/dist/commands/auth-mint.js +96 -0
  10. package/dist/commands/auth.js +5 -1
  11. package/dist/commands/browser.js +1 -1
  12. package/dist/commands/cost.js +8 -2
  13. package/dist/commands/daemon.js +2 -2
  14. package/dist/commands/doctor.js +6 -1
  15. package/dist/commands/exec.js +26 -17
  16. package/dist/commands/fleet-capture.js +7 -0
  17. package/dist/commands/focus.d.ts +1 -0
  18. package/dist/commands/focus.js +4 -2
  19. package/dist/commands/go.d.ts +5 -4
  20. package/dist/commands/go.js +8 -7
  21. package/dist/commands/insights.js +9 -0
  22. package/dist/commands/monitors.js +85 -30
  23. package/dist/commands/output.js +8 -2
  24. package/dist/commands/repo.js +18 -0
  25. package/dist/commands/secrets.js +33 -14
  26. package/dist/commands/sessions-inject.js +8 -3
  27. package/dist/commands/sessions-picker.js +2 -1
  28. package/dist/commands/sessions.d.ts +20 -12
  29. package/dist/commands/sessions.js +94 -40
  30. package/dist/commands/setup-accounts.d.ts +8 -0
  31. package/dist/commands/setup-accounts.js +47 -0
  32. package/dist/commands/setup.d.ts +1 -1
  33. package/dist/commands/setup.js +11 -2
  34. package/dist/commands/share.d.ts +52 -3
  35. package/dist/commands/share.js +262 -18
  36. package/dist/commands/ssh.d.ts +7 -0
  37. package/dist/commands/ssh.js +53 -14
  38. package/dist/commands/status.js +14 -0
  39. package/dist/commands/sync.js +44 -0
  40. package/dist/commands/view.d.ts +3 -1
  41. package/dist/commands/view.js +5 -4
  42. package/dist/lib/account-registry.d.ts +15 -5
  43. package/dist/lib/account-registry.js +165 -53
  44. package/dist/lib/accounting/rotate.d.ts +20 -6
  45. package/dist/lib/accounting/rotate.js +38 -7
  46. package/dist/lib/accounting/usage.d.ts +37 -1
  47. package/dist/lib/accounting/usage.js +71 -6
  48. package/dist/lib/agent-spec/agents.d.ts +5 -2
  49. package/dist/lib/agent-spec/agents.js +25 -7
  50. package/dist/lib/analytics/mix-commands.js +12 -6
  51. package/dist/lib/answer-router.js +2 -1
  52. package/dist/lib/auth-mint.d.ts +150 -0
  53. package/dist/lib/auth-mint.js +434 -0
  54. package/dist/lib/browser/profiles.d.ts +18 -0
  55. package/dist/lib/browser/profiles.js +26 -1
  56. package/dist/lib/browser/registry.d.ts +44 -14
  57. package/dist/lib/browser/registry.js +141 -45
  58. package/dist/lib/browser/remote-control.d.ts +9 -7
  59. package/dist/lib/browser/remote-control.js +9 -7
  60. package/dist/lib/claude-account-token.d.ts +10 -0
  61. package/dist/lib/claude-account-token.js +14 -4
  62. package/dist/lib/config-drift.d.ts +37 -0
  63. package/dist/lib/config-drift.js +72 -0
  64. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  65. package/dist/lib/daemon/auth-sync-service.js +34 -0
  66. package/dist/lib/daemon/daemon.js +30 -4
  67. package/dist/lib/daemon/runner.js +10 -2
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/device-config.d.ts +3 -3
  71. package/dist/lib/device-config.js +8 -7
  72. package/dist/lib/devices/config-migration.js +147 -1
  73. package/dist/lib/devices/connect.d.ts +26 -0
  74. package/dist/lib/devices/connect.js +48 -1
  75. package/dist/lib/devices/device-docs.d.ts +35 -0
  76. package/dist/lib/devices/device-docs.js +163 -0
  77. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  78. package/dist/lib/devices/discovery-policy.js +31 -21
  79. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  80. package/dist/lib/devices/doctor-findings.js +19 -1
  81. package/dist/lib/devices/registry.d.ts +11 -5
  82. package/dist/lib/devices/registry.js +46 -18
  83. package/dist/lib/exec.d.ts +88 -30
  84. package/dist/lib/exec.js +138 -34
  85. package/dist/lib/feed/feed.d.ts +10 -2
  86. package/dist/lib/feed/feed.js +35 -2
  87. package/dist/lib/feed-broadcast.js +1 -1
  88. package/dist/lib/fleet/apply.d.ts +11 -0
  89. package/dist/lib/fleet/apply.js +23 -3
  90. package/dist/lib/fleet/auth-sync.js +5 -3
  91. package/dist/lib/help.d.ts +9 -0
  92. package/dist/lib/help.js +29 -1
  93. package/dist/lib/hosts/dispatch.d.ts +4 -3
  94. package/dist/lib/hosts/dispatch.js +12 -8
  95. package/dist/lib/hosts/passthrough.d.ts +1 -10
  96. package/dist/lib/hosts/passthrough.js +1 -13
  97. package/dist/lib/hosts/providers/local.d.ts +9 -3
  98. package/dist/lib/hosts/providers/local.js +23 -12
  99. package/dist/lib/hosts/reconnect.d.ts +7 -4
  100. package/dist/lib/hosts/reconnect.js +29 -25
  101. package/dist/lib/hosts/registry.js +4 -1
  102. package/dist/lib/hosts/remote-os.js +3 -1
  103. package/dist/lib/installations/versions.js +9 -1
  104. package/dist/lib/linux-userns.d.ts +58 -0
  105. package/dist/lib/linux-userns.js +116 -0
  106. package/dist/lib/memory.d.ts +26 -0
  107. package/dist/lib/memory.js +80 -1
  108. package/dist/lib/monitors/config.d.ts +11 -0
  109. package/dist/lib/monitors/config.js +8 -0
  110. package/dist/lib/monitors/engine.js +8 -1
  111. package/dist/lib/monitors/state.d.ts +37 -1
  112. package/dist/lib/monitors/state.js +79 -4
  113. package/dist/lib/permissions-registry.d.ts +2 -0
  114. package/dist/lib/permissions-registry.js +116 -14
  115. package/dist/lib/permissions.d.ts +5 -3
  116. package/dist/lib/permissions.js +25 -27
  117. package/dist/lib/profiles.d.ts +8 -7
  118. package/dist/lib/profiles.js +12 -0
  119. package/dist/lib/project-key.d.ts +9 -0
  120. package/dist/lib/project-key.js +11 -0
  121. package/dist/lib/secrets/bundles.d.ts +35 -0
  122. package/dist/lib/secrets/bundles.js +78 -1
  123. package/dist/lib/secrets/push.d.ts +3 -8
  124. package/dist/lib/secrets/push.js +18 -14
  125. package/dist/lib/secrets/remote.d.ts +9 -18
  126. package/dist/lib/secrets/remote.js +11 -26
  127. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  128. package/dist/lib/secrets/reserved-sync.js +129 -0
  129. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  130. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  131. package/dist/lib/self-heal/registry.js +4 -0
  132. package/dist/lib/self-heal/types.d.ts +1 -1
  133. package/dist/lib/session/active.d.ts +10 -1
  134. package/dist/lib/session/active.js +8 -5
  135. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  136. package/dist/lib/session/actor-sidecar.js +2 -0
  137. package/dist/lib/session/db.d.ts +39 -4
  138. package/dist/lib/session/db.js +168 -31
  139. package/dist/lib/session/discover.d.ts +32 -4
  140. package/dist/lib/session/discover.js +126 -37
  141. package/dist/lib/session/insights.d.ts +14 -0
  142. package/dist/lib/session/insights.js +25 -2
  143. package/dist/lib/session/linear.js +1 -1
  144. package/dist/lib/session/live-metadata.js +1 -0
  145. package/dist/lib/session/pid-registry.d.ts +7 -0
  146. package/dist/lib/session/prompt.d.ts +15 -0
  147. package/dist/lib/session/prompt.js +21 -0
  148. package/dist/lib/session/shell-programs.d.ts +17 -0
  149. package/dist/lib/session/shell-programs.js +21 -0
  150. package/dist/lib/session/state.js +2 -1
  151. package/dist/lib/session/stream-render.js +2 -1
  152. package/dist/lib/session/tool-calls.js +2 -5
  153. package/dist/lib/session/trajectory-html.js +2 -1
  154. package/dist/lib/session/trajectory.js +3 -12
  155. package/dist/lib/session/types.d.ts +25 -0
  156. package/dist/lib/session/types.js +10 -0
  157. package/dist/lib/share/publish.d.ts +53 -5
  158. package/dist/lib/share/publish.js +99 -17
  159. package/dist/lib/share/worker-template.js +594 -64
  160. package/dist/lib/startup/root-command.js +2 -1
  161. package/dist/lib/state.d.ts +24 -0
  162. package/dist/lib/state.js +318 -54
  163. package/dist/lib/sync-status.d.ts +4 -0
  164. package/dist/lib/sync-status.js +3 -0
  165. package/dist/lib/terminal/resolve.d.ts +7 -0
  166. package/dist/lib/terminal/resolve.js +41 -2
  167. package/dist/lib/traces/classify.js +24 -19
  168. package/dist/lib/traces/insights.d.ts +67 -0
  169. package/dist/lib/traces/insights.js +178 -0
  170. package/dist/lib/traces/phenotype.d.ts +67 -0
  171. package/dist/lib/traces/phenotype.js +437 -0
  172. package/dist/lib/traces/segments.d.ts +133 -0
  173. package/dist/lib/traces/segments.js +301 -0
  174. package/dist/lib/traces/sync.d.ts +33 -0
  175. package/dist/lib/traces/sync.js +11 -2
  176. package/dist/lib/types.d.ts +47 -1
  177. package/dist/lib/usage-refresh.js +2 -1
  178. package/dist/lib/view-types.d.ts +2 -0
  179. package/dist/lib/watchdog/runner.js +18 -4
  180. package/package.json +2 -1
@@ -0,0 +1,434 @@
1
+ import { resolveAgentName } from './agents.js';
2
+ import { AUTH_BUNDLE, claudeAccountTokenKey, isValidClaudeSetupToken, readClaudeAccountEmail, } from './claude-account-token.js';
3
+ import { addAccount, findAccount, readAccountRegistry, setAccountSecret } from './account-registry.js';
4
+ import { bundleBackend, bundleExists, keychainRef, readBundle, rotateBundleSecret, writeBundleWithItems, } from './secrets/bundles.js';
5
+ import { secretsKeychainItem } from './secrets/index.js';
6
+ import { getBinaryPath, getGlobalDefault, getVersionHomePath, listInstalledVersions } from './installations/versions.js';
7
+ import { shellQuote } from './ssh-exec.js';
8
+ import { defaultPtyDriver, } from './fleet/remote-login.js';
9
+ import { showUrl } from './open-url.js';
10
+ import { loadDevices } from './devices/registry.js';
11
+ import { isSelfHost } from './devices/self-host.js';
12
+ import { pushBundleToHost } from './secrets/push.js';
13
+ import { assertCredentialTransportHostPinned, resolveHostSshTarget } from './secrets/remote.js';
14
+ import { resolveRemoteOsSync } from './hosts/remote-os.js';
15
+ /** Well-formed Claude setup-token as it appears inside a TTY blob. */
16
+ export const CLAUDE_SETUP_TOKEN_CAPTURE_RE = /sk-ant-oat01-[A-Za-z0-9_-]+/;
17
+ const ACCOUNT_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
18
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
19
+ /**
20
+ * Harnesses that expose an interactive setup-token mint. Native device-code
21
+ * login (codex/droid/kimi/grok) stays on `agents fleet login`; API keys stay
22
+ * on `agents accounts add`. Adding a harness here without a real mint command
23
+ * is a lying table — do not.
24
+ */
25
+ export const MINT_FLOWS = {
26
+ claude: {
27
+ harness: 'claude',
28
+ provider: 'anthropic',
29
+ auth: 'setup-token',
30
+ mintArgs: ['setup-token'],
31
+ // Authorize URL printed by `claude setup-token` before it waits for a code.
32
+ verificationUrlRegex: /(https:\/\/[^\s"'<>]+)/i,
33
+ tokenCapture: CLAUDE_SETUP_TOKEN_CAPTURE_RE,
34
+ },
35
+ };
36
+ export function listMintableHarnesses() {
37
+ return Object.keys(MINT_FLOWS);
38
+ }
39
+ export function getMintFlow(harnessRaw) {
40
+ const harness = resolveAgentName(harnessRaw);
41
+ if (!harness) {
42
+ throw new Error(`Unknown harness '${harnessRaw}'. Interactive setup-token mint is implemented for: ${listMintableHarnesses().join(', ')}.`);
43
+ }
44
+ const flow = MINT_FLOWS[harness];
45
+ if (flow)
46
+ return flow;
47
+ throw new Error(unmintableMessage(harness));
48
+ }
49
+ export function unmintableMessage(harness) {
50
+ return [
51
+ `Cannot mint a setup-token for '${harness}'.`,
52
+ `Interactive setup-token mint is implemented for: ${listMintableHarnesses().join(', ')}.`,
53
+ `Device-code native login is \`agents fleet login --agent ${harness}\`.`,
54
+ `API-key / pasted-token accounts use \`agents accounts add <name> --provider <provider> --auth api-key\`.`,
55
+ ].join(' ');
56
+ }
57
+ /** Strip CSI / Fe ANSI so a #1767 TTY blob can be scanned for a real token. */
58
+ export function stripAnsi(text) {
59
+ return text.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '');
60
+ }
61
+ /**
62
+ * Pull a single well-formed Claude setup-token out of a (possibly ANSI-wrapped)
63
+ * screen. Returns null when none is present. Two distinct tokens fail loud —
64
+ * guessing which one to seed is how a banner fragment becomes an auth header.
65
+ */
66
+ export function extractClaudeSetupToken(screen) {
67
+ const text = stripAnsi(screen);
68
+ const matches = text.match(new RegExp(CLAUDE_SETUP_TOKEN_CAPTURE_RE.source, 'g')) ?? [];
69
+ const unique = [...new Set(matches.filter(isValidClaudeSetupToken))];
70
+ if (unique.length === 0)
71
+ return null;
72
+ if (unique.length > 1) {
73
+ throw new Error(`Screen contained ${unique.length} distinct setup-tokens; refusing to guess. Re-run mint and capture a single sk-ant-oat01- token.`);
74
+ }
75
+ return unique[0];
76
+ }
77
+ /** First https URL on the screen, trailing punctuation stripped. */
78
+ export function extractMintUrl(screen, flow) {
79
+ const text = stripAnsi(screen);
80
+ const m = text.match(flow.verificationUrlRegex);
81
+ if (!m)
82
+ return undefined;
83
+ const raw = (m[1] ?? m[0]).trim();
84
+ return raw.replace(/[.,;:]+$/, '') || undefined;
85
+ }
86
+ export function isEmail(value) {
87
+ return EMAIL_RE.test(value.trim());
88
+ }
89
+ /** Account-name slug of an email (`ada@example.com` → `ada-at-example.com`). */
90
+ export function accountNameFromEmail(email) {
91
+ const slug = email
92
+ .trim()
93
+ .toLowerCase()
94
+ .replace(/@/g, '-at-')
95
+ .replace(/[^a-z0-9._-]/g, '-');
96
+ if (!ACCOUNT_NAME_RE.test(slug)) {
97
+ throw new Error(`Cannot derive an account name from email '${email}'. Pass --account <name>.`);
98
+ }
99
+ return slug;
100
+ }
101
+ export function assertValidSetupToken(token) {
102
+ const cleaned = token.trim();
103
+ if (!isValidClaudeSetupToken(cleaned)) {
104
+ throw new Error('Not a Claude setup-token (expected a single-line sk-ant-oat01-… value). The #1767 capture stored the TTY banner as the token — that is not a token. Re-run `claude setup-token` and paste only the sk-ant-oat01- line.');
105
+ }
106
+ return cleaned;
107
+ }
108
+ /**
109
+ * Resolve the named account + the email that keys the reserved `auth` bundle.
110
+ * `--account` that looks like an email is the email; a name needs `--email` or
111
+ * a locally signed-in `.claude.json`. Missing email fails loud — we must not
112
+ * fall back to a bare shared key (that is the multi-account mix-up).
113
+ */
114
+ export function resolveMintIdentity(input) {
115
+ const accountRaw = input.account?.trim();
116
+ const emailRaw = input.email?.trim();
117
+ const fromHome = readClaudeAccountEmail(input.home);
118
+ let email;
119
+ let accountName;
120
+ if (emailRaw) {
121
+ if (!isEmail(emailRaw))
122
+ throw new Error(`--email '${emailRaw}' is not an email address.`);
123
+ email = emailRaw;
124
+ }
125
+ if (accountRaw) {
126
+ if (isEmail(accountRaw)) {
127
+ if (email && email.toLowerCase() !== accountRaw.toLowerCase()) {
128
+ throw new Error(`--account '${accountRaw}' and --email '${email}' name different emails.`);
129
+ }
130
+ email = accountRaw;
131
+ accountName = accountNameFromEmail(accountRaw);
132
+ }
133
+ else {
134
+ if (!ACCOUNT_NAME_RE.test(accountRaw)) {
135
+ throw new Error(`Account name '${accountRaw}' must start with a letter or number and contain only letters, numbers, dot, underscore, or dash.`);
136
+ }
137
+ accountName = accountRaw;
138
+ }
139
+ }
140
+ if (!email)
141
+ email = fromHome ?? undefined;
142
+ if (!email) {
143
+ throw new Error("Cannot key a per-account setup-token without an email. Pass --account <email>, or --email, or sign in locally first so this version home's .claude.json has oauthAccount.emailAddress.");
144
+ }
145
+ if (!accountName)
146
+ accountName = accountNameFromEmail(email);
147
+ return { accountName, email };
148
+ }
149
+ /**
150
+ * Write (or rotate) the reserved FILE-BASED `auth` bundle's per-account key.
151
+ * Usage/probe ignores a keychain- or vault-backed bundle of this name, so a
152
+ * wrong backend fails loud instead of looking like a successful mint.
153
+ */
154
+ export function seedReservedAuthToken(email, token) {
155
+ const cleaned = assertValidSetupToken(token);
156
+ const key = claudeAccountTokenKey(email);
157
+ if (bundleExists(AUTH_BUNDLE)) {
158
+ const backend = bundleBackend(AUTH_BUNDLE);
159
+ if (backend !== 'file') {
160
+ throw new Error(`Reserved bundle '${AUTH_BUNDLE}' exists with backend '${backend}', but usage/probe only reads a FILE-backed auth bundle. Recreate it with: agents secrets create ${AUTH_BUNDLE} --backend file --policy never --i-understand --force`);
161
+ }
162
+ const bundle = readBundle(AUTH_BUNDLE);
163
+ if (key in bundle.vars) {
164
+ rotateBundleSecret(bundle, key, { newValue: cleaned, meta: { type: 'token' } });
165
+ return { key };
166
+ }
167
+ const item = secretsKeychainItem(AUTH_BUNDLE, key);
168
+ bundle.vars[key] = keychainRef(key);
169
+ if (!bundle.meta)
170
+ bundle.meta = {};
171
+ bundle.meta[key] = { type: 'token' };
172
+ writeBundleWithItems(bundle, new Map([[item, cleaned]]));
173
+ return { key };
174
+ }
175
+ const item = secretsKeychainItem(AUTH_BUNDLE, key);
176
+ const bundle = {
177
+ name: AUTH_BUNDLE,
178
+ backend: 'file',
179
+ policy: 'never',
180
+ description: 'Reserved per-account Claude setup-tokens for unattended usage/probe (never copy native OAuth).',
181
+ vars: { [key]: keychainRef(key) },
182
+ meta: { [key]: { type: 'token' } },
183
+ };
184
+ writeBundleWithItems(bundle, new Map([[item, cleaned]]));
185
+ return { key };
186
+ }
187
+ /**
188
+ * Create or rotate the named provider account that `agents run --account` and
189
+ * `agents accounts sync` consume. Existing account of a different kind fails
190
+ * loud rather than silently overwriting an API key with a setup-token.
191
+ */
192
+ export function seedNamedAccount(name, token, flow) {
193
+ const cleaned = assertValidSetupToken(token);
194
+ const existing = findAccount(name);
195
+ if (!existing)
196
+ return addAccount(name, flow.provider, flow.auth, cleaned);
197
+ if (existing.provider !== flow.provider || existing.auth !== flow.auth) {
198
+ throw new Error(`Account '${name}' already exists as ${existing.provider} ${existing.auth}; not overwriting it with a ${flow.provider} ${flow.auth}. Choose a different --account name.`);
199
+ }
200
+ setAccountSecret(name, cleaned);
201
+ return existing;
202
+ }
203
+ /** Stdout progress for the mint path. `--json` / quiet: drop it (stderr errors stay). */
204
+ function emitMintProgress(line, json) {
205
+ if (!json)
206
+ console.log(line);
207
+ }
208
+ /**
209
+ * Drive `claude setup-token` in a PTY: scrape the authorize URL, open it,
210
+ * optionally paste `--code`, then capture the token with the #1767 guard.
211
+ * Tears the session down on the way out (success, timeout, or throw).
212
+ */
213
+ export async function driveSetupTokenMint(command, flow, opts = {}) {
214
+ const driver = opts.driver ?? defaultPtyDriver();
215
+ const initialDelayMs = opts.drive?.initialDelayMs ?? 1500;
216
+ const pollMs = opts.drive?.pollMs ?? 500;
217
+ const timeoutMs = opts.drive?.timeoutMs ?? 180_000;
218
+ const json = opts.json === true;
219
+ const openUrl = opts.openUrl ?? (async (url) => {
220
+ const shown = await showUrl(url);
221
+ if (shown.via === 'none') {
222
+ // stderr: `--json` stdout stays parseable; the operator still gets the URL.
223
+ console.error(`Could not open a browser — open this yourself:\n ${url}`);
224
+ }
225
+ });
226
+ const id = await driver.start();
227
+ let opened;
228
+ let wroteCode = false;
229
+ let askedCode = false;
230
+ try {
231
+ await driver.exec(id, command);
232
+ await sleep(initialDelayMs);
233
+ const deadline = Date.now() + timeoutMs;
234
+ for (;;) {
235
+ const { screen, exited } = await driver.screen(id);
236
+ const url = extractMintUrl(screen, flow);
237
+ const token = extractClaudeSetupToken(screen);
238
+ if (token) {
239
+ const capturedUrl = url ?? opened;
240
+ if (capturedUrl)
241
+ emitMintProgress(`Authorize URL: ${capturedUrl}`, json);
242
+ return { token, url: capturedUrl, sessionId: id };
243
+ }
244
+ if (url && url !== opened) {
245
+ opened = url;
246
+ await openUrl(url);
247
+ }
248
+ if (url && !wroteCode && !askedCode) {
249
+ const code = opts.code ?? await opts.readCode?.();
250
+ askedCode = true;
251
+ if (code?.trim()) {
252
+ await driver.write(id, `${code.trim()}\r`);
253
+ wroteCode = true;
254
+ }
255
+ }
256
+ if (exited) {
257
+ throw new Error(opened
258
+ ? `Mint process exited before printing a setup-token. Authorize URL was ${opened}. Re-run and complete the browser step, or seed with --token-stdin.`
259
+ : 'Mint process exited before printing an authorize URL or a setup-token.');
260
+ }
261
+ if (Date.now() >= deadline) {
262
+ throw new Error(opened
263
+ ? `Timed out waiting for a setup-token after opening ${opened}. Paste the code with --code, or seed an already-minted token with --token-stdin.`
264
+ : 'Timed out waiting for `claude setup-token` to print an authorize URL.');
265
+ }
266
+ await sleep(pollMs);
267
+ }
268
+ }
269
+ catch (e) {
270
+ await driver.stop(id).catch(() => { });
271
+ throw e;
272
+ }
273
+ finally {
274
+ await driver.stop(id).catch(() => { });
275
+ }
276
+ }
277
+ export function buildMintCommand(flow, bin, home) {
278
+ if (!flow.mintArgs) {
279
+ throw new Error(`Harness '${flow.harness}' has no interactive mint command.`);
280
+ }
281
+ const args = flow.mintArgs.map(shellQuote).join(' ');
282
+ return `HOME=${shellQuote(home)} ${shellQuote(bin)} ${args}`;
283
+ }
284
+ export function resolveMintInstallation(harness) {
285
+ const installed = listInstalledVersions(harness);
286
+ const version = getGlobalDefault(harness) ?? installed[installed.length - 1];
287
+ if (!version) {
288
+ throw new Error(`No installed version of ${harness}. Install one with: agents add ${harness}`);
289
+ }
290
+ return {
291
+ version,
292
+ bin: getBinaryPath(harness, version),
293
+ home: getVersionHomePath(harness, version),
294
+ };
295
+ }
296
+ /**
297
+ * End-to-end mint: resolve identity, obtain a token (stdin or PTY drive),
298
+ * seed the named account + reserved auth bundle, optionally sync the fleet.
299
+ * Never returns or logs the token.
300
+ */
301
+ export async function mintAndSeed(input) {
302
+ const flow = getMintFlow(input.harness);
303
+ const json = input.json === true;
304
+ // An injected PTY driver is a complete substitute for exec'ing the local
305
+ // binary, so the install lookup is only required on the real drive path.
306
+ const install = input.token
307
+ ? null
308
+ : input.hooks?.driver
309
+ ? { bin: flow.harness, home: process.env.HOME ?? '/tmp' }
310
+ : resolveMintInstallation(flow.harness);
311
+ const identity = resolveMintIdentity({
312
+ account: input.account,
313
+ email: input.email,
314
+ home: install?.home,
315
+ });
316
+ let token;
317
+ if (input.token !== undefined) {
318
+ token = assertValidSetupToken(input.token);
319
+ }
320
+ else {
321
+ const command = buildMintCommand(flow, install.bin, install.home);
322
+ const openUrl = input.open === false
323
+ ? async (url) => {
324
+ emitMintProgress(`Authorize: ${url}`, json);
325
+ }
326
+ : input.hooks?.openUrl;
327
+ const driven = await driveSetupTokenMint(command, flow, {
328
+ driver: input.hooks?.driver,
329
+ openUrl,
330
+ readCode: input.hooks?.readCode,
331
+ drive: input.hooks?.drive,
332
+ code: input.code,
333
+ json,
334
+ });
335
+ token = driven.token;
336
+ }
337
+ const existing = findAccount(identity.accountName);
338
+ const account = seedNamedAccount(identity.accountName, token, flow);
339
+ const { key } = seedReservedAuthToken(identity.email, token);
340
+ const targets = await resolveSyncTargets(input.fleet === true, input.devices ?? []);
341
+ const fleet = [];
342
+ for (const device of targets) {
343
+ fleet.push(await syncMintedBundles(account.name, device));
344
+ }
345
+ const failed = fleet.filter((row) => !row.ok);
346
+ if (failed.length) {
347
+ throw new Error(`Minted locally but fleet sync failed for: ${failed.map((row) => `${row.device} (${row.message})`).join('; ')}. Retry: agents accounts sync ${account.name} <device>`);
348
+ }
349
+ return {
350
+ harness: flow.harness,
351
+ account: account.name,
352
+ email: identity.email,
353
+ authBundleKey: key,
354
+ rotated: Boolean(existing),
355
+ fleet,
356
+ };
357
+ }
358
+ export async function resolveSyncTargets(fleet, devices) {
359
+ const named = [...new Set(devices.map((d) => d.trim()).filter(Boolean))];
360
+ if (!fleet && named.length === 0)
361
+ return [];
362
+ const registry = await loadDevices();
363
+ const known = Object.keys(registry);
364
+ if (named.length) {
365
+ // A self host in --device is skipped, not an error; an unknown name is.
366
+ const unknown = named.filter((d) => !known.includes(d) && !isSelfHost(d));
367
+ if (unknown.length) {
368
+ throw new Error(`Unknown device${unknown.length === 1 ? '' : 's'} '${unknown.join("', '")}'. Register with \`agents devices\` or omit --device.`);
369
+ }
370
+ }
371
+ const fromFleet = fleet
372
+ ? known.filter((name) => !isSelfHost(name))
373
+ : [];
374
+ const union = [...new Set([...fromFleet, ...named.filter((d) => !isSelfHost(d))])];
375
+ return union;
376
+ }
377
+ async function syncMintedBundles(accountName, device) {
378
+ try {
379
+ const sshTarget = await resolveHostSshTarget(device);
380
+ assertCredentialTransportHostPinned(sshTarget);
381
+ const remoteBackend = resolveRemoteOsSync(device) === 'win32' ? 'keychain' : 'file';
382
+ const account = findAccount(accountName);
383
+ if (!account)
384
+ throw new Error(`Unknown provider account '${accountName}'.`);
385
+ const accountPush = pushBundleToHost(accountName, device, {
386
+ remoteBackend,
387
+ force: true,
388
+ operation: 'accounts mint --fleet',
389
+ policyNever: true,
390
+ agentOnly: false,
391
+ literalValues: {
392
+ ACCOUNT_ID: account.id,
393
+ PROVIDER: account.provider,
394
+ AUTH_TYPE: account.auth,
395
+ ...(account.baseUrl ? { BASE_URL: account.baseUrl } : {}),
396
+ },
397
+ });
398
+ if (!accountPush.ok)
399
+ throw new Error(accountPush.message);
400
+ if (bundleExists(AUTH_BUNDLE)) {
401
+ const authPush = pushBundleToHost(AUTH_BUNDLE, device, {
402
+ remoteBackend: 'file',
403
+ force: true,
404
+ operation: 'accounts mint --fleet',
405
+ policyNever: true,
406
+ agentOnly: true,
407
+ });
408
+ if (!authPush.ok)
409
+ throw new Error(authPush.message);
410
+ }
411
+ return { device, ok: true, message: `${accountPush.keyCount} keys` };
412
+ }
413
+ catch (err) {
414
+ return { device, ok: false, message: err instanceof Error ? err.message : String(err) };
415
+ }
416
+ }
417
+ /** True when a Claude setup-token is already seeded on this box (setup status). */
418
+ export function hasMintedSetupToken() {
419
+ const records = Object.values(readAccountRegistry().accounts);
420
+ const setup = records.filter((a) => a.auth === 'setup-token' && a.provider === 'anthropic');
421
+ if (setup.length) {
422
+ return { ready: true, detail: `${setup.length} Claude setup-token account${setup.length === 1 ? '' : 's'}` };
423
+ }
424
+ if (bundleExists(AUTH_BUNDLE) && bundleBackend(AUTH_BUNDLE) === 'file') {
425
+ const bundle = readBundle(AUTH_BUNDLE);
426
+ const keys = Object.keys(bundle.vars).filter((k) => k.startsWith('CLAUDE_CODE_OAUTH_TOKEN_'));
427
+ if (keys.length)
428
+ return { ready: true, detail: `reserved auth bundle (${keys.length} account key${keys.length === 1 ? '' : 's'})` };
429
+ }
430
+ return { ready: false, detail: 'no Claude setup-token minted — agents accounts mint claude' };
431
+ }
432
+ function sleep(ms) {
433
+ return new Promise((resolve) => setTimeout(resolve, ms));
434
+ }
@@ -1,3 +1,4 @@
1
+ import type { BrowserProfileConfig } from '../types.js';
1
2
  import type { BrowserProfile, ProfileName } from './types.js';
2
3
  export type { BrowserProfile } from './types.js';
3
4
  export { declaringDevices, profileKind, profileRegistry, type ProfileDeclaration, } from './registry.js';
@@ -175,6 +176,23 @@ export declare function effectiveLocalPort(profile: BrowserProfile): number | un
175
176
  * skip remote profiles in this scan.
176
177
  */
177
178
  export declare function findFreeProfilePort(): Promise<number>;
179
+ export declare function hasSshEndpoint(endpoints: BrowserProfileConfig['endpoints']): boolean;
180
+ /**
181
+ * Whether the automatic central-tombstone drain (PHNX-3315) may claim `config`
182
+ * into THIS device's doc during `agents sync`.
183
+ *
184
+ * Only REMOTE (`ssh://`) profiles qualify. An `ssh://` endpoint names a specific
185
+ * host, so the profile is fungible by design — any box resolves it to the same
186
+ * browser, and a concurrent double-claim across machines is harmless. A
187
+ * local/`cdp://` profile has NO per-machine ownership signal: "that browser is
188
+ * installed here" is not "I hold this profile's credentialed session", so two
189
+ * boxes with the same common browser installed would each auto-claim the same
190
+ * tombstone on their first post-merge sync and flip it identity->fungible
191
+ * fleet-wide (the exact logged-out-browser failure this module exists to prevent
192
+ * — PHNX-3315 review). Local/cdp tombstones stay central for the explicit
193
+ * `agents browser profiles claim`.
194
+ */
195
+ export declare function shouldAutoClaimCentralProfile(config: BrowserProfileConfig): boolean;
178
196
  /**
179
197
  * Refuse a profile whose LOCAL port another profile already owns.
180
198
  *
@@ -390,7 +390,7 @@ function validateRemoteBrowserBinary(binary) {
390
390
  throw new Error(`Remote browser binary contains shell metacharacters: ${binary}`);
391
391
  }
392
392
  }
393
- function hasSshEndpoint(endpoints) {
393
+ export function hasSshEndpoint(endpoints) {
394
394
  const targets = Array.isArray(endpoints)
395
395
  ? endpoints
396
396
  : Object.values(endpoints).map((preset) => preset.target);
@@ -403,6 +403,31 @@ function hasSshEndpoint(endpoints) {
403
403
  }
404
404
  });
405
405
  }
406
+ /**
407
+ * Whether the automatic central-tombstone drain (PHNX-3315) may claim `config`
408
+ * into THIS device's doc during `agents sync`.
409
+ *
410
+ * Only REMOTE (`ssh://`) profiles qualify. An `ssh://` endpoint names a specific
411
+ * host, so the profile is fungible by design — any box resolves it to the same
412
+ * browser, and a concurrent double-claim across machines is harmless. A
413
+ * local/`cdp://` profile has NO per-machine ownership signal: "that browser is
414
+ * installed here" is not "I hold this profile's credentialed session", so two
415
+ * boxes with the same common browser installed would each auto-claim the same
416
+ * tombstone on their first post-merge sync and flip it identity->fungible
417
+ * fleet-wide (the exact logged-out-browser failure this module exists to prevent
418
+ * — PHNX-3315 review). Local/cdp tombstones stay central for the explicit
419
+ * `agents browser profiles claim`.
420
+ */
421
+ export function shouldAutoClaimCentralProfile(config) {
422
+ if (!hasSshEndpoint(config.endpoints))
423
+ return false;
424
+ return isProfileLaunchableHere({
425
+ name: '_',
426
+ browser: config.browser,
427
+ binary: config.binary,
428
+ endpoints: config.endpoints,
429
+ });
430
+ }
406
431
  /**
407
432
  * Refuse a profile whose LOCAL port another profile already owns.
408
433
  *
@@ -3,7 +3,15 @@ export interface ProfileDeclaration {
3
3
  device: string;
4
4
  config: BrowserProfileConfig;
5
5
  }
6
- /** Leftover central `browser:` map from before per-device declarations. Empty when none remain. */
6
+ /**
7
+ * The leftover central `browser:` tombstone from before per-device declarations.
8
+ * Empty when none remain — and `agents sync` now drains it automatically via
9
+ * {@link autoEvictCentralBrowserProfiles}, so on a synced box this is empty and
10
+ * {@link profileRegistry} is the single source of truth. It survives only to
11
+ * fold in migration and to explain an as-yet-unclaimed profile in a resolve
12
+ * error (a profile hostable on a peer that has not synced yet), never as a
13
+ * parallel store callers read for resolution.
14
+ */
7
15
  export declare function centralBrowserProfiles(): Record<string, BrowserProfileConfig>;
8
16
  export interface CentralClaimResult {
9
17
  claimed: string[];
@@ -12,22 +20,44 @@ export interface CentralClaimResult {
12
20
  /**
13
21
  * Fold leftover central `browser:` entries into THIS device's declaration file.
14
22
  *
15
- * Never called implicitly, and that is the point. Every device can read the
16
- * central map, so an implicit claim races: whichever box happens to read first
17
- * claims the name, {@link profileKind} then reports it `identity`, and the
18
- * daemon tunnels to that box — which for a fleet-wide `cdp://localhost:*`
19
- * profile is a logged-out headless browser wearing a credentialed browser's
20
- * name. That is the exact bug this module exists to remove, and an implicit
21
- * migration would write it to disk as a stored fact.
23
+ * The EXPLICIT operator action (`agents browser profiles claim`), run on the
24
+ * machine that actually owns the browser. It is never called implicitly from a
25
+ * read, and that is the point: every device can read the central map, so an
26
+ * implicit claim races — whichever box reads first claims the name, {@link
27
+ * profileKind} then reports it `identity`, and the daemon tunnels to that box,
28
+ * which for a fleet-wide `cdp://localhost:*` profile is a logged-out headless
29
+ * browser wearing a credentialed browser's name. {@link autoEvictCentralBrowserProfiles}
30
+ * closes that race for the automatic path (host-gated + non-throwing on sync);
31
+ * this one is the manual, throw-on-conflict counterpart.
22
32
  *
23
- * The claim is an explicit operator action (`agents browser profiles claim`),
24
- * run on the machine that actually owns the browser. `canHostHere` is supplied
25
- * by the command layer so this module stays a leaf — it must not import
26
- * `isProfileLaunchableHere` (that would cycle through chrome.ts → profiles.ts).
27
- * Only profiles this machine can host are claimed; the rest stay central,
28
- * undeclared, and fail loudly on resolve.
33
+ * `canHostHere` is supplied by the command layer so this module stays a leaf —
34
+ * it must not import `isProfileLaunchableHere` (that would cycle through
35
+ * chrome.ts → profiles.ts). Only profiles this machine can host are claimed; the
36
+ * rest stay central, undeclared, and fail loudly on resolve.
29
37
  */
30
38
  export declare function migrateCentralBrowserProfiles(canHostHere: (config: BrowserProfileConfig) => boolean, name?: string): CentralClaimResult;
39
+ /**
40
+ * Automatic, self-draining counterpart to {@link migrateCentralBrowserProfiles}:
41
+ * fold every lingering central `browser:` profile THIS machine can host into its
42
+ * device doc and clear it from central, so the tombstone drains itself on
43
+ * `agents sync` with no manual `agents browser profiles claim`.
44
+ *
45
+ * Safe to call implicitly, unlike a claim at registry-read time (which races —
46
+ * see the `profileRegistry does not claim central declarations` guard), but the
47
+ * CALLER owns one ordering invariant: invoke it AFTER the sync's repo pull. Two
48
+ * boxes can both host the same profile (`canHostHere` is launchability, not
49
+ * ownership), so a box acting on a PRE-pull view of central could re-claim a
50
+ * profile a peer already drained and pushed — both writes land in different
51
+ * device files with no git conflict, and the profile flips identity->fungible.
52
+ * Running post-pull means central already reflects peers' drains, so the tombstone
53
+ * is gone locally before this box looks. The selection is also computed INSIDE
54
+ * the meta lock, and the path never throws — an unhostable profile or one that
55
+ * conflicts with an existing local declaration is left central for an explicit
56
+ * claim rather than wedging the sync. After it runs, {@link profileRegistry} is
57
+ * the single source of truth: a claimed profile lives in the device doc alone,
58
+ * never double-counted across the two stores.
59
+ */
60
+ export declare function autoEvictCentralBrowserProfiles(canHostHere: (config: BrowserProfileConfig) => boolean): CentralClaimResult;
31
61
  /**
32
62
  * Every profile any device declares, keyed by name to all declaring devices.
33
63
  * Reads every `devices/<name>/agents.yaml`; declarations never overwrite.