@phnx-labs/agents-cli 1.22.8 → 1.22.9

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 (47) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +5 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +41 -9
  5. package/dist/commands/feed.js +38 -1
  6. package/dist/commands/harness.d.ts +40 -1
  7. package/dist/commands/harness.js +316 -76
  8. package/dist/commands/profiles.d.ts +42 -0
  9. package/dist/commands/profiles.js +91 -3
  10. package/dist/commands/sessions-picker.js +3 -3
  11. package/dist/commands/sessions-render.d.ts +12 -0
  12. package/dist/commands/sessions-render.js +124 -0
  13. package/dist/commands/sessions.js +28 -1
  14. package/dist/commands/ssh.js +26 -3
  15. package/dist/commands/teams.js +12 -1
  16. package/dist/index.js +9 -2
  17. package/dist/lib/crabbox/cli.d.ts +35 -0
  18. package/dist/lib/crabbox/cli.js +46 -0
  19. package/dist/lib/crabbox/config.d.ts +21 -0
  20. package/dist/lib/crabbox/config.js +43 -0
  21. package/dist/lib/crabbox/lease.d.ts +15 -5
  22. package/dist/lib/crabbox/lease.js +57 -17
  23. package/dist/lib/daemon.js +12 -11
  24. package/dist/lib/devices/resolve-target.d.ts +4 -3
  25. package/dist/lib/devices/resolve-target.js +4 -3
  26. package/dist/lib/hosts/passthrough.js +18 -1
  27. package/dist/lib/hosts/registry.d.ts +4 -0
  28. package/dist/lib/hosts/registry.js +16 -0
  29. package/dist/lib/hosts/remote-cmd.js +1 -0
  30. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  31. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  32. package/dist/lib/observe-aliases.d.ts +30 -0
  33. package/dist/lib/observe-aliases.js +56 -0
  34. package/dist/lib/redact.js +4 -0
  35. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  36. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  37. package/dist/lib/session/parse.d.ts +5 -1
  38. package/dist/lib/session/parse.js +23 -13
  39. package/dist/lib/session/prompt.js +5 -0
  40. package/dist/lib/session/render.d.ts +3 -0
  41. package/dist/lib/session/render.js +33 -10
  42. package/dist/lib/startup/command-registry.js +15 -1
  43. package/dist/lib/usage-refresh.d.ts +19 -11
  44. package/dist/lib/usage-refresh.js +75 -43
  45. package/dist/lib/usage.d.ts +12 -0
  46. package/dist/lib/usage.js +37 -6
  47. package/package.json +1 -1
