@phnx-labs/agents-cli 1.14.1 → 1.14.3

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 (102) hide show
  1. package/README.md +31 -3
  2. package/dist/commands/browser.d.ts +2 -0
  3. package/dist/commands/browser.js +388 -0
  4. package/dist/commands/daemon.js +1 -1
  5. package/dist/commands/doctor.d.ts +16 -9
  6. package/dist/commands/doctor.js +248 -12
  7. package/dist/commands/exec.js +17 -17
  8. package/dist/commands/prune.js +9 -3
  9. package/dist/commands/refresh-rules.d.ts +15 -0
  10. package/dist/commands/{refresh-memory.js → refresh-rules.js} +14 -14
  11. package/dist/commands/routines.js +1 -1
  12. package/dist/commands/rules.js +100 -4
  13. package/dist/commands/secrets.js +206 -12
  14. package/dist/commands/sync.js +19 -0
  15. package/dist/commands/teams.js +162 -22
  16. package/dist/commands/trash.d.ts +10 -0
  17. package/dist/commands/trash.js +187 -0
  18. package/dist/commands/view.js +46 -13
  19. package/dist/index.js +62 -4
  20. package/dist/lib/agents.js +2 -2
  21. package/dist/lib/browser/cdp.d.ts +24 -0
  22. package/dist/lib/browser/cdp.js +94 -0
  23. package/dist/lib/browser/chrome.d.ts +16 -0
  24. package/dist/lib/browser/chrome.js +157 -0
  25. package/dist/lib/browser/drivers/local.d.ts +8 -0
  26. package/dist/lib/browser/drivers/local.js +22 -0
  27. package/dist/lib/browser/drivers/ssh.d.ts +9 -0
  28. package/dist/lib/browser/drivers/ssh.js +129 -0
  29. package/dist/lib/browser/index.d.ts +5 -0
  30. package/dist/lib/browser/index.js +5 -0
  31. package/dist/lib/browser/input.d.ts +6 -0
  32. package/dist/lib/browser/input.js +52 -0
  33. package/dist/lib/browser/ipc.d.ts +12 -0
  34. package/dist/lib/browser/ipc.js +223 -0
  35. package/dist/lib/browser/profiles.d.ts +11 -0
  36. package/dist/lib/browser/profiles.js +61 -0
  37. package/dist/lib/browser/refs.d.ts +21 -0
  38. package/dist/lib/browser/refs.js +88 -0
  39. package/dist/lib/browser/service.d.ts +45 -0
  40. package/dist/lib/browser/service.js +404 -0
  41. package/dist/lib/browser/types.d.ts +73 -0
  42. package/dist/lib/browser/types.js +7 -0
  43. package/dist/lib/cloud/codex.js +1 -1
  44. package/dist/lib/cloud/registry.js +2 -2
  45. package/dist/lib/cloud/rush.js +2 -2
  46. package/dist/lib/cloud/store.js +2 -2
  47. package/dist/lib/daemon.d.ts +1 -1
  48. package/dist/lib/daemon.js +47 -11
  49. package/dist/lib/diff-text.d.ts +25 -0
  50. package/dist/lib/diff-text.js +47 -0
  51. package/dist/lib/doctor-diff.d.ts +64 -0
  52. package/dist/lib/doctor-diff.js +497 -0
  53. package/dist/lib/git.js +3 -3
  54. package/dist/lib/hooks.d.ts +6 -0
  55. package/dist/lib/hooks.js +6 -1
  56. package/dist/lib/migrate.js +77 -0
  57. package/dist/lib/pty-client.js +3 -3
  58. package/dist/lib/pty-server.js +36 -7
  59. package/dist/lib/resources.js +1 -1
  60. package/dist/lib/rotate.d.ts +43 -26
  61. package/dist/lib/rotate.js +99 -44
  62. package/dist/lib/rules/compile.d.ts +104 -0
  63. package/dist/lib/{memory-compile.js → rules/compile.js} +160 -21
  64. package/dist/lib/rules/compose.d.ts +78 -0
  65. package/dist/lib/rules/compose.js +170 -0
  66. package/dist/lib/{memory.d.ts → rules/rules.d.ts} +5 -5
  67. package/dist/lib/{memory.js → rules/rules.js} +10 -10
  68. package/dist/lib/secrets/AgentsKeychain.app/Contents/CodeResources +0 -0
  69. package/dist/lib/secrets/AgentsKeychain.app/Contents/MacOS/AgentsKeychain +0 -0
  70. package/dist/lib/secrets/bundles.d.ts +61 -4
  71. package/dist/lib/secrets/bundles.js +222 -54
  72. package/dist/lib/secrets/index.d.ts +24 -5
  73. package/dist/lib/secrets/index.js +70 -41
  74. package/dist/lib/session/active.js +5 -5
  75. package/dist/lib/session/db.js +4 -4
  76. package/dist/lib/session/discover.js +2 -2
  77. package/dist/lib/session/render.js +21 -7
  78. package/dist/lib/shims.d.ts +28 -4
  79. package/dist/lib/shims.js +72 -14
  80. package/dist/lib/state.d.ts +22 -28
  81. package/dist/lib/state.js +83 -76
  82. package/dist/lib/sync-manifest.d.ts +2 -2
  83. package/dist/lib/sync-manifest.js +5 -5
  84. package/dist/lib/teams/agents.d.ts +4 -2
  85. package/dist/lib/teams/agents.js +11 -4
  86. package/dist/lib/teams/api.d.ts +1 -1
  87. package/dist/lib/teams/api.js +2 -2
  88. package/dist/lib/teams/index.d.ts +1 -0
  89. package/dist/lib/teams/index.js +1 -0
  90. package/dist/lib/teams/persistence.js +3 -3
  91. package/dist/lib/teams/registry.d.ts +8 -1
  92. package/dist/lib/teams/registry.js +8 -2
  93. package/dist/lib/teams/worktree.d.ts +30 -0
  94. package/dist/lib/teams/worktree.js +96 -0
  95. package/dist/lib/types.d.ts +13 -7
  96. package/dist/lib/types.js +3 -3
  97. package/dist/lib/versions.d.ts +30 -2
  98. package/dist/lib/versions.js +127 -105
  99. package/package.json +1 -1
  100. package/scripts/postinstall.js +29 -0
  101. package/dist/commands/refresh-memory.d.ts +0 -15
  102. package/dist/lib/memory-compile.d.ts +0 -66
