@phnx-labs/agents-cli 1.20.67 → 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 (83) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +14 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/check.js +96 -2
  5. package/dist/commands/computer.d.ts +26 -0
  6. package/dist/commands/computer.js +173 -149
  7. package/dist/commands/doctor.js +37 -9
  8. package/dist/commands/exec.d.ts +18 -0
  9. package/dist/commands/exec.js +126 -6
  10. package/dist/commands/fork.d.ts +9 -0
  11. package/dist/commands/fork.js +67 -0
  12. package/dist/commands/plugins.js +11 -2
  13. package/dist/commands/repo.js +47 -2
  14. package/dist/commands/run-account-picker.d.ts +14 -0
  15. package/dist/commands/run-account-picker.js +131 -0
  16. package/dist/commands/setup-browser.d.ts +18 -0
  17. package/dist/commands/setup-browser.js +142 -0
  18. package/dist/commands/setup-computer.d.ts +19 -0
  19. package/dist/commands/setup-computer.js +133 -0
  20. package/dist/commands/setup-share.d.ts +17 -0
  21. package/dist/commands/setup-share.js +85 -0
  22. package/dist/commands/setup.d.ts +1 -1
  23. package/dist/commands/setup.js +58 -1
  24. package/dist/commands/share.d.ts +15 -0
  25. package/dist/commands/share.js +10 -4
  26. package/dist/commands/ssh.js +267 -2
  27. package/dist/commands/teams.d.ts +11 -0
  28. package/dist/commands/teams.js +39 -1
  29. package/dist/commands/view.d.ts +4 -14
  30. package/dist/commands/view.js +39 -36
  31. package/dist/index.js +2 -1
  32. package/dist/lib/agents.d.ts +21 -5
  33. package/dist/lib/agents.js +59 -24
  34. package/dist/lib/auth-health.d.ts +103 -0
  35. package/dist/lib/auth-health.js +232 -0
  36. package/dist/lib/browser/chrome.d.ts +9 -0
  37. package/dist/lib/browser/chrome.js +22 -0
  38. package/dist/lib/browser/profiles.js +4 -25
  39. package/dist/lib/cli-entry.d.ts +23 -0
  40. package/dist/lib/cli-entry.js +116 -0
  41. package/dist/lib/computer/download.d.ts +51 -0
  42. package/dist/lib/computer/download.js +145 -0
  43. package/dist/lib/computer-rpc.js +9 -3
  44. package/dist/lib/daemon.d.ts +2 -2
  45. package/dist/lib/daemon.js +8 -89
  46. package/dist/lib/devices/connect.d.ts +15 -0
  47. package/dist/lib/devices/connect.js +17 -5
  48. package/dist/lib/devices/fleet.d.ts +23 -0
  49. package/dist/lib/devices/fleet.js +38 -0
  50. package/dist/lib/devices/health-report.d.ts +46 -0
  51. package/dist/lib/devices/health-report.js +159 -0
  52. package/dist/lib/devices/health.d.ts +14 -4
  53. package/dist/lib/devices/health.js +49 -8
  54. package/dist/lib/devices/terminfo.d.ts +44 -0
  55. package/dist/lib/devices/terminfo.js +167 -0
  56. package/dist/lib/git.d.ts +10 -0
  57. package/dist/lib/git.js +23 -0
  58. package/dist/lib/hosts/option.js +1 -1
  59. package/dist/lib/hosts/ready.js +5 -3
  60. package/dist/lib/hosts/remote-cmd.d.ts +12 -0
  61. package/dist/lib/hosts/remote-cmd.js +17 -1
  62. package/dist/lib/mcp.d.ts +21 -0
  63. package/dist/lib/mcp.js +278 -13
  64. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  65. package/dist/lib/resources/mcp.js +20 -342
  66. package/dist/lib/rotate.d.ts +12 -8
  67. package/dist/lib/rotate.js +22 -23
  68. package/dist/lib/routines.d.ts +13 -0
  69. package/dist/lib/routines.js +21 -0
  70. package/dist/lib/runner.js +18 -40
  71. package/dist/lib/secrets/agent.js +11 -15
  72. package/dist/lib/session/fork.d.ts +32 -0
  73. package/dist/lib/session/fork.js +101 -0
  74. package/dist/lib/share/capture.js +21 -3
  75. package/dist/lib/signin-badge.d.ts +41 -0
  76. package/dist/lib/signin-badge.js +64 -0
  77. package/dist/lib/ssh-exec.d.ts +5 -0
  78. package/dist/lib/ssh-exec.js +55 -1
  79. package/dist/lib/startup/command-registry.d.ts +1 -0
  80. package/dist/lib/startup/command-registry.js +2 -0
  81. package/dist/lib/usage.d.ts +40 -3
  82. package/dist/lib/usage.js +147 -16
  83. package/package.json +1 -1