@@ -1,49 +1,74 @@
1
1
  /**
2
- * Daemon-owned adaptive usage refresher.
2
+ * Daemon-owned usage refresher (per host).
3
3
  *
4
4
  * The routing hot path (`agents run` → collectRunCandidates) reads usage
5
5
  * CACHE-ONLY (`getUsageInfoForIdentity` `readOnly`, RUSH-2061) and never blocks
6
6
  * on a provider fetch. Something still has to keep that cache fresh — this
7
- * module is that something, running inside the daemon.
7
+ * module is that something, running inside the daemon on **each machine**.
8
8
  *
9
- * Design (per account, this host is the SOLE writer — the refresher only ever
10
- * touches accounts whose credentials live locally, so no cross-host
11
- * coordination and no shared-token contention):
9
+ * Design:
12
10
  *
13
- * - **Adaptive cadence from burn rate.** Each refresh stores the session
14
- * window's `usedPercent` + `capturedAt`. The next refresh is scheduled from
15
- * `deriveUsageHeadroom`'s projected `minutesToLimit`: an account racing
16
- * toward its cap is polled more often (down to 90s), an idle one rarely (up
17
- * to 15min). `computeNextRefreshDelayMs` is the pure clamp.
18
- * - **Hard hourly cap.** Regardless of cadence, at most `HOURLY_CALL_CAP` live
19
- * fetches per account per rolling hour, so a fast burn can't turn the 90s
20
- * floor into a hammering loop.
21
- * - **Respects the existing 429 backoff.** A provider under
22
- * `usageRateLimitedUntil` is skipped entirely — the whole point of the
23
- * backoff is to stop poking an endpoint that just said no.
11
+ * - **Per-host sole writer.** Only accounts whose credentials live on THIS
12
+ * box are listed; no fleet-wide usage sync.
13
+ * - **Fixed 5-minute cadence** (`REFRESH_INTERVAL_MS`). Enough to keep
14
+ * balanced/`agents view` off multi-hour stale data without thrashing
15
+ * provider APIs when the user runs agents frequently. The delay helpers
16
+ * still accept a burn projection for tests/future tuning, but the default
17
+ * floor and ceiling are both 5 minutes.
18
+ * - **Hard hourly cap** (`HOURLY_CALL_CAP`) so a stuck "due" loop cannot
19
+ * hammer an endpoint past ~12 calls/account/hour.
20
+ * - **429 backoff.** A provider under `usageRateLimitedUntil` is skipped
21
+ * entirely — no live fetch, no Touch ID, no re-armed penalty.
22
+ * - **File-only credentials on the daemon path.** Refresh never opens the
23
+ * ACL-bound macOS keychain item (Touch ID storm). It uses the no-ACL
24
+ * access-token cache / setup-token / `.credentials.json` only
25
+ * (`fileOnly: true` on `getUsageInfo`).
26
+ * - **Concurrency-safe cache writes.** Usage + headroom files are updated
27
+ * under `withFileLock` + atomic rename so a concurrent `agents view`
28
+ * background refresh cannot tear or drop another account's row.
24
29
  *
25
- * It also publishes the projected headroom (`minutesToLimit` + status) to a
26
- * small cache keyed by usage key, so the routing hot path can deprioritize an
27
- * account projected to cap without recomputing a burn rate it has no prior
28
- * sample for. `fleet-cache.ts` is the sync reader.
30
+ * Scenarios (what this path must survive):
31
+ *
32
+ * 1. **Daemon tick overlaps a slow tick** — overlap guard in daemon.ts;
33
+ * second tick is a no-op.
34
+ * 2. **`agents view` writes cache while daemon refreshes** — file lock
35
+ * serializes read-modify-write; no lost updates.
36
+ * 3. **macOS keychain ACL / Touch ID** — fileOnly refresh never calls
37
+ * `security find-generic-password` on Claude's ACL item.
38
+ * 4. **Provider 429** — whole provider skipped until Retry-After; cache
39
+ * freezes (visible as aged `capturedAt`) rather than hammering.
40
+ * 5. **Expired access token (no refresh)** — usage path never rotates
41
+ * single-use refresh tokens; counts as `failed`, reschedules 5m later.
42
+ * 6. **No file credential on this host** — account skipped / failed; no
43
+ * keychain fallback from the daemon refresher.
44
+ * 7. **Grok/Codex (network:false)** — not listed by `buildLocalUsageAccounts`;
45
+ * their "cache" is local logs, not this HTTP refresher.
29
46
  */
30
47
  import * as fs from 'fs';
31
48
  import * as path from 'path';
32
49
  import { getCacheDir } from './state.js';
50
+ import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
33
51
  import { deriveUsageHeadroom, getUsageInfo, buildCanonicalUsageContext, agentUsesNetworkUsage, USAGE_SOURCE_AGENT_IDS, } from './usage.js';
34
52
  import { getAccountInfo } from './agents.js';
35
53
  import { listInstalledVersions, getVersionHomePath } from './versions.js';