@@ -76,10 +76,87 @@ function migratePromptcutsIntoHooks() {
76
76
  catch { /* best-effort */ }
77
77
  }
78
78
  }
79
+ /**
80
+ * Move installed agent versions from the legacy single-root layout
81
+ * (~/.agents/versions/<agent>/<ver>/) into the system root
82
+ * (~/.agents-system/versions/<agent>/<ver>/).
83
+ *
84
+ * Pre-split installs put binaries and home dirs under ~/.agents/. After the
85
+ * split, the system code only scans ~/.agents-system/versions/, so without
86
+ * this migration the versions become invisible to listInstalledVersions and
87
+ * every command that depends on it (view, prune, run).
88
+ *
89
+ * Idempotent and non-destructive: if a same-named dest already exists we
90
+ * leave the legacy copy in place so the user can reconcile manually.
91
+ */
92
+ function migrateUserVersionsToSystem() {
93
+ const userVersions = path.join(USER_DIR, 'versions');
94
+ const sysVersions = path.join(SYSTEM_DIR, 'versions');
95
+ if (!fs.existsSync(userVersions))
96
+ return;
97
+ let movedCount = 0;
98
+ let skippedCount = 0;
99
+ let agentEntries;
100
+ try {
101
+ agentEntries = fs.readdirSync(userVersions, { withFileTypes: true });
102
+ }
103
+ catch {
104
+ return;
105
+ }
106
+ for (const agent of agentEntries) {
107
+ if (!agent.isDirectory())
108
+ continue;
109
+ const srcAgentDir = path.join(userVersions, agent.name);
110
+ const dstAgentDir = path.join(sysVersions, agent.name);
111
+ try {
112
+ fs.mkdirSync(dstAgentDir, { recursive: true, mode: 0o700 });
113
+ }
114
+ catch { /* best-effort */ }
115
+ let verEntries;
116
+ try {
117
+ verEntries = fs.readdirSync(srcAgentDir, { withFileTypes: true });
118
+ }
119
+ catch {
120
+ continue;
121
+ }
122
+ for (const ver of verEntries) {
123
+ if (!ver.isDirectory())
124
+ continue;
125
+ const src = path.join(srcAgentDir, ver.name);
126
+ const dst = path.join(dstAgentDir, ver.name);
127
+ if (fs.existsSync(dst)) {
128
+ skippedCount++;
129
+ continue;
130
+ }
131
+ try {
132
+ fs.renameSync(src, dst);
133
+ movedCount++;
134
+ }
135
+ catch { /* best-effort, leave legacy in place */ }
136
+ }
137
+ try {
138
+ if (fs.readdirSync(srcAgentDir).length === 0)
139
+ fs.rmdirSync(srcAgentDir);
140
+ }
141
+ catch { /* best-effort */ }
142
+ }
143
+ try {
144
+ if (fs.readdirSync(userVersions).length === 0)
145
+ fs.rmdirSync(userVersions);
146
+ }
147
+ catch { /* best-effort */ }
148
+ if (movedCount > 0) {
149
+ console.log(`Migrated ${movedCount} version dir${movedCount === 1 ? '' : 's'} from ~/.agents/versions/ to ~/.agents-system/versions/`);
150
+ }
151
+ if (skippedCount > 0) {
152
+ console.log(`Skipped ${skippedCount} version dir${skippedCount === 1 ? '' : 's'} already present in ~/.agents-system/versions/ (kept legacy copy at ~/.agents/versions/)`);
153
+ }
154
+ }
79
155
  /** Run all idempotent migrations. Safe to call multiple times. */
