@phnx-labs/agents-cli 1.22.41 → 1.22.42

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 (167) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +9 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/cli/command-registry.d.ts +2 -0
  5. package/dist/cli/command-registry.js +4 -0
  6. package/dist/commands/accounts.d.ts +29 -0
  7. package/dist/commands/accounts.js +220 -29
  8. package/dist/commands/alias.js +1 -1
  9. package/dist/commands/auth.d.ts +9 -0
  10. package/dist/commands/auth.js +104 -0
  11. package/dist/commands/browser-sessions-picker.js +42 -62
  12. package/dist/commands/browser.js +30 -38
  13. package/dist/commands/computer-actions.d.ts +1 -1
  14. package/dist/commands/computer-actions.js +1 -1
  15. package/dist/commands/computer-sessions-picker.js +32 -55
  16. package/dist/commands/computer.d.ts +1 -1
  17. package/dist/commands/computer.js +2 -2
  18. package/dist/commands/doctor.d.ts +1 -1
  19. package/dist/commands/doctor.js +3 -3
  20. package/dist/commands/feed.js +1 -1
  21. package/dist/commands/hooks.js +1 -1
  22. package/dist/commands/import.js +1 -1
  23. package/dist/commands/insights.js +163 -85
  24. package/dist/commands/inspect.js +2 -2
  25. package/dist/commands/mine.js +1 -1
  26. package/dist/commands/org.d.ts +6 -0
  27. package/dist/commands/org.js +174 -0
  28. package/dist/commands/packages.js +1 -1
  29. package/dist/commands/prune.js +1 -1
  30. package/dist/commands/routines.js +1 -1
  31. package/dist/commands/run-account-picker.d.ts +19 -0
  32. package/dist/commands/run-account-picker.js +81 -0
  33. package/dist/commands/sessions-picker-factory.d.ts +40 -0
  34. package/dist/commands/sessions-picker-factory.js +67 -0
  35. package/dist/commands/setup-browser.js +4 -4
  36. package/dist/commands/setup-mine.js +1 -1
  37. package/dist/commands/setup-preferences.js +2 -2
  38. package/dist/commands/setup.js +5 -3
  39. package/dist/commands/sync.js +1 -1
  40. package/dist/commands/versions.js +1 -1
  41. package/dist/commands/view.js +1 -1
  42. package/dist/lib/account-capabilities.d.ts +10 -0
  43. package/dist/lib/account-capabilities.js +34 -0
  44. package/dist/lib/agent-spec/agents.d.ts +584 -0
  45. package/dist/lib/agent-spec/agents.js +3127 -0
  46. package/dist/lib/agent-spec/provider.js +1 -1
  47. package/dist/lib/agents.d.ts +2 -584
  48. package/dist/lib/agents.js +2 -3127
  49. package/dist/lib/browser/drivers/local.d.ts +8 -2
  50. package/dist/lib/browser/drivers/local.js +8 -2
  51. package/dist/lib/browser/drivers/ssh.d.ts +8 -2
  52. package/dist/lib/browser/drivers/ssh.js +10 -4
  53. package/dist/lib/browser/hygiene.d.ts +1 -1
  54. package/dist/lib/browser/hygiene.js +5 -1
  55. package/dist/lib/browser/profiles.d.ts +74 -1
  56. package/dist/lib/browser/profiles.js +114 -5
  57. package/dist/lib/browser/runtime-state.d.ts +11 -5
  58. package/dist/lib/browser/runtime-state.js +13 -9
  59. package/dist/lib/browser/service.d.ts +68 -30
  60. package/dist/lib/browser/service.js +305 -194
  61. package/dist/lib/browser/types.d.ts +95 -3
  62. package/dist/lib/browser/types.js +51 -0
  63. package/dist/lib/catchup.js +1 -1
  64. package/dist/lib/computer/actions.d.ts +1 -1
  65. package/dist/lib/computer/actions.js +1 -1
  66. package/dist/lib/{computer-rpc.js → computer/computer-rpc.js} +5 -5
  67. package/dist/lib/computer/dispatch.d.ts +1 -1
  68. package/dist/lib/computer/download.d.ts +1 -1
  69. package/dist/lib/computer/download.js +3 -3
  70. package/dist/lib/{ssh-tunnel.d.ts → computer/ssh-tunnel.d.ts} +1 -1
  71. package/dist/lib/{ssh-tunnel.js → computer/ssh-tunnel.js} +12 -12
  72. package/dist/lib/daemon/daemon.js +1 -1
  73. package/dist/lib/{runner.d.ts → daemon/runner.d.ts} +10 -10
  74. package/dist/lib/{runner.js → daemon/runner.js} +40 -114
  75. package/dist/lib/devices/doctor-findings.d.ts +1 -1
  76. package/dist/lib/devices/doctor-findings.js +3 -3
  77. package/dist/lib/devices/fleet-inventory.js +1 -1
  78. package/dist/lib/devices/harness-inventory.js +1 -1
  79. package/dist/lib/devices/registry.js +4 -22
  80. package/dist/lib/doctor-diff.d.ts +1 -1
  81. package/dist/lib/doctor-diff.js +1 -1
  82. package/dist/lib/drift.js +1 -1
  83. package/dist/lib/entitlement.d.ts +31 -0
  84. package/dist/lib/entitlement.js +137 -0
  85. package/dist/lib/exec.js +39 -202
  86. package/dist/lib/feed/feed.js +2 -8
  87. package/dist/lib/fs-atomic.d.ts +7 -0
  88. package/dist/lib/fs-atomic.js +9 -0
  89. package/dist/lib/harness/adapter.d.ts +160 -0
  90. package/dist/lib/harness/adapter.js +47 -0
  91. package/dist/lib/harness/adapters/claude.d.ts +2 -0
  92. package/dist/lib/harness/adapters/claude.js +110 -0
  93. package/dist/lib/harness/adapters/codex.d.ts +2 -0
  94. package/dist/lib/harness/adapters/codex.js +83 -0
  95. package/dist/lib/harness/adapters/copilot.d.ts +2 -0
  96. package/dist/lib/harness/adapters/copilot.js +23 -0
  97. package/dist/lib/harness/adapters/cursor.d.ts +2 -0
  98. package/dist/lib/harness/adapters/cursor.js +38 -0
  99. package/dist/lib/harness/adapters/droid.d.ts +2 -0
  100. package/dist/lib/harness/adapters/droid.js +17 -0
  101. package/dist/lib/harness/adapters/grok.d.ts +2 -0
  102. package/dist/lib/harness/adapters/grok.js +14 -0
  103. package/dist/lib/harness/adapters/kimi.d.ts +2 -0
  104. package/dist/lib/harness/adapters/kimi.js +49 -0
  105. package/dist/lib/harness/adapters/muse.d.ts +2 -0
  106. package/dist/lib/harness/adapters/muse.js +44 -0
  107. package/dist/lib/harness/adapters/opencode.d.ts +2 -0
  108. package/dist/lib/harness/adapters/opencode.js +14 -0
  109. package/dist/lib/harness/exec-config-version.d.ts +8 -0
  110. package/dist/lib/harness/exec-config-version.js +22 -0
  111. package/dist/lib/harness/index.d.ts +1 -0
  112. package/dist/lib/harness/index.js +34 -0
  113. package/dist/lib/{hooks.d.ts → hooks/install.d.ts} +1 -1
  114. package/dist/lib/{hooks.js → hooks/install.js} +11 -11
  115. package/dist/lib/import.js +1 -1
  116. package/dist/lib/installations/index.d.ts +4 -4
  117. package/dist/lib/installations/index.js +3 -4
  118. package/dist/lib/installations/migrate.js +6 -6
  119. package/dist/lib/{shims.d.ts → installations/shims.d.ts} +2 -2
  120. package/dist/lib/{shims.js → installations/shims.js} +12 -114
  121. package/dist/lib/installations/store.d.ts +141 -3
  122. package/dist/lib/installations/store.js +475 -5
  123. package/dist/lib/installations/versions.d.ts +1 -126
  124. package/dist/lib/installations/versions.js +5 -465
  125. package/dist/lib/isolation-boundary-report.d.ts +1 -1
  126. package/dist/lib/isolation-boundary-report.js +1 -1
  127. package/dist/lib/mailbox-gc.js +2 -3
  128. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  129. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  130. package/dist/lib/monitors/dispatch.d.ts +1 -1
  131. package/dist/lib/monitors/dispatch.js +2 -2
  132. package/dist/lib/monitors/state.d.ts +1 -1
  133. package/dist/lib/plugins/plugins.js +1 -1
  134. package/dist/lib/plugins/skills.js +1 -1
  135. package/dist/lib/prix-account.d.ts +158 -0
  136. package/dist/lib/prix-account.js +214 -0
  137. package/dist/lib/project-key.js +2 -2
  138. package/dist/lib/project-launch.js +1 -1
  139. package/dist/lib/refresh.js +2 -2
  140. package/dist/lib/resource-inventory.d.ts +1 -1
  141. package/dist/lib/resource-inventory.js +1 -1
  142. package/dist/lib/rules/rules.js +1 -1
  143. package/dist/lib/scheduling/routines.js +1 -1
  144. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  145. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  146. package/dist/lib/self-heal/checks/hook-runtime.js +1 -1
  147. package/dist/lib/self-heal/checks/path.js +1 -1
  148. package/dist/lib/self-heal/checks/shadowing.js +1 -1
  149. package/dist/lib/self-heal/checks/shims.js +1 -1
  150. package/dist/lib/session/discover.d.ts +39 -0
  151. package/dist/lib/session/discover.js +3 -3
  152. package/dist/lib/session/parse.js +24 -42
  153. package/dist/lib/session/recovery.js +1 -1
  154. package/dist/lib/staleness/prune.d.ts +1 -1
  155. package/dist/lib/staleness/prune.js +1 -1
  156. package/dist/lib/staleness/writers/hooks.js +1 -1
  157. package/dist/lib/startup/command-registry.js +1 -1
  158. package/dist/lib/teams/agents.js +2 -19
  159. package/dist/lib/teams/parsers.js +20 -26
  160. package/dist/lib/teams/registry.js +2 -21
  161. package/dist/lib/triggers/handlers.js +1 -1
  162. package/dist/lib/triggers/webhook.js +1 -1
  163. package/dist/lib/uninstall.js +1 -1
  164. package/dist/lib/version.d.ts +9 -2
  165. package/dist/lib/version.js +11 -9
  166. package/package.json +1 -1
  167. /package/dist/lib/{computer-rpc.d.ts → computer/computer-rpc.d.ts} +0 -0