36
- /** Floor on the adaptive interval: an account seconds from its cap still isn't
37
- * polled faster than this. */
38
- export const REFRESH_MIN_MS = 90 * 1000;
39
- /** Ceiling on the adaptive interval: an idle account is still re-checked at
40
- * least this often so a cache never silently rots. */
41
- export const REFRESH_MAX_MS = 15 * 60 * 1000;
42
- /** Schedule the next refresh at `minutesToLimit / K` — poll well before the cap,
43
- * not exactly at it. */
54
+ /**
55
+ * Default schedule between successful (or attempted) live usage fetches for one
56
+ * account. Floor and ceiling of the delay helper are pinned to this so the
57
+ * daemon does not poll faster than 5 minutes even under high burn, and does not
58
+ * let an idle account rot longer than 5 minutes between attempts.
59
+ */
60
+ export const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
61
+ /** @deprecated alias — use {@link REFRESH_INTERVAL_MS}. Kept for test imports. */
62
+ export const REFRESH_MIN_MS = REFRESH_INTERVAL_MS;
63
+ /** @deprecated alias — use {@link REFRESH_INTERVAL_MS}. Kept for test imports. */
64
+ export const REFRESH_MAX_MS = REFRESH_INTERVAL_MS;
65
+ /** Burn-rate divisor retained for the pure delay helper / tests; with min=max
66
+ * the divisor does not change the scheduled interval. */
44
67
  export const REFRESH_BURN_DIVISOR = 4;
45
- /** At most this many live fetches per account per rolling hour. */
46
- export const HOURLY_CALL_CAP = 6;
68
+ /** At most this many live fetches per account per rolling hour (5m cadence ⇒ 12). */
69
+ export const HOURLY_CALL_CAP = 12;
70
+ /** How often the daemon wakes to *consider* a refresh pass (due accounts only). */
71
+ export const USAGE_REFRESH_TICK_MS = 60 * 1000;
47
72
  const HOUR_MS = 60 * 60 * 1000;
48
73
  /** Test seam for the headroom cache path (see usage.ts `setClaudeUsageCachePathForTest`). */
49
74
  let headroomCachePathOverride = null;