80
156
  export function runMigration() {
81
157
  migrateAgentsYaml();
82
158
  deleteSystemPromptsJson();
83
159
  migrateSystemConfigJson();
84
160
  migratePromptcutsIntoHooks();
161
+ migrateUserVersionsToSystem();
85
162
  }
@@ -9,7 +9,7 @@ import * as fs from 'fs';
9
9
  import { spawn, execSync } from 'child_process';
10
10
  import { fileURLToPath } from 'url';
11
11
  import * as path from 'path';
12
- import { getSocketPath, isPtyServerRunning } from './pty-server.js';
12
+ import { getSocketPath, getPtyLogPath, isPtyServerRunning } from './pty-server.js';
13
13
  const CONNECT_TIMEOUT_MS = 5000;
14
14
  const RESPONSE_TIMEOUT_MS = 30000;
15
15
  /**
@@ -33,7 +33,7 @@ async function ensureServer() {
33
33
  return;
34
34
  // Find the entry point to spawn the server
35
35
  const { bin, args } = getServerSpawnArgs();
36
- const logPath = path.join(path.dirname(getSocketPath()), 'pty.log');
36
+ const logPath = getPtyLogPath();
37
37
  const logFd = fs.openSync(logPath, 'a');
38
38
  const child = spawn(bin, args, {
39
39
  stdio: ['ignore', logFd, logFd],
@@ -57,7 +57,7 @@ async function ensureServer() {
57
57
  }
58
58
  await new Promise(r => setTimeout(r, 100));
59
59
  }
60
- throw new Error('PTY server failed to start within 5 seconds. Check ~/.agents/pty.log');
60
+ throw new Error('PTY server failed to start within 5 seconds. Check ~/.agents-system/helpers/pty/logs.jsonl');
61
61
  }
62
62
  function getServerSpawnArgs() {
63
63
  // Prefer the dist/index.js from the same installation as this code.
@@ -53,9 +53,12 @@ export function captureProcessStartTime(pid) {
53
53
  }
54
54
  // --- Constants ---
55
55
  const SENTINEL = '__AGENTS_PTY_DONE__';
56
+ const PTY_DIR = 'helpers/pty';
56
57
  const SOCKET_NAME = 'pty.sock';
57
58
  const PID_FILE = 'pty.pid';
58
- const LOG_FILE = 'pty.log';
59
+ const LOG_FILE = 'logs.jsonl';
60
+ const LOG_MAX_SIZE = 5 * 1024 * 1024; // 5 MB
61
+ const LOG_ROTATE_COUNT = 3;
59
62
  const SESSION_IDLE_MS = 30 * 60 * 1000; // 30 min
60
63
  const SERVER_IDLE_MS = 60 * 60 * 1000; // 1 hour
61
64
  // --- Path helpers ---
@@ -79,17 +82,24 @@ function buildPtyEnv() {
79
82
  }
80
83
  return env;
81
84
  }
85
+ /** Get the PTY helper directory, creating it if needed. */
86
+ function getPtyDir() {
87
+ const dir = path.join(getAgentsDir(), PTY_DIR);
88
+ fs.mkdirSync(dir, { recursive: true });
89
+ return dir;
90
+ }
82
91
  /** Get the unix socket path for the PTY server. */
83
92
  export function getSocketPath() {
84
- return path.join(getAgentsDir(), SOCKET_NAME);
93
+ return path.join(getPtyDir(), SOCKET_NAME);
85
94
  }
86
95
  /** Get the path to the PTY server PID file. */
87
96
  export function getPtyPidPath() {
88
- return path.join(getAgentsDir(), PID_FILE);
97
+ return path.join(getPtyDir(), PID_FILE);
89
98
  }
90
99
  /** Get the path to the PTY server log file. */
91
100
  export function getPtyLogPath() {
92
- return path.join(getAgentsDir(), LOG_FILE);
101
+ const logDir = getPtyDir();
102
+ return path.join(logDir, LOG_FILE);
93
103
  }
94
104
  /** Check if the PTY server process is alive by probing the stored PID. */
95
105
  export function isPtyServerRunning() {
@@ -112,11 +122,30 @@ export function isPtyServerRunning() {
112
122
  }
113
123
  }
114
124
  // --- Logging ---