@@ -41,13 +41,13 @@ import chalk from 'chalk';
41
41
  import { AGENTS, ALL_AGENT_IDS, supportsAccountInspection } from '../agents.js';
42
42
  import { blocksLocalScripts } from '../platform/winpath.js';
43
43
  import { loginHint } from '../signin-badge.js';
44
- import { CONFIG_ENV_ISOLATED_AGENTS } from '../shims.js';
44
+ import { CONFIG_ENV_ISOLATED_AGENTS } from '../installations/shims.js';
45
45
  import { padToWidth, stringWidth } from '../session/width.js';
46
46
  import { windowsSshEnrollmentProblem } from './windows-ssh-enrollment.js';
47
47
  const AGENT_NAMES = Object.fromEntries(ALL_AGENT_IDS.map((id) => [id, AGENTS[id].name]));
48
48
  /** Agents with NO per-version credential isolation: their login is shared across
49
49
  * every installed version, so a "log into THIS version" remediation would be a
50
- * lie. Derived from `CONFIG_ENV_ISOLATED_AGENTS` in `lib/shims.ts` — the shim
50
+ * lie. Derived from `CONFIG_ENV_ISOLATED_AGENTS` in `lib/installations/shims.ts` — the shim
51
51
  * generator is what actually exports the per-version isolation env var, so it is
52
52
  * the source of truth. Do not hand-maintain a second copy: an agent gaining
53
53
  * isolation there must not leave a stale "login is shared" hint here. */