@@ -12,8 +12,9 @@
12
12
  import * as fs from 'fs';
13
13
  import * as path from 'path';
14
14
  import * as yaml from 'yaml';
15
- import * as TOML from 'smol-toml';
16
15
  import { capableAgents } from '../capabilities.js';
16
+ import { getProjectMcpConfigPath } from '../agents.js';
17
+ import { writeMcpConfig } from '../mcp.js';
17
18
  import { getSystemMcpDir, getUserMcpDir, getProjectAgentsDir, getEnabledExtraRepos, } from '../state.js';
18
19
  /**
19
20
  * Parse an MCP YAML file into an McpItem.
@@ -132,316 +133,10 @@ export function getMcpConfigPath(agent, versionHome) {
132
133
  }
133
134
  }
134
135
  /**
135
- * Strip JSON comments (for JSONC files).
136
+ * Dispatch MCP items to the agent-specific config writer.
136
137
  */
137
- function stripJsonComments(content) {
138
- let result = '';
139
- let inString = false;
140
- let escape = false;
141
- let i = 0;
142
- while (i < content.length) {
143
- const char = content[i];
144
- const next = content[i + 1];
145
- if (escape) {
146
- result += char;
147
- escape = false;
148
- i++;
149
- continue;
150
- }
151
- if (char === '\\' && inString) {
152
- result += char;
153
- escape = true;
154
- i++;
155
- continue;
156
- }
157
- if (char === '"') {
158
- inString = !inString;
159
- result += char;
160
- i++;
161
- continue;
162
- }
163
- if (!inString) {
164
- if (char === '/' && next === '/') {
165
- while (i < content.length && content[i] !== '\n') {
166
- i++;
167
- }
168
- continue;
169
- }
170
- if (char === '/' && next === '*') {
171
- i += 2;
172
- while (i < content.length && !(content[i] === '*' && content[i + 1] === '/')) {
173
- i++;
174
- }
175
- i += 2;
176
- continue;
177
- }
178
- }
179
- result += char;
180
- i++;
181
- }
182
- return result;
183
- }
184
- /**
185
- * Write MCP servers to Claude settings.json format.
186
- */
187
- function syncToClaudeConfig(configPath, items) {
188
- let config = {};
189
- if (fs.existsSync(configPath)) {
190
- try {
191
- config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
192
- }
193
- catch {
194
- config = {};
195
- }
196
- }
197
- const mcpServers = {};
198
- for (const item of items) {
199
- if (item.transport === 'stdio') {
200
- mcpServers[item.name] = {
201
- command: item.command,
202
- args: item.args || [],
203
- env: item.env || {},
204
- };
205
- }
206
- else {
207
- mcpServers[item.name] = {
208
- url: item.url,
209
- ...(item.headers && { headers: item.headers }),
210
- };
211
- }
212
- }
213
- config.mcpServers = mcpServers;
214
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
215
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
216
- }
217
- /**
218
- * Write MCP servers to Codex config.toml format.
219
- */
220
- function syncToCodexConfig(configPath, items) {
221
- let config = {};
222
- if (fs.existsSync(configPath)) {
223
- try {
224
- config = TOML.parse(fs.readFileSync(configPath, 'utf-8'));
225
- }
226
- catch {
227
- config = {};
228
- }
229
- }
230
- const mcpServers = {};
231
- for (const item of items) {
232
- if (item.transport === 'stdio') {
233
- mcpServers[item.name] = {
234
- command: item.command,
235
- args: item.args || [],
236
- ...(item.env && { env: item.env }),
237
- };
238
- }
239
- // Codex may not support HTTP MCPs
240
- }
241
- config.mcp_servers = mcpServers;
242
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
243
- fs.writeFileSync(configPath, TOML.stringify(config), 'utf-8');
244
- }
245
- /**
246
- * Write MCP servers to Grok config.toml format ([mcp_servers] section).
247
- */
248
- function syncToGrokConfig(configPath, items) {
249
- let config = {};
250
- if (fs.existsSync(configPath)) {
251
- try {
252
- config = TOML.parse(fs.readFileSync(configPath, 'utf-8'));
253
- }
254
- catch {
255
- config = {};
256
- }
257
- }
258
- const mcpServers = {};
259
- for (const item of items) {
260
- if (item.transport === 'stdio') {
261
- mcpServers[item.name] = {
262
- command: item.command,
263
- args: item.args || [],
264
- ...(item.env && { env: item.env }),
265
- };
266
- }
267
- else if (item.transport === 'http' || item.transport === 'sse') {
268
- mcpServers[item.name] = {
269
- url: item.url,
270
- ...(item.headers && { headers: item.headers }),
271
- };
272
- }
273
- }
274
- config.mcp_servers = mcpServers;
275
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
276
- fs.writeFileSync(configPath, TOML.stringify(config), 'utf-8');
277
- }
278
- function syncToHermesConfig(configPath, items) {
279
- let config = {};
280
- if (fs.existsSync(configPath)) {
281
- try {
282
- const parsed = yaml.parse(fs.readFileSync(configPath, 'utf-8'));
283
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
284
- config = parsed;
285
- }
286
- }
287
- catch {
288
- config = {};
289
- }
290
- }
291
- const mcpServers = {};
292
- for (const item of items) {
293
- if (item.transport === 'stdio') {
294
- mcpServers[item.name] = {
295
- command: item.command,
296
- args: item.args || [],
297
- ...(item.env && { env: item.env }),
298
- };
299
- }
300
- else {
301
- mcpServers[item.name] = {
302
- url: item.url,
303
- };
304
- }
305
- }
306
- config.mcp_servers = mcpServers;
307
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
308
- fs.writeFileSync(configPath, yaml.stringify(config), 'utf-8');
309
- }
310
- /**
311
- * Write MCP servers to OpenCode opencode.jsonc format.
312
- */
313
- function syncToOpenCodeConfig(configPath, items) {
314
- let config = {};
315
- if (fs.existsSync(configPath)) {
316
- try {
317
- const content = stripJsonComments(fs.readFileSync(configPath, 'utf-8'));
318
- config = JSON.parse(content);
319
- }
320
- catch {
321
- config = {};
322
- }
323
- }
324
- const mcp = {};
325
- for (const item of items) {
326
- if (item.transport === 'stdio') {
327
- // OpenCode uses command as array
328
- const commandArray = [item.command, ...(item.args || [])];
329
- mcp[item.name] = {
330
- type: 'local',
331
- command: commandArray,
332
- ...(item.env && { env: item.env }),
333
- };
334
- }
335
- else {
336
- mcp[item.name] = {
337
- type: 'remote',
338
- url: item.url,
339
- };
340
- }
341
- }
342
- config.mcp = mcp;
343
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
344
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
345
- }
346
- /**
347
- * Write MCP servers to Cursor mcp.json format.
348
- */
349
- function syncToCursorConfig(configPath, items) {
350
- let config = {};
351
- if (fs.existsSync(configPath)) {
352
- try {
353
- config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
354
- }
355
- catch {
356
- config = {};
357
- }
358
- }
359
- const mcpServers = {};
360
- for (const item of items) {
361
- if (item.transport === 'stdio') {
362
- mcpServers[item.name] = {
363
- command: item.command,
364
- args: item.args || [],
365
- env: item.env || {},
366
- };
367
- }
368
- else {
369
- mcpServers[item.name] = {
370
- url: item.url,
371
- };
372
- }
373
- }
374
- config.mcpServers = mcpServers;
375
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
376
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
377
- }
378
- /**
379
- * Write MCP servers to Gemini settings.json format.
380
- */
381
- function syncToGeminiConfig(configPath, items) {
382
- let config = {};
383
- if (fs.existsSync(configPath)) {
384
- try {
385
- config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
386
- }
387
- catch {
388
- config = {};
389
- }
390
- }
391
- const mcpServers = {};
392
- for (const item of items) {
393
- if (item.transport === 'stdio') {
394
- mcpServers[item.name] = {
395
- command: item.command,
396
- args: item.args || [],
397
- env: item.env || {},
398
- };
399
- }
400
- else {
401
- mcpServers[item.name] = {
402
- url: item.url,
403
- };
404
- }
405
- }
406
- config.mcpServers = mcpServers;
407
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
408
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
409
- }
410
- /**
411
- * Write MCP servers to OpenClaw openclaw.json format.
412
- */
413
- function syncToOpenClawConfig(configPath, items) {
414
- let config = {};
415
- if (fs.existsSync(configPath)) {
416
- try {
417
- config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
418
- }
419
- catch {
420
- config = {};
421
- }
422
- }
423
- if (!config.mcp || typeof config.mcp !== 'object') {
424
- config.mcp = {};
425
- }
426
- const servers = {};
427
- for (const item of items) {
428
- if (item.transport === 'stdio') {
429
- servers[item.name] = {
430
- command: item.command,
431
- args: item.args,
432
- env: item.env,
433
- };
434
- }
435
- else {
436
- servers[item.name] = {
437
- url: item.url,
438
- transport: item.transport,
439
- };
440
- }
441
- }
442
- config.mcp.servers = servers;
443
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
444
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
138
+ function syncToAgentConfig(agent, configPath, items, mode = 'overwrite') {
139
+ writeMcpConfig(agent, configPath, items, mode);
445
140
  }
446
141
  /**
447
142
  * MCP resource handler implementing ResourceHandler<McpItem>.
@@ -511,39 +206,22 @@ export const McpHandler = {
511
206
  if (items.length === 0) {
512
207
  return;
513
208
  }
209
+ // Sync resolved MCPs to the version home (user-level agent config).
514
210
  const configPath = getMcpConfigPath(agent, versionHome);
515
- if (!configPath) {
516
- return;
517
- }
518
- const mcpItems = items.map((r) => r.item);
519
- switch (agent) {
520
- case 'claude':
521
- syncToClaudeConfig(configPath, mcpItems);
522
- break;
523
- case 'codex':
524
- syncToCodexConfig(configPath, mcpItems);
525
- break;
526
- case 'opencode':
527
- syncToOpenCodeConfig(configPath, mcpItems);
528
- break;
529
- case 'cursor':
530
- syncToCursorConfig(configPath, mcpItems);
531
- break;
532
- case 'gemini':
533
- syncToGeminiConfig(configPath, mcpItems);
534
- break;
535
- case 'openclaw':
536
- syncToOpenClawConfig(configPath, mcpItems);
537
- break;
538
- case 'grok':
539
- syncToGrokConfig(configPath, mcpItems);
540
- break;
541
- case 'hermes':
542
- syncToHermesConfig(configPath, mcpItems);
543
- break;
544
- case 'forge':
545
- syncToCursorConfig(configPath, mcpItems);
546
- break;
211
+ if (configPath) {
212
+ const mcpItems = items.map((r) => r.item);
213
+ syncToAgentConfig(agent, configPath, mcpItems, 'overwrite');
214
+ }
215
+ // Sync project-layer MCPs to the agent's project-level config path so each
216
+ // agent CLI can discover them alongside its user-level config. Merge so we
217
+ // do not clobber entries added manually or by the agent's own CLI.
218
+ const projectAgentsDir = cwd ? getProjectAgentsDir(cwd) : null;
219
+ if (projectAgentsDir) {
220
+ const projectItems = items.filter((r) => r.layer === 'project').map((r) => r.item);
221
+ if (projectItems.length > 0) {
222
+ const projectConfigPath = getProjectMcpConfigPath(agent, path.dirname(projectAgentsDir));
223
+ syncToAgentConfig(agent, projectConfigPath, projectItems, 'merge');
224
+ }
547
225
  }
548
226
  },
549
227
  format(agent) {
@@ -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
  /**
@@ -134,12 +134,25 @@ export interface RunMeta {
134
134
  startedAt: string;
135
135
  completedAt: string | null;
136
136
  exitCode: number | null;
137
+ /** Machine-readable failure reason when the run did not complete successfully. */
138
+ errorMessage?: string;
139
+ /** Wall-clock duration of the run in milliseconds (set when the run finishes). */
140
+ duration?: number;
137
141
  /** Set for `host:`-placed runs — where the job body executes (no local pid). */
138
142
  host?: string;
139
143
  /** The host-task sidecar id backing a `host:` run; the daemon monitor
140
144
  * finalizes the run by reconciling it against the remote `.exit`. */
141
145
  hostTaskId?: string;
142
146
  }