125
+ function rotateLogsIfNeeded(logPath) {
126
+ try {
127
+ const stat = fs.statSync(logPath);
128
+ if (stat.size < LOG_MAX_SIZE)
129
+ return;
130
+ for (let i = LOG_ROTATE_COUNT - 1; i >= 1; i--) {
131
+ const older = `${logPath}.${i}`;
132
+ const newer = i === 1 ? logPath : `${logPath}.${i - 1}`;
133
+ if (fs.existsSync(newer)) {
134
+ fs.renameSync(newer, older);
135
+ }
136
+ }
137
+ if (fs.existsSync(logPath)) {
138
+ fs.renameSync(logPath, `${logPath}.1`);
139
+ }
140
+ }
141
+ catch { }
142
+ }
115
143
  function log(level, message) {
116
- const ts = new Date().toISOString();
117
- const line = `[${ts}] [${level}] ${message}\n`;
144
+ const logPath = getPtyLogPath();
145
+ rotateLogsIfNeeded(logPath);
146
+ const entry = { ts: new Date().toISOString(), level, message };
118
147
  try {
119
- fs.appendFileSync(getPtyLogPath(), line, 'utf-8');
148
+ fs.appendFileSync(logPath, JSON.stringify(entry) + '\n', 'utf-8');
120
149
  }
121
150
  catch { }
122
151
  }
@@ -8,7 +8,7 @@ import { AGENTS, listInstalledMcpsWithScope } from './agents.js';
8
8
  import { listInstalledCommandsWithScope } from './commands.js';
9
9
  import { listInstalledSkillsWithScope } from './skills.js';
10
10
  import { listInstalledHooksWithScope } from './hooks.js';
11
- import { listInstalledInstructionsWithScope } from './memory.js';
11
+ import { listInstalledInstructionsWithScope } from './rules/rules.js';
12
12
  import { getEffectiveHome } from './versions.js';
13
13
  import { listMcpServerConfigs } from './mcp.js';
14
14
  import { getProjectAgentsDir, getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos, } from './state.js';
@@ -25,32 +25,51 @@ export interface RotateResult {
25
25
  excluded: RotateCandidate[];
26
26
  }
27
27
  export declare const RUN_STRATEGIES: RunStrategy[];
28
- /** Return a run strategy when the input is valid, otherwise null. */
28
+ /**
29
+ * Return a run strategy when the input is valid, otherwise null.
30
+ *
31
+ * `'rotate'` is accepted as a deprecated alias for `'balanced'` so old yaml
32
+ * configs and `--strategy rotate` invocations keep working. The legacy alias
33
+ * normalizes to `'balanced'` and uses the weighted-random algorithm.
34
+ */
29
35
  export declare function normalizeRunStrategy(value: unknown): RunStrategy | null;
30
36
  /** Read project-local run strategy from the nearest agents.yaml, if present. */
31
37
  export declare function getProjectRunStrategy(agent: AgentId, startPath: string): RunStrategy | null;
32
- /** Resolve the configured strategy: project agents.yaml, then ~/.agents-system/agents.yaml, then pinned. */
38
+ /**
39
+ * Resolve the configured strategy. Lookup order:
40
+ * 1. project-local agents.yaml (nearest to `startPath`)
41
+ * 2. ~/.agents-system/agents.yaml
42
+ * 3. default: `available` (use the pinned default version when healthy,
43
+ * otherwise fall through to a healthy account so a single rate-limited
44
+ * account doesn't block the run).
45
+ */
33
46
  export declare function getConfiguredRunStrategy(agent: AgentId, startPath?: string): RunStrategy;
34
47
  /** Persist the global run strategy used by bare `agents run <agent>`. */
35
48
  export declare function setGlobalRunStrategy(agent: AgentId, strategy: RunStrategy): void;
36
49
  /**
37
- * Pure selection: given a set of candidates, return the best one for the
38
- * next run. Kept separate from I/O so it can be unit-tested with fixtures.
50
+ * Pick a healthy candidate using weighted random by remaining capacity.
51
+ *
52
+ * Each healthy candidate gets weight = max(1, 100 - usedPercent) where
53
+ * usedPercent is the highest-utilized non-session window (week / sonnet_week
54
+ * for Claude). An account at 10% used gets weight 90; one at 90% used gets
55
+ * weight 10 — so the fresher account is 9× more likely to be picked. Over N
56
+ * calls, traffic distributes across healthy accounts proportional to their
57
+ * headroom, with no stampede on the lowest-usage one. Stateless — parallel
58
+ * callers naturally fan out via the random roll.
59
+ *
60
+ * Eligibility: signed in (email present), auth valid, and usage available
61
+ * (any non-session window strictly under 100%, or local flag not exhausted
62
+ * when no live snapshot exists).
63
+ *
64
+ * Dedupe: when multiple versions share an email, collapse to one candidate
65
+ * per email (the least-recently-active version). Prevents two parallel pods
66
+ * from "balancing" to different versions but hitting the same Anthropic
67
+ * account and both 429ing.
39
68
  *
40
- * Eligibility: signed in (email present) and not out of credits.
41
- * Dedupe: when multiple versions share an email (same Anthropic account
42
- * installed under several agent versions), collapse to one candidate per
43
- * email — the least-recently-active version. Without this, two parallel
44
- * pods could "rotate" to different versions but hit the same account and
45
- * both 429 against the same Anthropic quota.
46
- * Primary order: lowest live usage utilization wins. Least-recently-active is
47
- * the tie-breaker when usage is equal or unavailable. Never-used versions sort
48
- * oldest so fresh installs are tried before recently-used ones.
49
- * Tie-break: random — when two candidates share a `lastActive` timestamp
50
- * (common when N pods read the same snapshot), distribute across them so
51
- * parallel callers fan out instead of all picking the same version.
69
+ * Returns null if no candidate is eligible callers fall back to the pinned
70
+ * version so behavior stays predictable.
52
71
  */