@@ -74,27 +99,30 @@ export function readHeadroomEntry(usageKey) {
74
99
  /** Merge entries into the cache (best-effort; preserves other accounts' rows). */
75
100
  export function writeHeadroomEntries(entries) {
76
101
  try {
77
- const dir = getCacheDir();
78
- if (!fs.existsSync(dir))
79
- fs.mkdirSync(dir, { recursive: true });
80
- const merged = {
81
- version: 1,
82
- entries: { ...readHeadroomCache(), ...entries },
83
- };
84
- fs.writeFileSync(headroomCachePath(), JSON.stringify(merged, null, 2));
102
+ const cachePath = headroomCachePath();
103
+ ensureLockTarget(cachePath, JSON.stringify({ version: 1, entries: {} }, null, 2));
104
+ withFileLock(cachePath, () => {
105
+ // Re-read under the lock so a concurrent tick/view cannot drop rows.
106
+ const merged = {
107
+ version: 1,
108
+ entries: { ...readHeadroomCache(), ...entries },
109
+ };
110
+ atomicWriteFileSync(cachePath, JSON.stringify(merged, null, 2));
111
+ });
85
112
  }
86
113
  catch {
87
114
  // best-effort; a failed write just means the router sees no projection
88
115
  }
89
116
  }
90
117
  /**
91
- * The adaptive interval until the next refresh, clamped to [MIN, MAX]. A
92
- * shorter `minutesToLimit` (closer to the cap) polls sooner; `null` (unknown /
93
- * idle / not burning) waits the full ceiling.
118
+ * Interval until the next refresh attempt, clamped to [minMs, maxMs].
119
+ * Defaults pin both ends to {@link REFRESH_INTERVAL_MS} (5 minutes) so the
120
+ * live daemon path is a fixed schedule. Tests may pass a wider range to
121
+ * exercise burn-aware scheduling without changing production cadence.
94
122
  */
95
123
  export function computeNextRefreshDelayMs(minutesToLimit, opts = {}) {
96
- const minMs = opts.minMs ?? REFRESH_MIN_MS;
97
- const maxMs = opts.maxMs ?? REFRESH_MAX_MS;
124
+ const minMs = opts.minMs ?? REFRESH_INTERVAL_MS;
125
+ const maxMs = opts.maxMs ?? REFRESH_INTERVAL_MS;
98
126
  const divisor = opts.divisor ?? REFRESH_BURN_DIVISOR;
99
127
  if (minutesToLimit === null || !Number.isFinite(minutesToLimit))
100
128
  return maxMs;
@@ -169,10 +197,14 @@ export async function buildLocalUsageAccounts() {
169
197
  accounts.push({
170
198
  usageKey,
171
199
  agentId,
200
+ // fileOnly: never open the ACL-bound keychain item from the daemon —
201
+ // that path is the Touch ID storm. Usage reads setup-token /
202
+ // no-ACL cache / .credentials.json only (see loadClaudeOauth).
172
203
  fetch: () => getUsageInfo(agentId, {
173
204
  home: fetchInput.home,
174
205
  cliVersion: fetchInput.cliVersion,
175
206
  organizationId: fetchInput.organizationId,
207
+ fileOnly: true,
176
208
  }),
177
209
  });
178
210
  }
@@ -86,6 +86,13 @@ interface UsageOptions {
86
86
  home?: string;
87
87
  cliVersion?: string | null;
88
88
  organizationId?: string | null;
89
+ /**
90
+ * When true, never open the ACL-bound OS keychain item (macOS Touch ID).
91
+ * Daemon usage refresh sets this so a background tick cannot pop biometrics.
92
+ * Credentials come from the no-ACL access-token cache, a file-based
93
+ * setup-token, or `<home>/.claude/.credentials.json` only.
94
+ */
95
+ fileOnly?: boolean;
89
96
  }
90
97
  /** Canonical input for a single usage fetch operation. */
91
98
  export interface UsageFetchInput {
@@ -462,9 +469,14 @@ export declare function normalizeDroidWindows(data: DroidBillingLimitsResponse):
462
469
  * `getClaudeAccessToken`) or export the full blob (`readClaudeCredentialsBlob`
463
470
  * for Rush Cloud dispatch) must NOT pass it. Only the read-only, high-frequency
464
471
  * access-token-only consumers (the usage fetch and the auth-health probe) opt in.
472
+ *
473
+ * `opts.fileOnly` (implies access-token-only consumers) skips the ACL keychain
474
+ * read entirely — setup-token, no-ACL cache, and `.credentials.json` only. Used
475
+ * by the daemon usage refresher so a background tick can never pop Touch ID.
465
476
  */
466
477
  export declare function loadClaudeOauth(home?: string, opts?: {
467
478
  accessTokenCache?: boolean;
479
+ fileOnly?: boolean;
468
480
  }): Promise<ClaudeOauthCredentials | null>;
469
481
  /**
470
482
  * Save Claude OAuth credentials to the system keychain/keyring.
package/dist/lib/usage.js CHANGED
@@ -24,6 +24,7 @@ import { resolveClaudeSetupToken } from './claude-account-token.js';
24
24
  import { formatBackoffRemaining, noteUsageRateLimited, usageRateLimitedUntil, } from './usage-backoff.js';
25
25
  import { getCacheDir } from './state.js';
26
26
  import { mapBounded } from './concurrency.js';
27
+ import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
27
28
  const execFileAsync = promisify(execFile);
28
29
  const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
29
30
  const CLAUDE_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
@@ -654,7 +655,12 @@ async function getClaudeUsageInfo(options) {
654
655
  try {
655
656
  // Opt into the no-ACL access-token cache: this is the every-60s watchdog hot
656
657
  // path and usage needs only the access token, so it kills the Touch ID storm.
657
- const oauth = await loadClaudeOauth(options?.home, { accessTokenCache: true });
658
+ // Daemon refresh also sets fileOnly so we never fall through to the ACL
659
+ // keychain item (that path is the Touch ID prompt).
660
+ const oauth = await loadClaudeOauth(options?.home, {
661
+ accessTokenCache: true,
662
+ fileOnly: options?.fileOnly === true,
663
+ });
658
664
  if (!oauth?.accessToken) {
659
665
  return { snapshot: null, error: usageNoCredentialError('Claude') };
660
666
  }
@@ -1333,6 +1339,10 @@ function deleteCachedClaudeOauth(service) {
1333
1339
  * `getClaudeAccessToken`) or export the full blob (`readClaudeCredentialsBlob`
1334
1340
  * for Rush Cloud dispatch) must NOT pass it. Only the read-only, high-frequency
1335
1341
  * access-token-only consumers (the usage fetch and the auth-health probe) opt in.
1342
+ *
1343
+ * `opts.fileOnly` (implies access-token-only consumers) skips the ACL keychain
1344
+ * read entirely — setup-token, no-ACL cache, and `.credentials.json` only. Used
1345
+ * by the daemon usage refresher so a background tick can never pop Touch ID.
1336
1346
  */
1337
1347
  export async function loadClaudeOauth(home, opts) {
1338
1348
  // Read-only usage/probe callers (accessTokenCache) authenticate with a
@@ -1359,7 +1369,11 @@ export async function loadClaudeOauth(home, opts) {
1359
1369
  // anywhere, so the platform check must yield to it exactly as the inner
1360
1370
  // claudeOauthCacheActive() gate does — otherwise this whole block is dead on
1361
1371
  // a Windows runner and the tests below it read an empty home.
1362
- if (process.platform === 'darwin' || process.platform === 'linux' || isKeychainBackendOverridden()) {
1372
+ //
1373
+ // fileOnly: daemon refresh must never open the ACL-bound item (Touch ID).
1374
+ // It may still read the no-ACL cache item (set-no-acl — prompt-free).
1375
+ if (!opts?.fileOnly
1376
+ && (process.platform === 'darwin' || process.platform === 'linux' || isKeychainBackendOverridden())) {
1363
1377
  const service = getClaudeKeychainService(home);
1364
1378
  // Serve the no-ACL cache first (opt-in, macOS/test only) so the ACL-gated read
1365
1379
  // below — the one that pops Touch ID — happens at most once per token lifetime
@@ -1386,6 +1400,13 @@ export async function loadClaudeOauth(home, opts) {
1386
1400
  deleteCachedClaudeOauth(service);
1387
1401
  }
1388
1402
  }
1403
+ else if (opts?.fileOnly === true && opts?.accessTokenCache === true && claudeOauthCacheActive()) {
1404
+ // fileOnly + accessTokenCache: still allow the no-ACL cache (never Touch ID).
1405
+ const service = getClaudeKeychainService(home);
1406
+ const cached = readCachedClaudeOauth(service);
1407
+ if (cached)
1408
+ return cached;
1409
+ }
1389
1410
  const credsPath = path.join(home ?? os.homedir(), '.claude', '.credentials.json');
1390
1411
  try {
1391
1412
  if (fs.existsSync(credsPath)) {
@@ -1487,9 +1508,19 @@ export function readClaudeUsageCache(usageKey, cachePath = getClaudeUsageCachePa
1487
1508
  }
1488
1509
  /** Write a usage snapshot to the on-disk cache. */
1489
1510
  export function writeClaudeUsageCache(usageKey, snapshot, cachePath = getClaudeUsageCachePath()) {
1490
- const cache = readClaudeUsageCacheFile(cachePath);
1491
- cache[usageKey] = serializeClaudeUsageSnapshot(snapshot);
1492
- writeClaudeUsageCacheFile(cache, cachePath);
1511
+ try {
1512
+ ensureLockTarget(cachePath, '{}');
1513
+ withFileLock(cachePath, () => {
1514
+ // Re-read under the lock so a concurrent daemon tick / agents view
1515
+ // refresh cannot drop another account's row (lost update).
1516
+ const cache = readClaudeUsageCacheFile(cachePath);
1517
+ cache[usageKey] = serializeClaudeUsageSnapshot(snapshot);
1518
+ atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
1519
+ });
1520
+ }
1521
+ catch {
1522
+ /* best-effort cache write — lock busy or disk full */
1523
+ }
1493
1524
  }
1494
1525
  /** Read the entire usage cache file from disk. */
1495
1526
  function readClaudeUsageCacheFile(cachePath) {
@@ -1508,7 +1539,7 @@ function readClaudeUsageCacheFile(cachePath) {
1508
1539
  function writeClaudeUsageCacheFile(cache, cachePath) {
1509
1540
  try {
1510
1541
  fs.mkdirSync(path.dirname(cachePath), { recursive: true });
1511
- fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
1542
+ atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
1512
1543
  }
1513
1544
  catch {
1514
1545
  /* best-effort cache write */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.8",
3
+ "version": "1.22.9",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",