147
+ /**
148
+ * Finalize a run record with a terminal status, computing `duration` from
149
+ * `startedAt` and the completion timestamp. Keeps failure-reason population
150
+ * centralized so every completion path writes the same machine-readable fields.
151
+ */
152
+ export declare function finalizeRunMeta(meta: RunMeta, status: RunMeta['status'], exitCode: number | null, opts?: {
153
+ errorMessage?: string;
154
+ completedAt?: string;
155
+ }): void;
143
156
  /**
144
157
  * True when the job may execute on this machine: no `devices` allowlist (or
145
158
  * empty), or the allowlist includes this device. Both sides go through
@@ -50,6 +50,27 @@ export function normalizeTriggerEvent(input) {
50
50
  };
51
51
  return aliases[key] ?? null;
52
52
  }
53
+ /**
54
+ * Finalize a run record with a terminal status, computing `duration` from
55
+ * `startedAt` and the completion timestamp. Keeps failure-reason population
56
+ * centralized so every completion path writes the same machine-readable fields.
57
+ */
58
+ export function finalizeRunMeta(meta, status, exitCode, opts) {
59
+ meta.status = status;
60
+ meta.exitCode = exitCode;
61
+ meta.completedAt = opts?.completedAt ?? new Date().toISOString();
62
+ const started = Date.parse(meta.startedAt);
63
+ const completed = Date.parse(meta.completedAt);
64
+ meta.duration = Number.isFinite(started) && Number.isFinite(completed) && completed >= started
65
+ ? completed - started
66
+ : 0;
67
+ if (opts?.errorMessage) {
68
+ meta.errorMessage = opts.errorMessage;
69
+ }
70
+ else {
71
+ delete meta.errorMessage;
72
+ }
73
+ }
53
74
  /**
54
75
  * True when the job may execute on this machine: no `devices` allowlist (or
55
76
  * empty), or the allowlist includes this device. Both sides go through