53
- export declare function pickRotateCandidate(candidates: RotateCandidate[]): RotateResult | null;
72
+ export declare function pickBalancedCandidate(candidates: RotateCandidate[]): RotateResult | null;
54
73
  /**
55
74
  * Pick an available candidate. Prefers the configured pinned version when that
56
75
  * version has usage available; otherwise routes to the candidate with the most
@@ -58,19 +77,17 @@ export declare function pickRotateCandidate(candidates: RotateCandidate[]): Rota
58
77
  */
59
78
  export declare function pickAvailableCandidate(candidates: RotateCandidate[], preferredVersion?: string | null): RotateResult | null;
60
79
  /**
61
- * Rotate across installed versions of an agent and pick the best one for the
62
- * next run. "Best" means: signed in, usage available, and lowest usage
63
- * utilization, with least-recently-active as a tie-breaker.
80
+ * Pick a healthy version for `agent` using weighted random by remaining
81
+ * capacity. See `pickBalancedCandidate` for algorithm details.
64
82
  *
65
- * No external state: rotation and health are both read off per-version
66
- * AccountInfo — the same data `agents view` already surfaces. `lastActive`
67
- * advances naturally after each run, so the cursor is self-maintaining.
83
+ * No external state health and capacity are both read off per-version
84
+ * AccountInfo (same data `agents view` surfaces). The weighted random roll
85
+ * keeps parallel callers fanned out without rotation files or locks.
68
86
  *
69
- * Returns null if no installed version is eligible (either nothing installed
70
- * or every account is exhausted / not signed in). Callers fall back to the
87
+ * Returns null if no installed version is eligible. Callers fall back to the
71
88
  * global default so behavior stays predictable — we never refuse to run.
72
89
  */
73
- export declare function selectRotateVersion(agent: AgentId): Promise<RotateResult | null>;
90
+ export declare function selectBalancedVersion(agent: AgentId): Promise<RotateResult | null>;
74
91
  /** Select the configured version if available, otherwise another available version. */
75
92
  export declare function selectAvailableVersion(agent: AgentId, preferredVersion?: string | null): Promise<RotateResult | null>;