@@ -193,7 +193,7 @@ export function remediationFor(finding) {
193
193
  }
194
194
  // Isolated per-version home: the login must RUN INSIDE that home. A bare
195
195
  // `<cli> login` afterwards would NOT — the native shim resolves to the
196
- // project/default version (`lib/shims.ts:471-474`), so it would log into
196
+ // project/default version (`lib/installations/shims.ts:471-474`), so it would log into
197
197
  // whichever version is default, not the one that is logged out.
198
198
  switch (loginShape(agent)) {
199
199
  case 'subcommand':
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { getAvailableResources, getVersionHomePath, isVersionIsolated, listInstalledVersions } from '../installations/versions.js';
12
12
  import { supports } from '../capabilities.js';
13
- import { checkVersionHookWiring } from '../hooks.js';
13
+ import { checkVersionHookWiring } from '../hooks/install.js';
14
14
  import { getUserAgentsDir, getSystemAgentsDir, readMeta } from '../state.js';
15
15
  import { listNativeAccounts } from '../account-registry.js';
16
16
  import { readRepoState } from '../git.js';
@@ -16,7 +16,7 @@
16
16
  import chalk from 'chalk';
17
17
  import { ALL_AGENT_IDS, accountDisplayLabel, getAccountInfo, } from '../agents.js';
18
18
  import { deriveUsageStatusFromSnapshot, getUsageInfoByIdentity, getUsageLookupKey, } from '../accounting/usage.js';
19
- import { getVersionHomePath, listInstalledVersions } from '../installations/versions.js';
19
+ import { getVersionHomePath, listInstalledVersions } from '../installations/store.js';
20
20
  import { readMeta } from '../state.js';
21
21
  import { listNativeAccounts } from '../account-registry.js';
22
22
  /**
@@ -16,9 +16,9 @@
16
16
  import * as fs from 'fs/promises';
17
17
  import * as fsSync from 'fs';
18
18
  import * as path from 'path';
19
- import { randomBytes } from 'crypto';
20
19
  import lockfile from 'proper-lockfile';
21
20
  import { getDevicesRegistryPath, getDevicesIgnoredPath } from '../state.js';
21
+ import { atomicWriteJsonSync } from '../fs-atomic.js';
22
22
  /** A device's effective role, defaulting to `worker` when unset. */
23
23
  export function deviceRole(d) {
24
24
  return d.role ?? 'worker';
@@ -94,24 +94,6 @@ export function platformFromOs(os) {
94
94
  export function shellForPlatform(platform) {
95
95
  return platform === 'windows' ? 'powershell' : 'posix';
96
96
  }
97
- /**
98
- * Atomic JSON write: write to a unique sibling tmp file then rename over the
99
- * target. rename(2) is atomic on POSIX, so a crashed write leaves the old file
100
- * untouched instead of producing a half-written registry that loadDevices()
101
- * would reject.
102
- */
103
- async function atomicWriteJson(p, data) {
104
- await fs.mkdir(path.dirname(p), { recursive: true });
105
- const tmp = `${p}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`;
106
- await fs.writeFile(tmp, JSON.stringify(data, null, 2));
107
- try {
108
- await fs.rename(tmp, p);
109
- }
110
- catch (err) {
111
- await fs.unlink(tmp).catch(() => { });
112
- throw err;
113
- }
114
- }
115
97
  /**
116
98
  * Run `fn` while holding an exclusive cross-process lock on the registry file.
117
99
  * proper-lockfile requires the target to exist, so we touch it first. Stale
@@ -187,7 +169,7 @@ export function loadDevicesSync() {
187
169
  }
188
170
  }
189
171
  async function saveDevices(reg) {
190
- await atomicWriteJson(registryPath(), reg);
172
+ atomicWriteJsonSync(registryPath(), reg);
191
173
  }
192
174
  /** Get a single device profile, or null if it is not registered. */
193
175
  export async function getDevice(name) {
@@ -309,7 +291,7 @@ export async function addIgnored(name) {
309
291
  return withRegistryLock(p, async () => {
310
292
  const set = await loadIgnored();
311
293
  set.add(name);
312
- await atomicWriteJson(p, { ignored: [...set].sort(), updatedAt: new Date().toISOString() });
294
+ atomicWriteJsonSync(p, { ignored: [...set].sort(), updatedAt: new Date().toISOString() });
313
295
  return set;
314
296
  });
315
297
  }
@@ -321,7 +303,7 @@ export async function removeIgnored(name) {
321
303
  const set = await loadIgnored();
322
304
  if (!set.delete(name))
323
305
  return false;
324
- await atomicWriteJson(p, { ignored: [...set].sort(), updatedAt: new Date().toISOString() });
306
+ atomicWriteJsonSync(p, { ignored: [...set].sort(), updatedAt: new Date().toISOString() });
325
307
  return true;
326
308
  });
327
309
  }
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import type { AgentId } from './types.js';
23
23
  import type { DiscoveredPlugin } from './types.js';
24
- import { type HookWiringReport } from './hooks.js';
24
+ import { type HookWiringReport } from './hooks/install.js';
25
25
  import { type ResourceInventory } from './resource-inventory.js';
26
26
  export type DoctorKind = 'commands' | 'skills' | 'hooks' | 'rules' | 'mcp' | 'permissions' | 'subagents' | 'plugins' | 'promptcuts';
27
27
  export type DiffStatus = 'ok' | 'diff' | 'missing' | 'extra';
@@ -33,7 +33,7 @@ import { shouldInstallCommandAsSkill, commandSkillMatches, commandSkillName } fr
33
33
  import { gooseCommandMatches, gooseCommandsDir } from './goose-commands.js';
34
34
  import { supports } from './capabilities.js';
35
35
  import { listSkillsInVersionHome, getVersionSkillsDir } from './plugins/skills.js';
36
- import { listHookEntriesFromDir } from './hooks.js';
36
+ import { listHookEntriesFromDir } from './hooks/install.js';
37
37
  import { getResourceInventory } from './resource-inventory.js';
38
38
  const RULES_DOC_FILENAME = 'README.md';
39
39
  const ALL_KINDS = [
package/dist/lib/drift.js CHANGED
@@ -4,7 +4,7 @@ import { loadManifest, isStale } from './staleness/index.js';
4
4
  import { diffVersionResources } from './doctor-diff.js';
5
5
  import { diffVersionCommands, iterCommandsCapableVersions } from './commands.js';
6
6
  import { diffVersionSkills, iterSkillsCapableVersions } from './plugins/skills.js';
7
- import { iterHooksCapableVersions, listUnmanagedHooksInVersionHome, checkVersionHookWiring } from './hooks.js';
7
+ import { iterHooksCapableVersions, listUnmanagedHooksInVersionHome, checkVersionHookWiring } from './hooks/install.js';
8
8
  import { commitsBehindUpstream } from './git.js';
9
9
  import { getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos } from './state.js';
10
10
  // Lines naming exactly what's out of sync for a version, plugins prioritized:
@@ -0,0 +1,31 @@
1
+ /** How long a cached tier is trusted before the next call re-fetches. */
2
+ export declare const ENTITLEMENT_CACHE_TTL_MS: number;
3
+ export type EntitlementSource = 'live' | 'cache' | 'offline' | 'no-session';
4
+ export interface EntitlementTier {
5
+ /** The raw tier name from the backend ('free', 'admin', or a paid tier name). */
6
+ tierName: string;
7
+ /** true for 'admin' or any tier name other than 'free'. */
8
+ isPaid: boolean;
9
+ /** Where this value came from — mainly for diagnostics/tests. */
10
+ source: EntitlementSource;
11
+ }
12
+ export declare function setEntitlementUserYamlPathForTest(value: string | null): void;
13
+ export declare function setEntitlementCachePathForTest(value: string | null): void;
14
+ export declare function setEntitlementFetchForTest(fn: typeof globalThis.fetch): void;
15
+ export declare function entitlementCachePath(): string;
16
+ /**
17
+ * The live plan tier, cache-first with a TTL.
18
+ *
19
+ * - No `~/.rush/user.yaml` at all → free, no network call.
20
+ * - A fresh cache entry (within {@link ENTITLEMENT_CACHE_TTL_MS}) is returned
21
+ * with no network call.
22
+ * - A stale or missing cache triggers a live fetch; on success the result is
23
+ * cached and returned.
24
+ * - A failed fetch (offline, timeout, non-2xx) falls back to a stale cache if
25
+ * one exists — never silently drops a known-paid account to free just
26
+ * because the network hiccuped — and only falls back to free when there is
27
+ * no cache at all to fall back to.
28
+ */
29
+ export declare function getTier(): Promise<EntitlementTier>;
30
+ /** Per-harness account cap for a tier: 3 on free, 10 on paid/admin. */
31
+ export declare function accountCapForTier(tier: Pick<EntitlementTier, 'isPaid'>): number;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Plan-tier entitlement — the one place agents-cli reads a user's live billing
3
+ * tier for plan gates (the `agents accounts` per-harness cap, the `agents
4
+ * insights` paid split). Fetches `GET /api/v1/billing/subscription?agent=agi-cli`
5
+ * using the Rush session token from `~/.rush/user.yaml` (same read pattern as
6
+ * `lib/cloud/rush.ts` / `lib/secrets/drivers/rush.ts` — this module does not
7
+ * import either, since neither exports its token reader), caches the result on
8
+ * disk with a TTL, and stays offline-tolerant: a stale cache is honored over a
9
+ * failed network call rather than silently dropping a paid account to free, and
10
+ * no session file at all resolves straight to the free tier with no request.
11
+ */
12
+ import * as fs from 'node:fs';
13
+ import * as os from 'node:os';
14
+ import * as path from 'node:path';
15
+ import * as yaml from 'yaml';
16
+ import { getCacheDir } from './state.js';
17
+ import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
18
+ const PROXY_BASE = process.env.RUSH_PROXY_BASE ?? 'https://api.prix.dev';
19
+ const SUBSCRIPTION_PATH = '/api/v1/billing/subscription?agent=agi-cli';
20
+ /** How long a cached tier is trusted before the next call re-fetches. */
21
+ export const ENTITLEMENT_CACHE_TTL_MS = 15 * 60_000;
22
+ /** Same shape as bootstrap.ts's fetchNpmPackageMetadata — a hung connection
23
+ * (dead peer, a firewall dropping packets silently) must not block every
24
+ * `accounts add`/`insights` call indefinitely once the cache goes stale. */
25
+ const FETCH_TIMEOUT_MS = 5000;
26
+ const FREE_TIER = { tierName: 'free', isPaid: false };
27
+ // ─── Injectable seams (tests only — the lib's own real read/write paths) ────
28
+ let userYamlPathOverride = null;
29
+ let cachePathOverride = null;
30
+ let fetchImpl = globalThis.fetch;
31
+ export function setEntitlementUserYamlPathForTest(value) {
32
+ userYamlPathOverride = value;
33
+ }
34
+ export function setEntitlementCachePathForTest(value) {
35
+ cachePathOverride = value;
36
+ }
37
+ export function setEntitlementFetchForTest(fn) {
38
+ fetchImpl = fn;
39
+ }
40
+ function userYamlPath() {
41
+ return userYamlPathOverride ?? path.join(os.homedir(), '.rush', 'user.yaml');
42
+ }
43
+ export function entitlementCachePath() {
44
+ return cachePathOverride ?? path.join(getCacheDir(), '.entitlement-cache.json');
45
+ }
46
+ function readRushToken() {
47
+ const file = userYamlPath();
48
+ if (!fs.existsSync(file))
49
+ return null;
50
+ try {
51
+ const data = yaml.parse(fs.readFileSync(file, 'utf-8'));
52
+ return data?.session?.access_token || null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ function readCache() {
59
+ try {
60
+ const parsed = JSON.parse(fs.readFileSync(entitlementCachePath(), 'utf-8'));
61
+ if (parsed?.version === 1 && typeof parsed.tierName === 'string' && typeof parsed.fetchedAt === 'number')
62
+ return parsed;
63
+ }
64
+ catch {
65
+ // missing or corrupt — treat as no cache
66
+ }
67
+ return null;
68
+ }
69
+ /** Best-effort write; a failed cache write just means the next call re-fetches. */
70
+ function writeCache(tierName, isPaid) {
71
+ try {
72
+ const target = entitlementCachePath();
73
+ ensureLockTarget(target);
74
+ withFileLock(target, () => {
75
+ const entry = { version: 1, tierName, isPaid, fetchedAt: Date.now() };
76
+ atomicWriteFileSync(target, JSON.stringify(entry, null, 2));
77
+ });
78
+ }
79
+ catch {
80
+ // best-effort
81
+ }
82
+ }
83
+ /**
84
+ * Only `tierName: "free"` (or an absent tier name) reads as free — every other
85
+ * name (`admin`, or a real paid-tier name once agi-cli has a pricing manifest,
86
+ * see the integration spec §4.4 G3) is treated as paid. Backend tier config for
87
+ * agi-cli doesn't exist yet, so this deliberately does not lean on
88
+ * `hasSubscription`/`needsUpgrade` nuance the backend isn't populating meaningfully
89
+ * for this agent today.
90
+ */
91
+ function classifySubscription(sub) {
92
+ const tierName = typeof sub.tierName === 'string' && sub.tierName ? sub.tierName : 'free';
93
+ return { tierName, isPaid: tierName !== 'free' };
94
+ }
95
+ /**
96
+ * The live plan tier, cache-first with a TTL.
97
+ *
98
+ * - No `~/.rush/user.yaml` at all → free, no network call.
99
+ * - A fresh cache entry (within {@link ENTITLEMENT_CACHE_TTL_MS}) is returned
100
+ * with no network call.
101
+ * - A stale or missing cache triggers a live fetch; on success the result is
102
+ * cached and returned.
103
+ * - A failed fetch (offline, timeout, non-2xx) falls back to a stale cache if
104
+ * one exists — never silently drops a known-paid account to free just
105
+ * because the network hiccuped — and only falls back to free when there is
106
+ * no cache at all to fall back to.
107
+ */
108
+ export async function getTier() {
109
+ const token = readRushToken();
110
+ if (!token)
111
+ return { ...FREE_TIER, source: 'no-session' };
112
+ const cached = readCache();
113
+ if (cached && Date.now() - cached.fetchedAt < ENTITLEMENT_CACHE_TTL_MS) {
114
+ return { tierName: cached.tierName, isPaid: cached.isPaid, source: 'cache' };
115
+ }
116
+ try {
117
+ const res = await fetchImpl(`${PROXY_BASE}${SUBSCRIPTION_PATH}`, {
118
+ headers: { Authorization: `Bearer ${token}` },
119
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
120
+ });
121
+ if (!res.ok)
122
+ throw new Error(`billing/subscription responded ${res.status}`);
123
+ const sub = (await res.json());
124
+ const { tierName, isPaid } = classifySubscription(sub);
125
+ writeCache(tierName, isPaid);
126
+ return { tierName, isPaid, source: 'live' };
127
+ }
128
+ catch {
129
+ if (cached)
130
+ return { tierName: cached.tierName, isPaid: cached.isPaid, source: 'offline' };
131
+ return { ...FREE_TIER, source: 'offline' };
132
+ }
133
+ }
134
+ /** Per-harness account cap for a tier: 3 on free, 10 on paid/admin. */
135
+ export function accountCapForTier(tier) {
136
+ return tier.isPaid ? 10 : 3;
137
+ }
package/dist/lib/exec.js CHANGED
@@ -19,8 +19,7 @@ import { sanitizeProcessEnv } from './secrets/bundles.js';
19
19
  import { resolveActor, actorEnv } from './actor.js';
20
20
  import { expandLocalHome } from './project-root.js';
21
21
  import { getShimsDir, getHistoryDir, getRuntimeStateDir } from './state.js';
22
- import { resolveCodexHome } from './codex-home.js';
23
- import { readCodexConfiguredModel } from './shims.js';
22
+ import { readCodexConfiguredModel } from './installations/shims.js';
24
23
  import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
25
24
  import { writeSessionActorRecord, writeSessionAliasRecord } from './session/actor-sidecar.js';
26
25
  import { loadHookSessionIndex, resolveHookSessionId } from './session/hook-sessions.js';
@@ -32,10 +31,12 @@ import { isTmuxInstalled } from './tmux/binary.js';
32
31
  import { isTmuxEnabled } from './device-config.js';
33
32
  import { machineId } from './machine-id.js';
34
33
  import { shellQuote } from './ssh-exec.js';
35
- import { resolveClaudeSetupToken } from './claude-account-token.js';
36
34
  import { codexEditWritableRoots, codexPolicyArgs } from './codex-policy.js';
35
+ import { resolveClaudeSetupToken } from './claude-account-token.js';
37
36
  import { applyAddDirs } from './add-dir.js';
38
37
  import { applyActiveRulesPresetAtRun } from './rules/run-sync.js';
38
+ import { resolveHarnessAdapter, stripForeignConfigDir } from './harness/index.js';
39
+ import { resolveConfigVersion } from './harness/exec-config-version.js';
39
40
  /**
40
41
  * Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
41
42
  *
@@ -282,180 +283,25 @@ export function buildExecEnv(options) {
282
283
  // Config-dir env vars are agent-specific. When the caller is running inside
283
284
  // an agent-managed shell, process.env already carries one; spreading into a
284
285
  // different agent's env would leak a config pointer the target CLI doesn't
285
- // understand. Strip foreign vars and pin the right one to the versioned home.
286
- if (options.agent === 'claude') {
287
- const cwd = options.cwd || process.cwd();
288
- const resolvedVersion = options.version ?? resolveVersion('claude', cwd);
289
- // Use an explicitly pinned version unconditionally; for auto-resolved versions
290
- // only inject the path when the version is actually installed on disk.
291
- const version = options.version
292
- ? resolvedVersion
293
- : (resolvedVersion && isVersionInstalled('claude', resolvedVersion) ? resolvedVersion : null);
294
- // The per-account `claude setup-token` only resolves when there is a version
295
- // home to key it to; version===null (claude unresolved / not installed) yields
296
- // null, exactly as the routines path treats it (`runner.ts:1017-1021`). The
297
- // token decision below runs even then, so an ambient inherited value is stripped
298
- // on the routines/provisioned path regardless of whether a version resolved.
299
- const versionHome = version ? getVersionHomePath('claude', version) : null;
300
- const setupToken = versionHome ? resolveClaudeSetupToken(versionHome) : null;
301
- if (versionHome) {
302
- result.CLAUDE_CONFIG_DIR = path.join(versionHome, '.claude');
303
- // A managed pin lives in a per-version dir; Claude Code's own background
304
- // auto-updater would rewrite that pinned binary in place (and has left it
305
- // half-swapped and broken). Disable it so a pin stays a pin. Honor an
306
- // explicit user value — from process.env (already in result) or from
307
- // options.env (spread over result below).
308
- if (result.DISABLE_AUTOUPDATER === undefined) {
309
- result.DISABLE_AUTOUPDATER = '1';
310
- }
311
- }
312
- // The `auth` bundle's setup-token exists so a run with NO human present
313
- // authenticates without the Touch-ID-gated login item — usage probes,
314
- // routines, dispatched runs (claude-account-token.ts). An interactive run
315
- // has a human at the TTY, and their own per-version login is the credential
316
- // they established and expect; overriding it made `/status` report
317
- // `Auth token: CLAUDE_CODE_OAUTH_TOKEN` on a personal machine and took every
318
- // hand-driven session off that login. macOS cannot cheaply confirm a home's
319
- // login first (probing the Keychain raises an authorization sheet per
320
- // installed version on the `agents run` hot path — agents.ts
321
- // `isClaudeCredentialFileBlank`), so interactive simply defers to Claude
322
- // Code, which prompts a present human to log in if the login is missing.
323
- if (resolveInteractive(options)) {
324
- // Drop an INHERITED copy of OUR OWN setup-token: an interactive launch from
325
- // inside a headless agent's shell inherits that agent's injected value via
326
- // sanitizeProcessEnv(process.env) and would keep authenticating as it.
327
- // Matched by VALUE, so a token the user exported deliberately is a different
328
- // string and is left alone (#2383). This is NARROWER than the non-interactive
329
- // path below, which overwrites-or-deletes unconditionally and never inspects
330
- // the inherited value — a DIFFERENT account's inherited setup-token passing
331
- // through this equality check is the adjacent hole RUSH-2360 leaves as
332
- // follow-up (it does not silently run on a *shared, rotating* token, which is
333
- // what caused the RUSH-1822 logout storm).
334
- if (setupToken && result.CLAUDE_CODE_OAUTH_TOKEN === setupToken) {
335
- delete result.CLAUDE_CODE_OAUTH_TOKEN;
336
- }
337
- }
338
- else {
339
- // Non-interactive (routines, dispatched, provisioned box): mirror the routines
340
- // path (`runner.ts:1017-1021`) UNCONDITIONALLY. Inject the per-account
341
- // setup-token when one resolves — it replaces any ambient shared value
342
- // inherited from the launcher. When NONE resolves, STRIP the ambient
343
- // CLAUDE_CODE_OAUTH_TOKEN so a run on a provisioned box can never silently
344
- // authenticate as the shared, rotating token an earlier version of this path
345
- // let through — the RUSH-1822 fleet-wide-logout hazard, tracked by RUSH-2360.
346
- // A missing login then fails loud (401) against this home's own credential
347
- // instead of quietly borrowing another's. options.env still wins below for an
348
- // explicit caller override.
349
- if (setupToken) {
350
- result.CLAUDE_CODE_OAUTH_TOKEN = setupToken;
351
- }
352
- else {
353
- delete result.CLAUDE_CODE_OAUTH_TOKEN;
354
- }
355
- }
356
- delete result.CODEX_HOME;
357
- delete result.COPILOT_HOME;
358
- delete result.KIMI_CODE_HOME;
359
- }
360
- else if (options.agent === 'codex') {
361
- const cwd = options.cwd || process.cwd();
362
- const resolvedVersion = options.version ?? resolveVersion('codex', cwd);
363
- const version = options.version
364
- ? resolvedVersion
365
- : (resolvedVersion && isVersionInstalled('codex', resolvedVersion) ? resolvedVersion : null);
366
- if (version) {
367
- // On macOS the deep versioned home overflows the Unix-socket SUN_LEN
368
- // limit for codex's app-server control socket; resolve to a short,
369
- // SUN_LEN-safe home (migrating once if needed). See codex-home.ts.
370
- const versionedHome = path.join(getVersionHomePath('codex', version), '.codex');
371
- const agentsUserDir = path.dirname(getHistoryDir());
372
- result.CODEX_HOME = resolveCodexHome(versionedHome, agentsUserDir, version);
373
- }
374
- delete result.CLAUDE_CONFIG_DIR;
375
- delete result.COPILOT_HOME;
376
- delete result.KIMI_CODE_HOME;
377
- }
378
- else if (options.agent === 'copilot') {
379
- // Copilot honors COPILOT_HOME (relocates ~/.copilot, including settings,
380
- // mcp-config.json, sessions, logs). Pin it at the per-version home so
381
- // version switches isolate MCP servers, auth, and session history.
382
- const cwd = options.cwd || process.cwd();
383
- const resolvedVersion = options.version ?? resolveVersion('copilot', cwd);
384
- const version = options.version
385
- ? resolvedVersion
386
- : (resolvedVersion && isVersionInstalled('copilot', resolvedVersion) ? resolvedVersion : null);
387
- if (version) {
388
- result.COPILOT_HOME = path.join(getVersionHomePath('copilot', version), '.copilot');
389
- }
390
- delete result.CLAUDE_CONFIG_DIR;
391
- delete result.CODEX_HOME;
392
- delete result.KIMI_CODE_HOME;
393
- }
394
- else if (options.agent === 'kimi') {
395
- // Kimi honors KIMI_CODE_HOME (relocates ~/.kimi-code, including config,
396
- // skills, hooks, sessions). Pin it at the per-version home.
397
- const cwd = options.cwd || process.cwd();
398
- const resolvedVersion = options.version ?? resolveVersion('kimi', cwd);
399
- const version = options.version
400
- ? resolvedVersion
401
- : (resolvedVersion && isVersionInstalled('kimi', resolvedVersion) ? resolvedVersion : null);
402
- if (version) {
403
- result.KIMI_CODE_HOME = path.join(getVersionHomePath('kimi', version), '.kimi-code');
404
- }
405
- delete result.CLAUDE_CONFIG_DIR;
406
- delete result.CODEX_HOME;
407
- delete result.COPILOT_HOME;
408
- }
409
- else if (options.agent === 'muse') {
410
- // Muse has no MUSE_CONFIG_DIR. Config is XDG-based:
411
- // $XDG_CONFIG_HOME/muse (settings, skills, hooks, auth)
412
- // $XDG_DATA_HOME/muse (sessions, plugins)
413
- // Pin both into the version home so multi-version isolation matches
414
- // Claude's CLAUDE_CONFIG_DIR / Codex's CODEX_HOME, and so Muse never
415
- // resolves through the adopt-time ~/.config/muse symlink (SymlinkOrReparse).
416
- const cwd = options.cwd || process.cwd();
417
- const resolvedVersion = options.version ?? resolveVersion('muse', cwd);
418
- const version = options.version
419
- ? resolvedVersion
420
- : (resolvedVersion && isVersionInstalled('muse', resolvedVersion) ? resolvedVersion : null);
421
- if (version) {
422
- const versionHome = getVersionHomePath('muse', version);
423
- result.XDG_CONFIG_HOME = path.join(versionHome, '.config');
424
- result.XDG_DATA_HOME = path.join(versionHome, '.local', 'share');
425
- }
426
- delete result.CLAUDE_CONFIG_DIR;
427
- delete result.CODEX_HOME;
428
- delete result.COPILOT_HOME;
429
- delete result.KIMI_CODE_HOME;
430
- }
431
- else if (options.agent === 'cursor') {
432
- // Cursor has no config-dir env var (only CURSOR_API_KEY / CURSOR_API_ENDPOINT).
433
- // Its OAuth token — the login gate — lives at $XDG_CONFIG_HOME/cursor/auth.json
434
- // (verified empirically: relocating XDG_CONFIG_HOME relocates the login;
435
- // ~/.cursor/cli-config.json holds only account metadata, not the token). Pin
436
- // XDG_CONFIG_HOME into the version home so each installed Cursor account
437
- // authenticates from its own token, isolated per run — no global ~/.cursor
438
- // symlink swap, so concurrent runs on different accounts never clobber one
439
- // another. cli-config.json (HOME-relative) has no override and stays on the
440
- // shared home; only the token is per-account, which is what gates the login.
441
- const cwd = options.cwd || process.cwd();
442
- const resolvedVersion = options.version ?? resolveVersion('cursor', cwd);
443
- const version = options.version
444
- ? resolvedVersion
445
- : (resolvedVersion && isVersionInstalled('cursor', resolvedVersion) ? resolvedVersion : null);
446
- if (version) {
447
- result.XDG_CONFIG_HOME = path.join(getVersionHomePath('cursor', version), '.config');
448
- }
449
- delete result.CLAUDE_CONFIG_DIR;
450
- delete result.CODEX_HOME;
451
- delete result.COPILOT_HOME;
452
- delete result.KIMI_CODE_HOME;
286
+ // understand. Each harness adapter pins its own config-dir var to the versioned
287
+ // home and strips the foreign ones; a harness with no config-dir env (the old
288
+ // `else`) has no adapter override, so all four are stripped here.
289
+ const configAdapter = resolveHarnessAdapter(options.agent);
290
+ if (configAdapter.applyExecConfigEnv) {
291
+ // Resolve version/versionHome here (this module already imports
292
+ // installations/versions); the adapters must not import it themselves or they
293
+ // close a versions -> shims -> harness -> adapter import cycle.
294
+ const { version, versionHome } = resolveConfigVersion(options.agent, options.cwd || process.cwd(), options.version);
295
+ configAdapter.applyExecConfigEnv(result, {
296
+ agent: options.agent,
297
+ version,
298
+ versionHome,
299
+ interactive: resolveInteractive(options),
300
+ resolveClaudeSetupToken,
301
+ });
453
302
  }
454
303
  else {
455
- delete result.CLAUDE_CONFIG_DIR;
456
- delete result.CODEX_HOME;
457
- delete result.COPILOT_HOME;
458
- delete result.KIMI_CODE_HOME;
304
+ stripForeignConfigDir(result);
459
305
  }
460
306
  // Point the agent at its own mailbox so the PreToolUse `mailbox-inject` hook
461
307
  // knows which box to drain and inject mid-run. Keyed by the session id — the
@@ -952,18 +798,24 @@ export function buildExecCommand(options) {
952
798
  // AGENT_COMMANDS.modeFlags drifted apart. Tests assert they agree.
953
799
  throw new Error(`Internal error: ${options.agent} declares '${resolvedMode}' in capabilities.modes but has no entry in AGENT_COMMANDS.modeFlags.${resolvedMode}.`);
954
800
  }
955
- if (options.agent === 'cursor' && resolvedMode === 'edit' && !interactive) {
956
- // A configured headless run is the workspace trust decision. Keep this
957
- // narrower than --yolo/-f, which also bypasses permission checks.
958
- cmd.push('--trust');
801
+ // Launch-arg quirks (the harness axis of Move 3): cursor's additive `--trust`
802
+ // and the codex/kimi mode-flag overrides move to the harness adapter, so the
803
+ // per-agent name-chain collapses to one dispatch. The generic resume-subcommand
804
+ // and modeFlags paths stay here — they are not per-harness.
805
+ const launchAdapter = resolveHarnessAdapter(options.agent);
806
+ const launchArgsCtx = {
807
+ resolvedMode,
808
+ interactive,
809
+ cwd: options.cwd ?? process.cwd(),
810
+ addDirs: (options.addDirs ?? []).map(expandLocalHome),
811
+ };
812
+ const preModeArgs = launchAdapter.execPreModeArgs?.(launchArgsCtx);
813
+ if (preModeArgs) {
814
+ cmd.push(...preModeArgs);
959
815
  }
960
- if (options.agent === 'codex') {
961
- const policyMode = resolvedMode === 'plan' || resolvedMode === 'skip' ? resolvedMode : 'edit';
962
- const writableRoots = [
963
- ...codexEditWritableRoots(options.cwd ?? process.cwd()),
964
- ...(options.addDirs ?? []).map(expandLocalHome),
965
- ];
966
- cmd.push(...codexPolicyArgs(policyMode, writableRoots));
816
+ const modeArgsOverride = launchAdapter.execModeArgs?.(launchArgsCtx);
817
+ if (modeArgsOverride !== undefined) {
818
+ cmd.push(...modeArgsOverride);
967
819
  }
968
820
  else if (resumeSpec && 'subcommand' in resumeSpec) {
969
821
  if (resolvedMode === 'skip') {
@@ -982,21 +834,6 @@ export function buildExecCommand(options) {
982
834
  cmd.push(...modeFlags);
983
835
  }
984
836
  }
985
- else if (options.agent === 'kimi' && !interactive) {
986
- // kimi's headless prompt mode (`-p`/`--prompt`) is self-contained and REFUSES
987
- // to be combined with any startup-mode flag: `--plan`, `--auto`, and `--yolo`
988
- // all abort with "Cannot combine --prompt with --X" (verified against the live
989
- // kimi CLI). The write-capable modes (edit/auto/skip) all collapse to kimi's
990
- // default `-p` behavior, which already auto-approves tool calls, so we emit no
991
- // mode flag. Plan can't reach here headless — resolveHeadlessMode already
992
- // downgraded it to auto (kimi's headlessPlan:false); this asserts that
993
- // invariant so a plan-mode run can never silently mutate the workspace.
994
- if (resolvedMode === 'plan') {
995
- throw new Error(`Internal error: kimi reached headless command build with resolved mode 'plan'; ` +
996
- `resolveHeadlessMode should have downgraded it to auto (capabilities.headlessPlan is false).`);
997
- }
998
- // edit/auto/skip: emit no mode flag — `kimi -p` auto-runs.
999
- }
1000
837
  else {
1001
838
  cmd.push(...modeFlags);
1002
839
  }
@@ -1228,7 +1065,7 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
1228
1065
  // not parse `rawArgs`, so a `-p "task"` passthrough is headless in fact and
1229
1066
  // interactive by this classification, and loses the token. Windows-only in
1230
1067
  // practice (POSIX uses the bash shim, which execs the binary and never reaches
1231
- // buildExecEnv — `shims.ts:749`, `shims.ts:761-764`).
1068
+ // buildExecEnv — `installations/shims.ts` generateShimScript).
1232
1069
  const env = buildExecEnv({ agent, version, cwd, mode: defaultModeFor(agent), effort: 'auto', env: { AGENT_LAUNCH_ID: launchId } });
1233
1070
  const { command, args, shell } = resolveShimSpawn(process.platform, binary, [...launchArgs, ...rawArgs]);
1234
1071
  return new Promise((resolve) => {
@@ -28,6 +28,7 @@ import { stringifyDoc } from '../yaml-io.js';
28
28
  import { getFeedDir, getUserAgentsDir } from '../state.js';
29
29
  import { isAdmin, isHighConsequenceAllowed, isKnownOperator } from '../operator.js';
30
30
  import { projectKeyFromCwd } from '../project-key.js';
31
+ import { atomicWriteJsonSync } from '../fs-atomic.js';
31
32
  /**
32
33
  * Stable block id for a session. One block per session -- a new question
33
34
  * replaces the previous one (the agent can only ask one question at a time).
@@ -56,11 +57,6 @@ function safeReadJson(file) {
56
57
  return undefined;
57
58
  }
58
59
  }
59
- function atomicWriteJson(file, value) {
60
- const tmp = `${file}.${process.pid}.tmp`;
61
- fs.writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf-8');
62
- fs.renameSync(tmp, file);
63
- }
64
60
  /** Read one block record. Returns undefined when missing or corrupt. */
65
61
  export function readBlock(blockId, root) {
66
62
  const parsed = safeReadJson(blockPath(root ?? getFeedDir(), blockId));
@@ -285,9 +281,7 @@ export function publishBlock(block, root) {
285
281
  const dir = root ?? getFeedDir();
286
282
  fs.mkdirSync(dir, { recursive: true });
287
283
  const target = blockPath(dir, block.blockId);
288
- const tmp = `${target}.${process.pid}.tmp`;
289
- fs.writeFileSync(tmp, JSON.stringify(block, null, 2), 'utf-8');
290
- fs.renameSync(tmp, target);
284
+ atomicWriteJsonSync(target, block);
291
285
  }
292
286
  /** Read all block records. Returns them sorted by stable block filename. */
293
287
  export function listBlocks(root) {
@@ -11,6 +11,13 @@ export declare function ensureLockTarget(filePath: string, initialContent?: stri
11
11
  * partial write. On POSIX, rename(2) is atomic.
12
12
  */
13
13
  export declare function atomicWriteFileSync(filePath: string, content: string, options?: fs.WriteFileOptions): void;
14
+ /**
15
+ * Convenience wrapper around {@link atomicWriteFileSync} for the common case of
16
+ * writing pretty-printed JSON (RUSH-2840). Same tmp-then-rename mechanics, same
17
+ * caller responsibility to ensure the parent directory exists first — this adds
18
+ * only the `JSON.stringify`.
19
+ */
20
+ export declare function atomicWriteJsonSync(filePath: string, data: unknown): void;
14
21
  /**
15
22
  * Acquires an exclusive proper-lockfile lock on filePath, runs fn, then
16
23
  * releases the lock. Retries with capped linear back-off until either the lock