@phnx-labs/agents-cli 1.16.0 → 1.17.0

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 (60) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/commands/browser.js +248 -9
  3. package/dist/commands/cloud.js +8 -0
  4. package/dist/commands/exec.js +70 -1
  5. package/dist/commands/plugins.js +179 -5
  6. package/dist/commands/prune.js +6 -0
  7. package/dist/commands/secrets.js +117 -19
  8. package/dist/commands/view.js +21 -8
  9. package/dist/commands/workflows.d.ts +10 -0
  10. package/dist/commands/workflows.js +457 -0
  11. package/dist/index.js +31 -16
  12. package/dist/lib/browser/cdp.js +7 -4
  13. package/dist/lib/browser/chrome.d.ts +10 -0
  14. package/dist/lib/browser/chrome.js +37 -2
  15. package/dist/lib/browser/drivers/local.js +13 -2
  16. package/dist/lib/browser/input.d.ts +1 -0
  17. package/dist/lib/browser/input.js +3 -0
  18. package/dist/lib/browser/ipc.js +14 -0
  19. package/dist/lib/browser/profiles.d.ts +5 -0
  20. package/dist/lib/browser/profiles.js +45 -0
  21. package/dist/lib/browser/service.d.ts +10 -0
  22. package/dist/lib/browser/service.js +29 -1
  23. package/dist/lib/browser/types.d.ts +11 -1
  24. package/dist/lib/cloud/rush.d.ts +28 -1
  25. package/dist/lib/cloud/rush.js +68 -13
  26. package/dist/lib/commands.d.ts +0 -15
  27. package/dist/lib/commands.js +5 -5
  28. package/dist/lib/hooks.js +24 -11
  29. package/dist/lib/migrate.js +59 -1
  30. package/dist/lib/permissions.d.ts +0 -58
  31. package/dist/lib/permissions.js +10 -10
  32. package/dist/lib/plugins.d.ts +75 -34
  33. package/dist/lib/plugins.js +640 -133
  34. package/dist/lib/resource-patterns.d.ts +41 -0
  35. package/dist/lib/resource-patterns.js +82 -0
  36. package/dist/lib/resources/index.d.ts +17 -0
  37. package/dist/lib/resources/index.js +7 -0
  38. package/dist/lib/resources/types.d.ts +1 -1
  39. package/dist/lib/resources/workflows.d.ts +24 -0
  40. package/dist/lib/resources/workflows.js +110 -0
  41. package/dist/lib/resources.d.ts +6 -1
  42. package/dist/lib/resources.js +12 -2
  43. package/dist/lib/session/db.d.ts +18 -0
  44. package/dist/lib/session/db.js +106 -7
  45. package/dist/lib/session/discover.d.ts +6 -0
  46. package/dist/lib/session/discover.js +28 -17
  47. package/dist/lib/shims.d.ts +3 -51
  48. package/dist/lib/shims.js +18 -10
  49. package/dist/lib/sqlite.js +10 -4
  50. package/dist/lib/state.d.ts +15 -2
  51. package/dist/lib/state.js +29 -8
  52. package/dist/lib/types.d.ts +43 -14
  53. package/dist/lib/versions.d.ts +3 -0
  54. package/dist/lib/versions.js +139 -27
  55. package/dist/lib/workflows.d.ts +79 -0
  56. package/dist/lib/workflows.js +233 -0
  57. package/package.json +1 -5
  58. package/scripts/postinstall.js +59 -58
  59. package/dist/commands/fork.d.ts +0 -10
  60. package/dist/commands/fork.js +0 -146
@@ -1,5 +1,5 @@
1
1
  import { CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity } from '../cdp.js';