76
93
  export declare function resolveRunVersion(agent: AgentId, strategy: RunStrategy, cwd?: string): Promise<{
@@ -11,12 +11,26 @@ import { getAccountInfo } from './agents.js';
11
11
  import { readMeta, writeMeta, getAgentsDir } from './state.js';
12
12
  import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
13
13
  import { getUsageInfoByIdentity, getUsageLookupKey, isClaudeAuthValid, } from './usage.js';
14
- export const RUN_STRATEGIES = ['pinned', 'available', 'rotate'];
15
- /** Return a run strategy when the input is valid, otherwise null. */
14
+ const ROTATE_DIR = 'helpers/rotate';
15
+ function getRotateDir() {
16
+ const dir = path.join(getAgentsDir(), ROTATE_DIR);
17
+ fs.mkdirSync(dir, { recursive: true });
18
+ return dir;
19
+ }
20
+ export const RUN_STRATEGIES = ['pinned', 'available', 'balanced'];
21
+ /**
22
+ * Return a run strategy when the input is valid, otherwise null.
23
+ *
24
+ * `'rotate'` is accepted as a deprecated alias for `'balanced'` so old yaml
25
+ * configs and `--strategy rotate` invocations keep working. The legacy alias
26
+ * normalizes to `'balanced'` and uses the weighted-random algorithm.
27
+ */
16
28
  export function normalizeRunStrategy(value) {
17
- return typeof value === 'string' && RUN_STRATEGIES.includes(value)
18
- ? value
19
- : null;
29
+ if (typeof value !== 'string')
30
+ return null;
31
+ if (value === 'rotate')
32
+ return 'balanced';
33
+ return RUN_STRATEGIES.includes(value) ? value : null;
20
34
  }
21
35
  /** Read project-local run strategy from the nearest agents.yaml, if present. */
22
36
  export function getProjectRunStrategy(agent, startPath) {
@@ -39,11 +53,18 @@ export function getProjectRunStrategy(agent, startPath) {
39
53
  }
40
54
  return null;
41
55
  }
42
- /** Resolve the configured strategy: project agents.yaml, then ~/.agents-system/agents.yaml, then pinned. */
56
+ /**
57
+ * Resolve the configured strategy. Lookup order:
58
+ * 1. project-local agents.yaml (nearest to `startPath`)
59
+ * 2. ~/.agents-system/agents.yaml
60
+ * 3. default: `available` (use the pinned default version when healthy,
61
+ * otherwise fall through to a healthy account so a single rate-limited
62
+ * account doesn't block the run).
63
+ */
43
64
  export function getConfiguredRunStrategy(agent, startPath = process.cwd()) {
44
65
  return getProjectRunStrategy(agent, startPath)
45
66
  ?? normalizeRunStrategy(readMeta().run?.[agent]?.strategy)
46
- ?? 'pinned';
67
+ ?? 'available';
47
68
  }
48
69
  /** Persist the global run strategy used by bare `agents run <agent>`. */
49
70
  export function setGlobalRunStrategy(agent, strategy) {
@@ -112,23 +133,29 @@ function dedupeAndSortCandidates(candidates) {
112
133
  return [...byEmail.values()].sort(compareCandidates);
113
134
  }
114
135
  /**
115
- * Pure selection: given a set of candidates, return the best one for the
116
- * next run. Kept separate from I/O so it can be unit-tested with fixtures.
136
+ * Pick a healthy candidate using weighted random by remaining capacity.
137
+ *
138
+ * Each healthy candidate gets weight = max(1, 100 - usedPercent) where
139
+ * usedPercent is the highest-utilized non-session window (week / sonnet_week
140
+ * for Claude). An account at 10% used gets weight 90; one at 90% used gets
141
+ * weight 10 — so the fresher account is 9× more likely to be picked. Over N
142
+ * calls, traffic distributes across healthy accounts proportional to their
143
+ * headroom, with no stampede on the lowest-usage one. Stateless — parallel
144
+ * callers naturally fan out via the random roll.
117
145
  *
118
- * Eligibility: signed in (email present) and not out of credits.
119
- * Dedupe: when multiple versions share an email (same Anthropic account
120
- * installed under several agent versions), collapse to one candidate per
121
- * email — the least-recently-active version. Without this, two parallel
122
- * pods could "rotate" to different versions but hit the same account and
123
- * both 429 against the same Anthropic quota.
124
- * Primary order: lowest live usage utilization wins. Least-recently-active is
125
- * the tie-breaker when usage is equal or unavailable. Never-used versions sort
126
- * oldest so fresh installs are tried before recently-used ones.
127
- * Tie-break: randomwhen two candidates share a `lastActive` timestamp
128
- * (common when N pods read the same snapshot), distribute across them so
129
- * parallel callers fan out instead of all picking the same version.
146
+ * Eligibility: signed in (email present), auth valid, and usage available
147
+ * (any non-session window strictly under 100%, or local flag not exhausted
148
+ * when no live snapshot exists).
149
+ *
150
+ * Dedupe: when multiple versions share an email, collapse to one candidate
151
+ * per email (the least-recently-active version). Prevents two parallel pods
152
+ * from "balancing" to different versions but hitting the same Anthropic
153
+ * account and both 429ing.
154
+ *
155
+ * Returns null if no candidate is eligible callers fall back to the pinned
156
+ * version so behavior stays predictable.
130
157
  */
131
- export function pickRotateCandidate(candidates) {
158
+ export function pickBalancedCandidate(candidates) {
132
159
  const healthy = [];
133
160
  const excluded = [];
134
161
  for (const c of candidates) {
@@ -146,7 +173,33 @@ export function pickRotateCandidate(candidates) {
146
173
  if (!deduped.has(c))
147
174
  excluded.push(c);
148
175
  }
149
- return { picked: sorted[0], healthy: sorted, excluded };
176
+ const picked = weightedRandomByCapacity(sorted);
177
+ return { picked, healthy: sorted, excluded };
178
+ }
179
+ /**
180
+ * Pick one candidate from `sorted` using weights proportional to remaining
181
+ * routing capacity. Floor each weight at 1 so a near-exhausted-but-still-
182
+ * eligible candidate can still be picked occasionally. When usage is unknown
183
+ * (no live snapshot), treat the candidate as full-capacity (weight 100) — we
184
+ * have no signal to deprioritize it.
185
+ */
186
+ function weightedRandomByCapacity(sorted) {
187
+ const weights = sorted.map((c) => {
188
+ const used = getRoutingUsedPercent(c.usageSnapshot);
189
+ if (used === null)
190
+ return 100;
191
+ return Math.max(1, 100 - used);
192
+ });
193
+ const total = weights.reduce((sum, w) => sum + w, 0);
194
+ if (total <= 0)
195
+ return sorted[0];
196
+ let roll = Math.random() * total;
197
+ for (let i = 0; i < sorted.length; i++) {
198
+ roll -= weights[i];
199
+ if (roll <= 0)
200
+ return sorted[i];
201
+ }
202
+ return sorted[sorted.length - 1];
150
203
  }
151
204
  /**
152
205
  * Pick an available candidate. Prefers the configured pinned version when that
@@ -210,20 +263,18 @@ async function collectRunCandidates(agent) {
210
263
  });
211
264
  }
212
265
  /**
213
- * Rotate across installed versions of an agent and pick the best one for the
214
- * next run. "Best" means: signed in, usage available, and lowest usage
215
- * utilization, with least-recently-active as a tie-breaker.
266
+ * Pick a healthy version for `agent` using weighted random by remaining
267
+ * capacity. See `pickBalancedCandidate` for algorithm details.
216
268
  *
217
- * No external state: rotation and health are both read off per-version
218
- * AccountInfo — the same data `agents view` already surfaces. `lastActive`
219
- * advances naturally after each run, so the cursor is self-maintaining.
269
+ * No external state health and capacity are both read off per-version
270
+ * AccountInfo (same data `agents view` surfaces). The weighted random roll
271
+ * keeps parallel callers fanned out without rotation files or locks.
220
272
  *
221
- * Returns null if no installed version is eligible (either nothing installed
222
- * or every account is exhausted / not signed in). Callers fall back to the
273
+ * Returns null if no installed version is eligible. Callers fall back to the
223
274
  * global default so behavior stays predictable — we never refuse to run.
224
275
  */
225
- export async function selectRotateVersion(agent) {
226
- return pickRotateCandidate(await collectRunCandidates(agent));
276
+ export async function selectBalancedVersion(agent) {
277
+ return pickBalancedCandidate(await collectRunCandidates(agent));
227
278
  }
228
279
  /** Select the configured version if available, otherwise another available version. */
229
280
  export async function selectAvailableVersion(agent, preferredVersion) {
@@ -241,7 +292,7 @@ export async function selectAvailableVersion(agent, preferredVersion) {
241
292
  * a torn write just means the next reader sees a stale timestamp (harmless).
242
293
  */
243
294
  function recordRotationPick(agent, version) {
244
- const stampPath = path.join(getAgentsDir(), `rotate-stamp-${agent}.json`);
295
+ const stampPath = path.join(getRotateDir(), `stamp-${agent}.json`);
245
296
  try {
246
297
  fs.writeFileSync(stampPath, JSON.stringify({ version, ts: Date.now() }), 'utf-8');
247
298
  }
@@ -252,7 +303,7 @@ function recordRotationPick(agent, version) {
252
303
  * or stamp is older than 60 seconds (stale).
253
304
  */
254
305
  function readRotationStamp(agent) {
255
- const stampPath = path.join(getAgentsDir(), `rotate-stamp-${agent}.json`);
306
+ const stampPath = path.join(getRotateDir(), `stamp-${agent}.json`);
256
307
  try {
257
308
  const raw = JSON.parse(fs.readFileSync(stampPath, 'utf-8'));
258
309
  if (Date.now() - raw.ts < 60_000)
@@ -268,17 +319,21 @@ export async function resolveRunVersion(agent, strategy, cwd = process.cwd()) {
268
319
  }
269
320
  const rotation = strategy === 'available'
270
321
  ? await selectAvailableVersion(agent, fallback)
271
- : await selectRotateVersion(agent);
322
+ : await selectBalancedVersion(agent);
272
323
  if (rotation) {
273
- // If another process just picked the same version (within 60s), try the
274
- // next healthy candidate to distribute load across accounts.
275
- const recentPick = readRotationStamp(agent);
276
- if (recentPick === rotation.picked.version && rotation.healthy.length > 1) {
277
- const alt = rotation.healthy.find(c => c.version !== recentPick);
278
- if (alt)
279
- rotation.picked = alt;
324
+ // `available` is sticky to the pinned default when healthy. Use the 60s
325
+ // anti-collision stamp to nudge parallel callers off the same version.
326
+ // `balanced` doesn't need this — its weighted random roll already
327
+ // distributes naturally across healthy accounts.
328
+ if (strategy === 'available') {
329
+ const recentPick = readRotationStamp(agent);
330
+ if (recentPick === rotation.picked.version && rotation.healthy.length > 1) {
331
+ const alt = rotation.healthy.find(c => c.version !== recentPick);
332
+ if (alt)
333
+ rotation.picked = alt;
334
+ }
335
+ recordRotationPick(agent, rotation.picked.version);
280
336
  }
281
- recordRotationPick(agent, rotation.picked.version);
282
337
  return { version: rotation.picked.version, rotation };
283
338
  }
284
339
  return { version: fallback, rotation: null };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Rules file compilation -- resolving @-imports into a single flat file.
3
+ *
4
+ * Agents that do not natively resolve `@path/to/file` imports (Codex, Cursor)
5
+ * need a pre-compiled rules file with all imports inlined. This module
6
+ * handles that expansion for both user-scope (writes into version home) and
7
+ * project-scope (writes into the workspace).
8
+ */
9
+ import type { AgentId } from '../types.js';
10
+ import { type RulesLayer } from './compose.js';
11
+ /** Sidecar manifest recording source file hashes for staleness detection. */
12
+ export interface CompileManifest {
13
+ compiledAt: string;
14
+ sources: {
15
+ path: string;
16
+ sha256: string;
17
+ mtime?: number;
18
+ size?: number;
19
+ }[];
20
+ }
21
+ /** Result of resolving @-imports in a rules file. */
22
+ export interface ResolveResult {
23
+ /** Fully-inlined content. */
24
+ content: string;
25
+ /** Absolute paths of every file read during resolution (including the root). */
26
+ sources: string[];
27
+ }
28
+ /**
29
+ * Expand all `@path/to/file` imports in `content`, recursively up to
30
+ * MAX_DEPTH. Imports inside fenced code blocks and inline code spans are
31
+ * left alone, matching Claude Code's parser. Missing files are left as-is
32
+ * (silent skip), matching the documented behavior.
33
+ *
34
+ * Relative paths resolve against `baseDir`; absolute and tilde-prefixed
35
+ * paths resolve against the filesystem root / home directory.
36
+ */
37
+ export declare function resolveImports(content: string, baseDir: string): ResolveResult;
38
+ /** True if the agent's native runtime resolves `@path` imports in its rules file. */
39
+ export declare function supportsRulesImports(agentId: AgentId): boolean;
40
+ /**
41
+ * Fast staleness check. Returns true when:
42
+ * - the compiled file or its manifest is missing
43
+ * - any recorded source file is missing
44
+ * - any recorded source's sha256 no longer matches
45
+ *
46
+ * For agents that support @-imports natively, always returns false — there's
47
+ * nothing to compile.
48
+ */
49
+ export declare function isRulesStale(agentId: AgentId, version: string): boolean;
50
+ /**
51
+ * Resolve the source `rules/AGENTS.md` (with all @-imports expanded) and
52
+ * write the result into the version home, alongside a sidecar manifest that
53
+ * records source file hashes for staleness detection.
54
+ *
55
+ * Agents that natively resolve @-imports are skipped (no-op) — their sync
56
+ * uses the standard copyFileSync path in `syncResourcesToVersion`.
57
+ */
58
+ export declare function compileRulesForAgent(agentId: AgentId, version: string): {
59
+ compiled: boolean;
60
+ compiledPath: string;
61
+ sources: number;
62
+ };
63
+ /**
64
+ * Recompile rules if stale. Safe to call on every agent invocation — the
65
+ * staleness check is fast (sha256 of 8-10 small files, ~10-20ms). Returns
66
+ * true if a recompile happened, false otherwise.
67
+ */
68
+ export declare function ensureRulesFresh(agentId: AgentId, version: string): boolean;
69
+ export interface ProjectCompileResult {
70
+ /** True when cwd/AGENTS.md was newly written or rewritten. */
71
+ compiled: boolean;
72
+ /** Absolute path to cwd/AGENTS.md. Empty when no project rules dir was present. */
73
+ agentsPath: string;
74
+ /** Per-agent instruction filenames symlinked (or copied) to AGENTS.md. */
75
+ symlinks: string[];
76
+ /** Number of source files inlined (root + recursive @-imports). */
77
+ sources: number;
78
+ /** Per-agent files we left alone because the user wrote/owns them. */
79
+ skippedClobber: string[];
80
+ }
81
+ /**
82
+ * Compile project-scope rules into a workspace's root memory files so each
83
+ * agent's native loader picks them up.
84
+ *
85
+ * Composes rules from all available layers (project > user > extras > system)
86
+ * with project highest priority — so a project's `subrules/` and `rules.yaml`
87
+ * shadow user/system fragments and presets. Writes `cwd/AGENTS.md` with
88
+ * COMPILED_HEADER_PROJECT and creates symlinks (CLAUDE.md, GEMINI.md,
89
+ * .cursorrules, etc.) → AGENTS.md so every agent finds its expected file at
90
+ * cwd. The agent's own loader merges this project-level file with its
91
+ * user-level rules (in version home) at runtime.
92
+ *
93
+ * Don't-clobber guard: if `cwd/AGENTS.md` exists without our header, the user
94
+ * authored it — leave it alone and report via `skippedClobber`. Same for any
95
+ * pre-existing per-agent file or symlink that doesn't already point at
96
+ * AGENTS.md.
97
+ *
98
+ * No-op when `cwd/.agents/rules/` does not exist. Idempotent on repeated
99
+ * calls — content equality short-circuits the write.
100
+ */
101
+ export declare function compileRulesForProject(cwd: string, opts?: {
102
+ preset?: string;
103
+ layers?: RulesLayer[];
104
+ }): ProjectCompileResult;