@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.
- package/README.md +31 -3
- package/dist/commands/browser.d.ts +2 -0
- package/dist/commands/browser.js +388 -0
- package/dist/commands/daemon.js +1 -1
- package/dist/commands/doctor.d.ts +16 -9
- package/dist/commands/doctor.js +248 -12
- package/dist/commands/exec.js +17 -17
- package/dist/commands/prune.js +9 -3
- package/dist/commands/refresh-rules.d.ts +15 -0
- package/dist/commands/{refresh-memory.js → refresh-rules.js} +14 -14
- package/dist/commands/routines.js +1 -1
- package/dist/commands/rules.js +100 -4
- package/dist/commands/secrets.js +206 -12
- package/dist/commands/sync.js +19 -0
- package/dist/commands/teams.js +162 -22
- package/dist/commands/trash.d.ts +10 -0
- package/dist/commands/trash.js +187 -0
- package/dist/commands/view.js +46 -13
- package/dist/index.js +62 -4
- package/dist/lib/agents.js +2 -2
- package/dist/lib/browser/cdp.d.ts +24 -0
- package/dist/lib/browser/cdp.js +94 -0
- package/dist/lib/browser/chrome.d.ts +16 -0
- package/dist/lib/browser/chrome.js +157 -0
- package/dist/lib/browser/drivers/local.d.ts +8 -0
- package/dist/lib/browser/drivers/local.js +22 -0
- package/dist/lib/browser/drivers/ssh.d.ts +9 -0
- package/dist/lib/browser/drivers/ssh.js +129 -0
- package/dist/lib/browser/index.d.ts +5 -0
- package/dist/lib/browser/index.js +5 -0
- package/dist/lib/browser/input.d.ts +6 -0
- package/dist/lib/browser/input.js +52 -0
- package/dist/lib/browser/ipc.d.ts +12 -0
- package/dist/lib/browser/ipc.js +223 -0
- package/dist/lib/browser/profiles.d.ts +11 -0
- package/dist/lib/browser/profiles.js +61 -0
- package/dist/lib/browser/refs.d.ts +21 -0
- package/dist/lib/browser/refs.js +88 -0
- package/dist/lib/browser/service.d.ts +45 -0
- package/dist/lib/browser/service.js +404 -0
- package/dist/lib/browser/types.d.ts +73 -0
- package/dist/lib/browser/types.js +7 -0
- package/dist/lib/cloud/codex.js +1 -1
- package/dist/lib/cloud/registry.js +2 -2
- package/dist/lib/cloud/rush.js +2 -2
- package/dist/lib/cloud/store.js +2 -2
- package/dist/lib/daemon.d.ts +1 -1
- package/dist/lib/daemon.js +47 -11
- package/dist/lib/diff-text.d.ts +25 -0
- package/dist/lib/diff-text.js +47 -0
- package/dist/lib/doctor-diff.d.ts +64 -0
- package/dist/lib/doctor-diff.js +497 -0
- package/dist/lib/git.js +3 -3
- package/dist/lib/hooks.d.ts +6 -0
- package/dist/lib/hooks.js +6 -1
- package/dist/lib/migrate.js +77 -0
- package/dist/lib/pty-client.js +3 -3
- package/dist/lib/pty-server.js +36 -7
- package/dist/lib/resources.js +1 -1
- package/dist/lib/rotate.d.ts +43 -26
- package/dist/lib/rotate.js +99 -44
- package/dist/lib/rules/compile.d.ts +104 -0
- package/dist/lib/{memory-compile.js → rules/compile.js} +160 -21
- package/dist/lib/rules/compose.d.ts +78 -0
- package/dist/lib/rules/compose.js +170 -0
- package/dist/lib/{memory.d.ts → rules/rules.d.ts} +5 -5
- package/dist/lib/{memory.js → rules/rules.js} +10 -10
- package/dist/lib/secrets/AgentsKeychain.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/AgentsKeychain.app/Contents/MacOS/AgentsKeychain +0 -0
- package/dist/lib/secrets/bundles.d.ts +61 -4
- package/dist/lib/secrets/bundles.js +222 -54
- package/dist/lib/secrets/index.d.ts +24 -5
- package/dist/lib/secrets/index.js +70 -41
- package/dist/lib/session/active.js +5 -5
- package/dist/lib/session/db.js +4 -4
- package/dist/lib/session/discover.js +2 -2
- package/dist/lib/session/render.js +21 -7
- package/dist/lib/shims.d.ts +28 -4
- package/dist/lib/shims.js +72 -14
- package/dist/lib/state.d.ts +22 -28
- package/dist/lib/state.js +83 -76
- package/dist/lib/sync-manifest.d.ts +2 -2
- package/dist/lib/sync-manifest.js +5 -5
- package/dist/lib/teams/agents.d.ts +4 -2
- package/dist/lib/teams/agents.js +11 -4
- package/dist/lib/teams/api.d.ts +1 -1
- package/dist/lib/teams/api.js +2 -2
- package/dist/lib/teams/index.d.ts +1 -0
- package/dist/lib/teams/index.js +1 -0
- package/dist/lib/teams/persistence.js +3 -3
- package/dist/lib/teams/registry.d.ts +8 -1
- package/dist/lib/teams/registry.js +8 -2
- package/dist/lib/teams/worktree.d.ts +30 -0
- package/dist/lib/teams/worktree.js +96 -0
- package/dist/lib/types.d.ts +13 -7
- package/dist/lib/types.js +3 -3
- package/dist/lib/versions.d.ts +30 -2
- package/dist/lib/versions.js +127 -105
- package/package.json +1 -1
- package/scripts/postinstall.js +29 -0
- package/dist/commands/refresh-memory.d.ts +0 -15
- package/dist/lib/memory-compile.d.ts +0 -66
package/dist/lib/migrate.js
CHANGED
|
@@ -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
|
}
|
package/dist/lib/pty-client.js
CHANGED
|
@@ -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 =
|
|
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.
|
|
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.
|
package/dist/lib/pty-server.js
CHANGED
|
@@ -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 = '
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
117
|
-
|
|
144
|
+
const logPath = getPtyLogPath();
|
|
145
|
+
rotateLogsIfNeeded(logPath);
|
|
146
|
+
const entry = { ts: new Date().toISOString(), level, message };
|
|
118
147
|
try {
|
|
119
|
-
fs.appendFileSync(
|
|
148
|
+
fs.appendFileSync(logPath, JSON.stringify(entry) + '\n', 'utf-8');
|
|
120
149
|
}
|
|
121
150
|
catch { }
|
|
122
151
|
}
|
package/dist/lib/resources.js
CHANGED
|
@@ -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 './
|
|
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';
|
package/dist/lib/rotate.d.ts
CHANGED
|
@@ -25,32 +25,51 @@ export interface RotateResult {
|
|
|
25
25
|
excluded: RotateCandidate[];
|
|
26
26
|
}
|
|
27
27
|
export declare const RUN_STRATEGIES: RunStrategy[];
|
|
28
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
38
|
-
*
|
|
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
|
-
*
|
|
41
|
-
*
|
|
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
|
|
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
|
-
*
|
|
62
|
-
*
|
|
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
|
|
66
|
-
* AccountInfo
|
|
67
|
-
*
|
|
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
|
|
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
|
|
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<{
|
package/dist/lib/rotate.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
/**
|
|
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
|
-
?? '
|
|
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
|
-
*
|
|
116
|
-
*
|
|
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)
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
214
|
-
*
|
|
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
|
|
218
|
-
* AccountInfo
|
|
219
|
-
*
|
|
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
|
|
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
|
|
226
|
-
return
|
|
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(
|
|
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(
|
|
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
|
|
322
|
+
: await selectBalancedVersion(agent);
|
|
272
323
|
if (rotation) {
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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;
|