2
- import { launchBrowser, allocatePort } from '../chrome.js';
2
+ import { launchBrowser, getPortOccupant } from '../chrome.js';
3
3
  export async function connectLocal(endpoint, profile) {
4
4
  const url = new URL(endpoint);
5
5
  if (url.protocol !== 'cdp:') {
@@ -17,7 +17,18 @@ export async function connectLocal(endpoint, profile) {
17
17
  if (err instanceof Error && err.message.startsWith('Browser identity mismatch')) {
18
18
  throw err;
19
19
  }
20
- const newPort = allocatePort();
20
+ // Distinguish "nothing listening on this port" (fine to launch fresh) from
21
+ // "something is listening but it's not a debuggable browser" (bail loudly —
22
+ // silently launching on a different port leads to confusing `pid 0` and
23
+ // `CDP connection not open` errors downstream).
24
+ const occupant = getPortOccupant(port);
25
+ if (occupant) {
26
+ throw new Error(`Port ${port} is occupied by ${occupant.command} (pid ${occupant.pid}) but is ` +
27
+ `not serving the Chrome DevTools Protocol. Either stop that process ` +
28
+ `(\`kill ${occupant.pid}\`) or restart it with \`--remote-debugging-port=${port}\` ` +
29
+ `so profile "${profile.name}" can attach.`);
30
+ }
31
+ const newPort = port;
21
32
  const chromeOpts = { ...profile.chrome, viewport: profile.viewport };
22
33
  const { pid, wsUrl } = await launchBrowser(profile.name, profile.browser, newPort, chromeOpts, profile.secrets, profile.binary);
23
34
  const cdp = new CDPClient();
@@ -1,6 +1,7 @@
1
1
  import type { CDPClient } from './cdp.js';
2
2
  export declare function clickAtCoords(cdp: CDPClient, sessionId: string, x: number, y: number): Promise<void>;
3
3
  export declare function hoverAtCoords(cdp: CDPClient, sessionId: string, x: number, y: number): Promise<void>;
4
+ export declare function scrollAtCoords(cdp: CDPClient, sessionId: string, x: number, y: number, deltaX: number, deltaY: number): Promise<void>;
4
5
  export declare function typeText(cdp: CDPClient, sessionId: string, text: string): Promise<void>;
5
6
  export declare function pressKey(cdp: CDPClient, sessionId: string, keyName: string): Promise<void>;
6
7
  export declare function focusNode(cdp: CDPClient, sessionId: string, backendNodeId: number): Promise<void>;
@@ -5,6 +5,9 @@ export async function clickAtCoords(cdp, sessionId, x, y) {
5
5
  export async function hoverAtCoords(cdp, sessionId, x, y) {
6
6
  await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }, sessionId);
7
7
  }
8
+ export async function scrollAtCoords(cdp, sessionId, x, y, deltaX, deltaY) {
9
+ await cdp.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x, y, deltaX, deltaY }, sessionId);
10
+ }
8
11
  export async function typeText(cdp, sessionId, text) {
9
12
  for (const char of text) {
10
13
  await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', text: char }, sessionId);
@@ -78,6 +78,13 @@ export class BrowserIPCServer {
78
78
  windowTargetId: result.windowId,
79
79
  };
80
80
  }
81
+ case 'launch-profile': {
82
+ if (!request.profile) {
83
+ return { ok: false, error: 'Profile required' };
84
+ }
85
+ const result = await this.service.launchProfile(request.profile);
86
+ return { ok: true, port: result.port, pid: result.pid };
87
+ }
81
88
  case 'done': {
82
89
  if (!request.task) {
83
90
  return { ok: false, error: 'Task required' };
@@ -191,6 +198,13 @@ export class BrowserIPCServer {
191
198
  await this.service.hover(request.task, request.ref, request.tabId);
192
199
  return { ok: true };
193
200
  }
201
+ case 'scroll': {
202
+ if (!request.task) {
203
+ return { ok: false, error: 'Task required' };
204
+ }
205
+ await this.service.scroll(request.task, request.scrollX ?? 0, request.scrollY ?? 0, request.scrollAtX, request.scrollAtY, request.tabId);
206
+ return { ok: true };
207
+ }
194
208
  case 'set-viewport': {
195
209
  if (!request.task || !request.width || !request.height) {
196
210
  return { ok: false, error: 'Task, width, and height required' };
@@ -4,6 +4,11 @@ export declare function getBrowserRuntimeDir(): string;
4
4
  export declare function getProfileRuntimeDir(name: string): string;
5
5
  export declare function listProfiles(): Promise<BrowserProfile[]>;
6
6
  export declare function getProfile(name: string): Promise<BrowserProfile | null>;
7
+ /**
8
+ * Find a port in 9222–9399 that is not already claimed by another profile
9
+ * and is not currently in use by any OS process.
10
+ */
11
+ export declare function findFreeProfilePort(): Promise<number>;
7
12
  export declare function createProfile(profile: BrowserProfile): Promise<void>;
8
13
  export declare function updateProfile(profile: BrowserProfile): Promise<void>;
9
14
  export declare function deleteProfile(name: string): Promise<void>;
@@ -1,5 +1,7 @@
1
1
  import * as path from 'path';
2
+ import { execSync } from 'child_process';
2
3
  import { getBrowserRuntimeDir as getBrowserRuntimeDirRoot, readMeta, writeMeta, } from '../state.js';
4
+ import { findBrowserPath } from './chrome.js';
3
5
  export function getBrowserRuntimeDir() {
4
6
  return getBrowserRuntimeDirRoot();
5
7
  }
@@ -51,11 +53,54 @@ export async function getProfile(name) {
51
53
  return null;
52
54
  return configToProfile(name, config);
53
55
  }
56
+ /**
57
+ * Find a port in 9222–9399 that is not already claimed by another profile
58
+ * and is not currently in use by any OS process.
59
+ */
60
+ export async function findFreeProfilePort() {
61
+ const profiles = await listProfiles();
62
+ const usedByProfile = new Set();
63
+ for (const p of profiles) {
64
+ const port = extractConfiguredPort(p);
65
+ if (port !== undefined)
66
+ usedByProfile.add(port);
67
+ }
68
+ for (let port = 9222; port <= 9399; port++) {
69
+ if (usedByProfile.has(port))
70
+ continue;
71
+ try {
72
+ execSync(`lsof -i :${port}`, { stdio: 'ignore' });
73
+ // lsof succeeded → something is listening → port is in use
74
+ }
75
+ catch {
76
+ // lsof threw → nothing on this port → it's free
77
+ return port;
78
+ }
79
+ }
80
+ throw new Error('No available ports in range 9222-9399');
81
+ }
54
82
  export async function createProfile(profile) {
55
83
  const meta = readMeta();
56
84
  if (meta.browser?.[profile.name]) {
57
85
  throw new Error(`Profile "${profile.name}" already exists`);
58
86
  }
87
+ // Check for port collision with existing profiles
88
+ const newPort = extractConfiguredPort(profile);
89
+ if (newPort !== undefined && meta.browser) {
90
+ for (const [existingName, existingConfig] of Object.entries(meta.browser)) {
91
+ const existingProfile = configToProfile(existingName, existingConfig);
92
+ const existingPort = extractConfiguredPort(existingProfile);
93
+ if (existingPort === newPort) {
94
+ throw new Error(`Port ${newPort} is already used by profile "${existingName}". ` +
95
+ `Each profile must own a unique port. Use a different port or omit --endpoint to auto-assign.`);
96
+ }
97
+ }
98
+ }
99
+ // Resolve the browser binary at create time. Fails fast with an actionable
100
+ // error ("Comet not installed at /Applications/Comet.app") rather than
101
+ // deferring the failure to the first task. `findBrowserPath` short-circuits
102
+ // for browser=custom without a binary by throwing — same outcome.
103
+ findBrowserPath(profile.browser, profile.binary);
59
104
  meta.browser = meta.browser ?? {};
60
105
  meta.browser[profile.name] = profileToConfig(profile);
61
106
  writeMeta(meta);
@@ -17,6 +17,15 @@ export declare class BrowserService {
17
17
  tabId?: string;
18
18
  windowId?: string;
19
19
  }>;
20
+ /**
21
+ * Launch (or attach to) the profile's browser without creating a task. Used by
22
+ * `agents browser profiles launch <name>` so users can warm up the browser —
23
+ * including the first-run onboarding flow — before any automation starts.
24
+ */
25
+ launchProfile(profileName: string): Promise<{
26
+ port: number;
27
+ pid: number;
28
+ }>;
20
29
  stop(taskName: string): Promise<{
21
30
  ok: boolean;
22
31
  profile?: string;
@@ -60,6 +69,7 @@ export declare class BrowserService {
60
69
  type(taskId: string, ref: number, text: string, tabHint?: string): Promise<void>;
61
70
  press(taskId: string, key: string, tabHint?: string): Promise<void>;
62
71
  hover(taskId: string, ref: number, tabHint?: string): Promise<void>;
72
+ scroll(taskId: string, deltaX: number, deltaY: number, atX?: number, atY?: number, tabHint?: string): Promise<void>;
63
73
  status(profileName?: string): Promise<ProfileStatus[]>;
64
74
  private reconcileFromDisk;
65
75
  setViewport(taskId: string, width: number, height: number, options?: {
@@ -7,7 +7,7 @@ import { connectLocal } from './drivers/local.js';
7
7
  import { connectSSH } from './drivers/ssh.js';
8
8
  import { generateTaskId, generateShortId, generateFunName, } from './types.js';
9
9
  import { getRefs, resolveRefToCoords } from './refs.js';
10
- import { clickAtCoords, hoverAtCoords, typeText, pressKey, focusNode } from './input.js';
10
+ import { clickAtCoords, hoverAtCoords, scrollAtCoords, typeText, pressKey, focusNode } from './input.js';
11
11
  import { emit } from '../events.js';
12
12
  export class BrowserService {
13
13
  connections = new Map();
@@ -97,6 +97,24 @@ export class BrowserService {
97
97
  }
98
98
  return { task: taskId, name: taskName, tabId };
99
99
  }
100
+ /**
101
+ * Launch (or attach to) the profile's browser without creating a task. Used by
102
+ * `agents browser profiles launch <name>` so users can warm up the browser —
103
+ * including the first-run onboarding flow — before any automation starts.
104
+ */
105
+ async launchProfile(profileName) {
106
+ const profile = await getProfile(profileName);
107
+ if (!profile) {
108
+ throw new Error(`Profile "${profileName}" not found`);
109
+ }
110
+ let conn = this.connections.get(profileName);
111
+ if (!conn) {
112
+ conn = await this.connectProfile(profile);
113
+ this.connections.set(profileName, conn);
114
+ }
115
+ emit('browser.launch', { profile: profileName, task: '', pid: conn.pid });
116
+ return { port: conn.port, pid: conn.pid };
117
+ }
100
118
  async stop(taskName) {
101
119
  for (const [profileName, conn] of this.connections) {
102
120
  const task = conn.tasks.get(taskName);
@@ -424,6 +442,16 @@ export class BrowserService {
424
442
  const { x, y } = await resolveRefToCoords(conn.cdp, sessionId, nodeMap, ref);
425
443
  await hoverAtCoords(conn.cdp, sessionId, x, y);
426
444
  }
445
+ async scroll(taskId, deltaX, deltaY, atX, atY, tabHint) {
446
+ const { conn, task } = await this.findTask(taskId);
447
+ const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
448
+ const cdpTargetId = this.getCdpTargetId(task, shortId);
449
+ const target = await this.getTarget(conn, cdpTargetId);
450
+ if (!target)
451
+ throw new Error(`Tab ${shortId} not found`);
452
+ const sessionId = await this.getSessionId(conn, target.targetId);
453
+ await scrollAtCoords(conn.cdp, sessionId, atX ?? 0, atY ?? 0, deltaX, deltaY);
454
+ }
427
455
  async status(profileName) {
428
456
  const seen = new Set();
429
457
  const statuses = [];
@@ -11,6 +11,8 @@ export interface BrowserProfile {
11
11
  viewport?: {
12
12
  width: number;
13
13
  height: number;
14
+ x?: number;
15
+ y?: number;
14
16
  };
15
17
  }
16
18
  export interface ChromeOptions {
@@ -19,6 +21,8 @@ export interface ChromeOptions {
19
21
  viewport?: {
20
22
  width: number;
21
23
  height: number;
24
+ x?: number;
25
+ y?: number;
22
26
  };
23
27
  }
24
28
  export interface Task {
@@ -69,7 +73,7 @@ export interface HistoricalTask {
69
73
  domains: string[];
70
74
  tabCount: number;
71
75
  }
72
- export type IPCAction = 'start' | 'done' | 'stop' | 'status' | 'history' | 'navigate' | 'tab-add' | 'tab-focus' | 'tab-close' | 'tab-list' | 'evaluate' | 'screenshot' | 'refs' | 'click' | 'type' | 'press' | 'hover' | 'set-viewport' | 'set-device' | 'console' | 'errors' | 'requests' | 'response-body' | 'wait' | 'set-download-path' | 'wait-download';
76
+ export type IPCAction = 'start' | 'launch-profile' | 'done' | 'stop' | 'status' | 'history' | 'navigate' | 'tab-add' | 'tab-focus' | 'tab-close' | 'tab-list' | 'evaluate' | 'screenshot' | 'refs' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'set-viewport' | 'set-device' | 'console' | 'errors' | 'requests' | 'response-body' | 'wait' | 'set-download-path' | 'wait-download';
73
77
  export interface IPCRequest {
74
78
  action: IPCAction;
75
79
  task?: string;
@@ -82,6 +86,10 @@ export interface IPCRequest {
82
86
  ref?: number;
83
87
  text?: string;
84
88
  key?: string;
89
+ scrollX?: number;
90
+ scrollY?: number;
91
+ scrollAtX?: number;
92
+ scrollAtY?: number;
85
93
  interactive?: boolean;
86
94
  limit?: number;
87
95
  width?: number;
@@ -111,6 +119,8 @@ export interface IPCResponse {
111
119
  result?: unknown;
112
120
  path?: string;
113
121
  refs?: string;
122
+ port?: number;
123
+ pid?: number;
114
124
  logs?: ConsoleEntry[];
115
125
  errors?: ErrorEntry[];
116
126
  requests?: NetworkRequest[];
@@ -5,6 +5,8 @@
5
5
  * Requires the Rush GitHub App installed on the target repo.
6
6
  */
7
7
  import type { CloudProvider, CloudTask, CloudTaskStatus, CloudEvent, DispatchOptions, ProviderCapabilities } from './types.js';
8
+ export declare const RUSH_CONSENT_PATH: string;
9
+ export declare function hasRushUploadConsent(opts?: DispatchOptions): boolean;
8
10
  /** One version's entry in the account manifest sent on every dispatch. */
9
11
  export interface AccountManifestEntry {
10
12
  version: string;
@@ -35,7 +37,7 @@ export interface AccountTokenEntry {
35
37
  * Returns null when no Claude versions are signed in (the dispatch falls back
36
38
  * to the platform-wide key, current behavior).
37
39
  */
38
- export declare function buildAccountManifest(): Promise<AccountManifest | null>;
40
+ export declare function buildAccountManifest(strategy?: string): Promise<AccountManifest | null>;
39
41
  /**
40
42
  * Re-load OAuth blobs for the given versions so they can be uploaded to the
41
43
  * server on a retry. Only the versions named in the manifest are loaded — we
@@ -52,6 +54,7 @@ export declare function buildDispatchBody(input: {
52
54
  agent?: string;
53
55
  prompt: string;
54
56
  mode?: string;
57
+ strategy?: string;
55
58
  resolvedRepos: Array<{
56
59
  installation_id: number;
57
60
  repo_owner: string;
@@ -60,6 +63,30 @@ export declare function buildDispatchBody(input: {
60
63
  accountManifest?: AccountManifest | null;
61
64
  accountTokens?: AccountTokenEntry[] | null;
62
65
  }): Record<string, unknown>;
66
+ /** A single account registered in Rush Cloud's multi-account rotation pool. */
67
+ export interface RemoteAccount {
68
+ id: string;
69
+ provider: string;
70
+ email: string | null;
71
+ subscription_type: string | null;
72
+ five_hour_pct: number | null;
73
+ seven_day_pct: number | null;
74
+ usage_fetched_at: string | null;
75
+ created_at: string;
76
+ }
77
+ /** Fetch all Claude accounts in this user's Rush Cloud rotation pool (no tokens). */
78
+ export declare function listRemoteAccounts(): Promise<RemoteAccount[]>;
79
+ /**
80
+ * Register a CLAUDE_CODE_OAUTH_TOKEN with Rush Cloud's rotation pool.
81
+ * The server validates the token against the Anthropic usage API and stores it
82
+ * encrypted in Vault. Returns the account metadata (no token).
83
+ */
84
+ export declare function addRemoteAccount(provider: string, pastedToken: string): Promise<RemoteAccount & {
85
+ five_hour_pct: number | null;
86
+ seven_day_pct: number | null;
87
+ }>;
88
+ /** Remove a Claude account from Rush Cloud's rotation pool by its ID. */
89
+ export declare function removeRemoteAccount(id: string): Promise<void>;
63
90
  export declare class RushCloudProvider implements CloudProvider {
64
91
  id: "rush";
65
92
  name: string;
@@ -15,14 +15,15 @@ import { parseSSE } from './stream.js';
15
15
  import { listInstalledVersions, getVersionHomePath } from '../versions.js';
16
16
  import { getAccountInfo } from '../agents.js';
17
17
  import { loadClaudeOauth } from '../usage.js';
18
+ import { selectBalancedVersion } from '../rotate.js';
18
19
  const PROXY_BASE = 'https://api.prix.dev';
19
20
  const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
20
21
  // Persistent consent record for uploading Claude OAuth blobs to Rush Cloud.
21
22
  // Created on first explicit consent (env var or flag); subsequent dispatches
22
23
  // see it and proceed without re-prompting.
23
- const RUSH_CONSENT_PATH = path.join(getCloudDir(), 'rush-consent.json');
24
+ export const RUSH_CONSENT_PATH = path.join(getCloudDir(), 'rush-consent.json');
24
25
  const RUSH_CONSENT_ENV = 'AGENTS_RUSH_UPLOAD_TOKENS';
25
- function hasRushUploadConsent(opts) {
26
+ export function hasRushUploadConsent(opts) {
26
27
  if (process.env[RUSH_CONSENT_ENV] === '1')
27
28
  return true;
28
29
  const po = opts?.providerOptions;
@@ -184,20 +185,37 @@ async function readClaudeCredentialsBlob(home) {
184
185
  * Returns null when no Claude versions are signed in (the dispatch falls back
185
186
  * to the platform-wide key, current behavior).
186
187
  */
187
- export async function buildAccountManifest() {
188
- const versions = listInstalledVersions('claude');
189
- if (versions.length === 0)
190
- return null;
188
+ export async function buildAccountManifest(strategy) {
189
+ let candidateVersions;
190
+ if (strategy === 'balanced') {
191
+ // Use the same health-checked, deduped-by-email set that `agents run --balanced` uses.
192
+ // `result.healthy` contains one candidate per unique email, ordered by remaining capacity.
193
+ const result = await selectBalancedVersion('claude');
194
+ if (!result || result.healthy.length === 0)
195
+ return null;
196
+ candidateVersions = result.healthy
197
+ .filter((c) => !!c.email)
198
+ .map((c) => ({ version: c.version, email: c.email }));
199
+ }
200
+ else {
201
+ // Default: all installed versions that have a signed-in account.
202
+ const versions = listInstalledVersions('claude');
203
+ if (versions.length === 0)
204
+ return null;
205
+ const rows = await Promise.all(versions.map(async (version) => {
206
+ const home = getVersionHomePath('claude', version);
207
+ const info = await getAccountInfo('claude', home);
208
+ return info.email ? { version, email: info.email } : null;
209
+ }));
210
+ candidateVersions = rows.filter((r) => r !== null);
211
+ }
191
212
  const entries = [];
192
- for (const version of versions) {
213
+ for (const { version, email } of candidateVersions) {
193
214
  const home = getVersionHomePath('claude', version);
194
- const info = await getAccountInfo('claude', home);
195
- if (!info.email)
196
- continue;
197
215
  const blob = await readClaudeCredentialsBlob(home);
198
216
  if (!blob)
199
217
  continue;
200
- entries.push({ version, email: info.email, cred_fp: sha256(blob) });
218
+ entries.push({ version, email, cred_fp: sha256(blob) });
201
219
  }
202
220
  if (entries.length === 0)
203
221
  return null;
@@ -237,6 +255,7 @@ export function buildDispatchBody(input) {
237
255
  prompt: input.prompt,
238
256
  repos: input.resolvedRepos,
239
257
  mode: input.mode,
258
+ ...(input.strategy ? { strategy: input.strategy } : {}),
240
259
  };
241
260
  if (input.resolvedRepos.length === 1) {
242
261
  body.installation_id = primary.installation_id;
@@ -251,6 +270,37 @@ export function buildDispatchBody(input) {
251
270
  }
252
271
  return body;
253
272
  }
273
+ /** Fetch all Claude accounts in this user's Rush Cloud rotation pool (no tokens). */
274
+ export async function listRemoteAccounts() {
275
+ const token = readToken();
276
+ const res = await api('GET', '/api/v1/cloud-accounts', token);
277
+ if (!res.ok) {
278
+ throw new Error(`Failed to list accounts (${res.status}): ${sanitizeErrorBody(await res.text())}`);
279
+ }
280
+ const data = await res.json();
281
+ return data.accounts ?? [];
282
+ }
283
+ /**
284
+ * Register a CLAUDE_CODE_OAUTH_TOKEN with Rush Cloud's rotation pool.
285
+ * The server validates the token against the Anthropic usage API and stores it
286
+ * encrypted in Vault. Returns the account metadata (no token).
287
+ */
288
+ export async function addRemoteAccount(provider, pastedToken) {
289
+ const token = readToken();
290
+ const res = await api('POST', '/api/v1/cloud-accounts', token, { provider, token: pastedToken });
291
+ if (!res.ok) {
292
+ throw new Error(`Failed to add account (${res.status}): ${sanitizeErrorBody(await res.text())}`);
293
+ }
294
+ return await res.json();
295
+ }
296
+ /** Remove a Claude account from Rush Cloud's rotation pool by its ID. */
297
+ export async function removeRemoteAccount(id) {
298
+ const token = readToken();
299
+ const res = await api('DELETE', `/api/v1/cloud-accounts/${encodeURIComponent(id)}`, token);
300
+ if (!res.ok) {
301
+ throw new Error(`Failed to remove account (${res.status}): ${sanitizeErrorBody(await res.text())}`);
302
+ }
303
+ }
254
304
  export class RushCloudProvider {
255
305
  id = 'rush';
256
306
  name = 'Rush Cloud';
@@ -289,13 +339,18 @@ export class RushCloudProvider {
289
339
  repo_owner: r.owner,
290
340
  repo_name: r.name,
291
341
  })));
292
- const accountManifest = await buildAccountManifest();
342
+ const strategy = options.providerOptions?.strategy;
343
+ // When balanced, the server owns the pool and rotates internally — no
344
+ // client-side manifest needed. We just forward the strategy so the server
345
+ // knows to load from Vault instead of waiting for a manifest.
346
+ const accountManifest = strategy === 'balanced' ? null : await buildAccountManifest();
293
347
  const body = buildDispatchBody({
294
348
  agent: options.agent,
295
349
  prompt: options.prompt,
296
350
  mode: options.providerOptions?.mode,
297
351
  resolvedRepos,
298
352
  accountManifest,
353
+ strategy,
299
354
  });
300
355
  let res = await api('POST', '/api/v1/cloud-runs', token, body);
301
356
  // Server detects drift (new account or rotated token) by comparing the
@@ -317,7 +372,7 @@ export class RushCloudProvider {
317
372
  ``,
318
373
  `To consent, re-run with one of:`,
319
374
  ` AGENTS_RUSH_UPLOAD_TOKENS=1 agents cloud run ...`,
320
- ` agents cloud run --upload-account-tokens ... # if your CLI exposes this flag`,
375
+ ` agents cloud run --upload-account-tokens ...`,
321
376
  ``,
322
377
  `Consent will be recorded at ${RUSH_CONSENT_PATH} so you won't be asked again.`,
323
378
  `Remove that file to revoke.`,
@@ -35,10 +35,6 @@ export interface InstalledCommand {
35
35
  path: string;
36
36
  description?: string;
37
37
  }
38
- /** Parse command metadata (name, description) from YAML frontmatter or TOML headers. */
39
- export declare function parseCommandMetadata(filePath: string): CommandMetadata | null;
40
- /** Validate command metadata, returning errors and warnings. */
41
- export declare function validateCommandMetadata(metadata: CommandMetadata | null, commandName: string): ValidationResult;
42
38
  /** Discover all command markdown files in a repository's commands/ directory. */
43
39
  export declare function discoverCommands(repoPath: string): DiscoveredCommand[];
44
40
  /** Find the source path for a command in a repository. */
@@ -98,17 +94,6 @@ export declare function iterCommandsCapableVersions(filter?: {
98
94
  }>;
99
95
  /** Remove a command from an agent's config directory. */
100
96
  export declare function uninstallCommand(agentId: AgentId, commandName: string): boolean;
101
- /** List command names installed for an agent in the active version home. */
102
- export declare function listInstalledCommands(agentId: AgentId): string[];
103
- /**
104
- * Check if a command exists for an agent.
105
- */
106
- export declare function commandExists(agentId: AgentId, commandName: string): boolean;
107
- /**
108
- * Check if installed command content matches source content.
109
- * Handles format conversion (markdown to TOML for Gemini).
110
- */
111
- export declare function commandContentMatches(agentId: AgentId, commandName: string, sourcePath: string): boolean;
112
97
  /**
113
98
  * List installed commands with scope information.
114
99
  * Pass options.home to read from a version-managed agent's home directory.
@@ -15,7 +15,7 @@ import { getCommandsDir, getUserCommandsDir, getEnabledExtraRepos, getProjectAge
15
15
  import { getEffectiveHome, getVersionHomePath, listInstalledVersions } from './versions.js';
16
16
  import { commandSkillMatches, installCommandSkillToVersion, listCommandSkillsInVersion, removeCommandSkillFromVersion, shouldInstallCommandAsSkill, } from './command-skills.js';
17
17
  /** Parse command metadata (name, description) from YAML frontmatter or TOML headers. */
18
- export function parseCommandMetadata(filePath) {
18
+ function parseCommandMetadata(filePath) {
19
19
  if (!fs.existsSync(filePath)) {
20
20
  return null;
21
21
  }
@@ -51,7 +51,7 @@ export function parseCommandMetadata(filePath) {
51
51
  }
52
52
  }
53
53
  /** Validate command metadata, returning errors and warnings. */
54
- export function validateCommandMetadata(metadata, commandName) {
54
+ function validateCommandMetadata(metadata, commandName) {
55
55
  const errors = [];
56
56
  const warnings = [];
57
57
  if (!metadata) {
@@ -336,7 +336,7 @@ export function uninstallCommand(agentId, commandName) {
336
336
  return false;
337
337
  }
338
338
  /** List command names installed for an agent in the active version home. */
339
- export function listInstalledCommands(agentId) {
339
+ function listInstalledCommands(agentId) {
340
340
  const agent = AGENTS[agentId];
341
341
  const home = getEffectiveHome(agentId);
342
342
  const commandsDir = path.join(home, `.${agentId}`, agent.commandsSubdir);
@@ -352,7 +352,7 @@ export function listInstalledCommands(agentId) {
352
352
  /**
353
353
  * Check if a command exists for an agent.
354
354
  */
355
- export function commandExists(agentId, commandName) {
355
+ function commandExists(agentId, commandName) {
356
356
  const agent = AGENTS[agentId];
357
357
  const home = getEffectiveHome(agentId);
358
358
  const commandsDir = path.join(home, `.${agentId}`, agent.commandsSubdir);
@@ -370,7 +370,7 @@ function normalizeContent(content) {
370
370
  * Check if installed command content matches source content.
371
371
  * Handles format conversion (markdown to TOML for Gemini).
372
372
  */
373
- export function commandContentMatches(agentId, commandName, sourcePath) {
373
+ function commandContentMatches(agentId, commandName, sourcePath) {
374
374
  const agent = AGENTS[agentId];
375
375
  const home = getEffectiveHome(agentId);
376
376
  const commandsDir = path.join(home, `.${agentId}`, agent.commandsSubdir);
package/dist/lib/hooks.js CHANGED
@@ -14,16 +14,15 @@ import * as TOML from 'smol-toml';
14
14
  import { AGENTS, HOOKS_CAPABLE_AGENTS } from './agents.js';
15
15
  import { supports, explainSkip } from './capabilities.js';
16
16
  import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
17
- import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir } from './state.js';
17
+ import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir, getEnabledExtraRepos } from './state.js';
18
18
  function getCentralHooksDir() { return getUserHooksDir(); }
19
19
  /**
20
- * Resolve a hook script's absolute path by checking the user dir first
21
- * (where `installHooksCentrally` lands new files) and falling back to the
22
- * system dir (where npm-shipped defaults live). Returns null if neither
23
- * exists. Mirrors the precedence used by `listCentralHooks`.
20
+ * Resolve a hook script's absolute path. Checks user dir first, then enabled
21
+ * extra repos in insertion order, then system dir. Returns null if not found.
24
22
  */
25
23
  function resolveHookScriptPath(script) {
26
- for (const root of [getUserAgentsDir(), getSystemAgentsDir()]) {
24
+ const extraDirs = getEnabledExtraRepos().map(e => e.dir);
25
+ for (const root of [getUserAgentsDir(), ...extraDirs, getSystemAgentsDir()]) {
27
26
  const candidate = path.join(root, 'hooks', script);
28
27
  if (fs.existsSync(candidate))
29
28
  return candidate;
@@ -32,14 +31,15 @@ function resolveHookScriptPath(script) {
32
31
  }
33
32
  /**
34
33
  * Prefixes used for stale-entry cleanup in agent settings files. A registered
35
- * hook command is considered "managed by us" if it lives under either
36
- * `~/.agents/hooks/` (user) or `~/.agents-system/hooks/` (system). Cleanup
37
- * filters use this list so leftover entries from either dir get garbage
38
- * collected on rewrite.
34
+ * hook command is considered "managed" if it lives under any known hooks dir
35
+ * (user, extra repos, or system). Entries from removed extra repos are also
36
+ * garbage-collected because they won't appear in this list any more.
39
37
  */
40
38
  function getManagedHookPrefixes() {
39
+ const extraDirs = getEnabledExtraRepos().map(e => e.dir);
41
40
  return [
42
41
  path.join(getUserAgentsDir(), 'hooks') + path.sep,
42
+ ...extraDirs.map(d => path.join(d, 'hooks') + path.sep),
43
43
  path.join(getSystemAgentsDir(), 'hooks') + path.sep,
44
44
  ];
45
45
  }
@@ -616,16 +616,29 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
616
616
  return { registered: [], errors: [] };
617
617
  }
618
618
  const overrideRoots = agentsDirOverride ? [agentsDirOverride] : null;
619
+ // Scripts are copied into the version home during sync — prefer that stable
620
+ // local path so registered commands don't break when source dirs change.
621
+ const localHooksDir = !overrideRoots
622
+ ? path.join(versionHome, `.${agentId}`, AGENTS[agentId].hooksDir)
623
+ : null;
619
624
  const resolveScript = (script) => {
620
625
  if (overrideRoots) {
621
626
  const candidate = path.join(overrideRoots[0], 'hooks', script);
622
627
  return fs.existsSync(candidate) ? candidate : null;
623
628
  }
629
+ if (localHooksDir) {
630
+ const local = path.join(localHooksDir, script);
631
+ if (fs.existsSync(local))
632
+ return local;
633
+ }
624
634
  return resolveHookScriptPath(script);
625
635
  };
626
636
  const managedPrefixes = overrideRoots
627
637
  ? [path.join(overrideRoots[0], 'hooks') + path.sep]
628
- : getManagedHookPrefixes();
638
+ : [
639
+ ...getManagedHookPrefixes(),
640
+ ...(localHooksDir ? [localHooksDir + path.sep] : []),
641
+ ];
629
642
  if (agentId === 'claude') {
630
643
  return registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes);
631
644
  }