@phnx-labs/agents-cli 1.20.63 → 1.20.64

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 (69) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +9 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +55 -28
  5. package/dist/commands/feed.d.ts +4 -0
  6. package/dist/commands/feed.js +27 -8
  7. package/dist/commands/lease.d.ts +23 -0
  8. package/dist/commands/lease.js +201 -0
  9. package/dist/commands/mailboxes.d.ts +20 -0
  10. package/dist/commands/mailboxes.js +390 -0
  11. package/dist/commands/routines.js +20 -14
  12. package/dist/commands/sessions-export.d.ts +2 -0
  13. package/dist/commands/sessions-export.js +279 -0
  14. package/dist/commands/sessions-import.d.ts +2 -0
  15. package/dist/commands/sessions-import.js +230 -0
  16. package/dist/commands/sessions.js +4 -0
  17. package/dist/commands/ssh.js +98 -3
  18. package/dist/commands/usage.d.ts +2 -0
  19. package/dist/commands/usage.js +7 -2
  20. package/dist/commands/view.d.ts +1 -1
  21. package/dist/index.js +2 -1
  22. package/dist/lib/agents.d.ts +18 -0
  23. package/dist/lib/agents.js +27 -17
  24. package/dist/lib/browser/drivers/ssh.js +19 -2
  25. package/dist/lib/comms-render.d.ts +37 -0
  26. package/dist/lib/comms-render.js +89 -0
  27. package/dist/lib/crabbox/cli.d.ts +72 -0
  28. package/dist/lib/crabbox/cli.js +158 -9
  29. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  30. package/dist/lib/crabbox/runtimes.js +24 -0
  31. package/dist/lib/daemon.js +6 -1
  32. package/dist/lib/devices/health.d.ts +77 -0
  33. package/dist/lib/devices/health.js +186 -0
  34. package/dist/lib/mailbox.d.ts +39 -0
  35. package/dist/lib/mailbox.js +112 -0
  36. package/dist/lib/paths.d.ts +13 -0
  37. package/dist/lib/paths.js +26 -4
  38. package/dist/lib/routines.d.ts +21 -2
  39. package/dist/lib/routines.js +35 -12
  40. package/dist/lib/runner.js +255 -13
  41. package/dist/lib/sandbox.d.ts +9 -1
  42. package/dist/lib/sandbox.js +11 -2
  43. package/dist/lib/session/bundle.d.ts +150 -0
  44. package/dist/lib/session/bundle.js +189 -0
  45. package/dist/lib/session/remote-bundle.d.ts +12 -0
  46. package/dist/lib/session/remote-bundle.js +61 -0
  47. package/dist/lib/session/sync/agents.d.ts +54 -6
  48. package/dist/lib/session/sync/agents.js +0 -0
  49. package/dist/lib/session/sync/manifest.d.ts +14 -3
  50. package/dist/lib/session/sync/manifest.js +4 -0
  51. package/dist/lib/session/sync/sync.d.ts +23 -2
  52. package/dist/lib/session/sync/sync.js +177 -74
  53. package/dist/lib/ssh-tunnel.js +13 -1
  54. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  55. package/dist/lib/staleness/detectors/subagents.js +5 -192
  56. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  57. package/dist/lib/staleness/writers/subagents.js +11 -102
  58. package/dist/lib/startup/command-registry.d.ts +2 -0
  59. package/dist/lib/startup/command-registry.js +5 -0
  60. package/dist/lib/subagents-registry.d.ts +85 -0
  61. package/dist/lib/subagents-registry.js +393 -0
  62. package/dist/lib/subagents.d.ts +8 -8
  63. package/dist/lib/subagents.js +32 -663
  64. package/dist/lib/sync-umbrella.d.ts +1 -0
  65. package/dist/lib/sync-umbrella.js +14 -3
  66. package/dist/lib/types.d.ts +9 -0
  67. package/dist/lib/usage.d.ts +42 -3
  68. package/dist/lib/usage.js +162 -22
  69. package/package.json +1 -1
@@ -63,6 +63,7 @@ export interface UmbrellaResult {
63
63
  pushed: number;
64
64
  pulled: number;
65
65
  merged: number;
66
+ error?: string;
66
67
  };
67
68
  devices?: {
68
69
  synced: number;
@@ -118,9 +118,20 @@ export async function runUmbrellaSync(args) {
118
118
  const { isSyncConfigured } = await import('./session/sync/config.js');
119
119
  if (isBetaEnabled('session-sync') && isSyncConfigured()) {
120
120
  const { syncSessions } = await import('./session/sync/sync.js');
121
- const r = await syncSessions();
122
- result.sessions = { ran: true, pushed: r.pushed, pulled: r.pulled, merged: r.merged };
123
- log(`sessions: pushed ${r.pushed}, pulled ${r.pulled}, merged ${r.merged}`);
121
+ // Record a failure here instead of letting it throw: like the fetchSecrets
122
+ // block above, a failure in one fetch stage must not abort the others or
123
+ // the reconcile below (see this function's doc comment). Without this,
124
+ // syncSessions() throwing — e.g. via the C1 containment reject on a
125
+ // malicious peer manifest — would skip `plan.reconcile` entirely.
126
+ try {
127
+ const r = await syncSessions();
128
+ result.sessions = { ran: true, pushed: r.pushed, pulled: r.pulled, merged: r.merged };
129
+ log(`sessions: pushed ${r.pushed}, pulled ${r.pulled}, merged ${r.merged}`);
130
+ }
131
+ catch (err) {
132
+ result.sessions = { ran: false, pushed: 0, pulled: 0, merged: 0, error: err.message };
133
+ log(`sessions: failed — ${err.message}`);
134
+ }
124
135
  }
125
136
  else {
126
137
  result.sessions = { ran: false, pushed: 0, pulled: 0, merged: 0 };
@@ -624,6 +624,15 @@ export interface ExtraRepoConfig {
624
624
  export interface Meta {
625
625
  agents?: Partial<Record<AgentId, string>>;
626
626
  run?: RunConfig;
627
+ /**
628
+ * `agents run --lease` config. `secretsBundle` names the keychain secrets bundle
629
+ * whose provider token (e.g. `HCLOUD_TOKEN`) crabbox uses to reach the cloud API.
630
+ * When unset, the bundle is resolved by env (`AGENTS_LEASE_SECRETS_BUNDLE`) then
631
+ * auto-detected (the first bundle that declares a provider token key).
632
+ */
633
+ lease?: {
634
+ secretsBundle?: string;
635
+ };
627
636
  /** macOS secrets-agent config. `policy` is the default prompt policy for
628
637
  * bundles without an explicit per-bundle policy: `daily` (the default) asks
629
638
  * once per ~7 days, `always` asks every time. `auto` (default on) lets the
@@ -1,7 +1,7 @@
1
- import type { AccountInfo } from './agents.js';
1
+ import { type AccountInfo } from './agents.js';
2
2
  import type { AgentId } from './types.js';
3
3
  /** Discriminator for usage window types. */
4
- export type UsageWindowKey = 'session' | 'week' | 'sonnet_week';
4
+ export type UsageWindowKey = 'session' | 'week' | 'sonnet_week' | 'month';
5
5
  /** A single rate-limit window with utilization percentage and reset time. */
6
6
  export interface UsageWindow {
7
7
  key: UsageWindowKey;
@@ -154,7 +154,46 @@ export interface KimiUsagesResponse {
154
154
  export declare function normalizeKimiWindows(data: KimiUsagesResponse): UsageWindow[];
155
155
  /** Derive a display plan label from Kimi's membership tier or subscription type. */
156
156
  export declare function formatKimiPlan(data: KimiUsagesResponse): string | null;
157
- /** Load Claude OAuth credentials from the system keychain/keyring. */
157
+ /** A single Droid token-rate-limit window from /api/billing/limits. */
158
+ interface DroidLimitWindow {
159
+ usedPercent?: number | null;
160
+ windowEnd?: string | null;
161
+ }
162
+ /** Response shape from Factory's billing limits endpoint (subset we render). */
163
+ export interface DroidBillingLimitsResponse {
164
+ usesTokenRateLimitsBilling?: boolean | null;
165
+ limits?: {
166
+ standard?: {
167
+ fiveHour?: DroidLimitWindow | null;
168
+ weekly?: DroidLimitWindow | null;
169
+ monthly?: DroidLimitWindow | null;
170
+ } | null;
171
+ } | null;
172
+ }
173
+ /**
174
+ * Normalize the Factory billing-limits payload into the common UsageWindow
175
+ * shape. Orgs on the legacy (non token-rate-limit) billing model have no
176
+ * meaningful windows, so they render nothing — mirrors droid's own gate on
177
+ * `usesTokenRateLimitsBilling` before it reads `limits.standard`.
178
+ */
179
+ export declare function normalizeDroidWindows(data: DroidBillingLimitsResponse): UsageWindow[];
180
+ /**
181
+ * Load a version home's Claude OAuth credential from the two stores Claude Code
182
+ * uses, tried in order:
183
+ *
184
+ * 1. The OS keychain (`getKeychainToken`). Canonical on macOS — Claude Code
185
+ * writes the token to the login keychain and we read it via `/usr/bin/security`.
186
+ * 2. `<home>/.claude/.credentials.json`. On a headless Linux box (the
187
+ * `agents view --host <linux>` case) there is no reachable Secret Service, so
188
+ * the Claude CLI stores its OAuth token in this plaintext file instead. The
189
+ * keychain read above finds nothing on that platform, so we fall back to the
190
+ * file. Same wrapped `{ claudeAiOauth }` shape, so one parser handles both.
191
+ * Mirrors `readClaudeCredentialsBlob` (cloud/rush.ts), the proven pattern.
192
+ *
193
+ * Without step 2 the live usage fetch got no token on Linux, so `agents view`
194
+ * (run remotely over SSH by `--host`) rendered no usage bars even though the
195
+ * account + plan — read from the plaintext `.claude.json` — showed fine.
196
+ */
158
197
  export declare function loadClaudeOauth(home?: string): Promise<ClaudeOauthCredentials | null>;
159
198
  /**
160
199
  * Derive the Keychain service name for a Claude home directory.
package/dist/lib/usage.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Usage and rate-limit tracking for Claude and Codex agents.
2
+ * Usage and rate-limit tracking for Claude, Codex, Kimi, and Droid agents.
3
3
  *
4
- * Fetches live usage data from the Anthropic OAuth API (Claude) or parses
4
+ * Fetches live usage data from each agent's usage API (Anthropic OAuth for
5
+ * Claude, Kimi Code /usages, Factory billing limits for Droid) or parses
5
6
  * rate-limit events from Codex session logs. Results are normalized into a
6
7
  * common UsageSnapshot shape, cached to disk, and rendered as terminal
7
8
  * progress bars for the `agents view` command.
@@ -14,6 +15,7 @@ import * as path from 'path';
14
15
  import * as readline from 'readline';
15
16
  import { promisify } from 'util';
16
17
  import chalk from 'chalk';
18
+ import { decodeJwtPayload, decryptDroidAuthPayload } from './agents.js';
17
19
  import { walkForFiles } from './fs-walk.js';
18
20
  import { getKeychainToken, setKeychainToken, deleteKeychainToken, } from './secrets/index.js';
19
21
  import { getCacheDir } from './state.js';
@@ -34,6 +36,7 @@ const CLAUDE_KEYCHAIN_SERVICE = 'Claude Code-credentials';
34
36
  const getClaudeUsageCachePath = () => path.join(getCacheDir(), 'claude-usage.json');
35
37
  const CACHED_CLAUDE_USAGE_SOURCE_LABEL = 'last seen live account data';
36
38
  const KIMI_USAGES_URL = 'https://api.kimi.com/coding/v1/usages';
39
+ const DROID_USAGE_URL = 'https://api.factory.ai/api/billing/limits';
37
40
  const COMPACT_BAR_LEN = 5;
38
41
  const USAGE_BAR_LEN = 10;
39
42
  const FULL = '\u2588';
@@ -47,6 +50,8 @@ export async function getUsageInfo(agentId, options) {
47
50
  return getCodexUsageInfo(options);
48
51
  case 'kimi':
49
52
  return getKimiUsageInfo(options);
53
+ case 'droid':
54
+ return getDroidUsageInfo(options);
50
55
  default:
51
56
  return { snapshot: null, error: null };
52
57
  }
@@ -117,13 +122,14 @@ const inFlightRefreshes = new Map();
117
122
  */
118
123
  export async function getUsageInfoForIdentity(input) {
119
124
  const usageKey = getUsageLookupKey(input.info);
120
- // Agents whose usage comes from a live network call (Claude, Kimi) go through
121
- // the stale-while-revalidate cache below so `agents run`/`agents view` stay off
122
- // the network on the hot path. Everything else (Codex reads local session
123
- // logs) takes the legacy blocking path. The on-disk cache is shared and keyed
124
- // by usageKey, which is namespaced per agent (`claude:org=…`, `kimi:user=…`),
125
- // so one cache file holds every account without collision.
126
- const usesNetworkUsage = input.agentId === 'claude' || input.agentId === 'kimi';
125
+ // Agents whose usage comes from a live network call (Claude, Kimi, Droid) go
126
+ // through the stale-while-revalidate cache below so `agents run`/`agents view`
127
+ // stay off the network on the hot path. Everything else (Codex reads local
128
+ // session logs) takes the legacy blocking path. The on-disk cache is shared and
129
+ // keyed by usageKey, which is namespaced per agent (`claude:org=…`,
130
+ // `kimi:user=…`, `droid:org=…`), so one cache file holds every account without
131
+ // collision.
132
+ const usesNetworkUsage = input.agentId === 'claude' || input.agentId === 'kimi' || input.agentId === 'droid';
127
133
  if (!usesNetworkUsage || !usageKey) {
128
134
  return getUsageInfo(input.agentId, {
129
135
  home: input.home,
@@ -201,8 +207,12 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3) {
201
207
  parts.push(chalk.gray(plan.padEnd(planWidth)));
202
208
  }
203
209
  if (snapshot) {
210
+ // Compact rows show the two windows every agent shares (session + week);
211
+ // extra windows (Claude's Sonnet week, Droid's month) render only in the
212
+ // full per-version usage section. An exhausted month window still surfaces
213
+ // here as the rate-limited badge via deriveUsageStatusFromSnapshot.
204
214
  const windows = snapshot.windows
205
- .filter((window) => window.key !== 'sonnet_week')
215
+ .filter((window) => window.key === 'session' || window.key === 'week')
206
216
  .map((window) => `${chalk.gray(`${window.shortLabel}:`)} ${renderCompactUsageBar(window.usedPercent)}`);
207
217
  if (windows.length > 0) {
208
218
  parts.push(windows.join(' '));
@@ -513,6 +523,95 @@ export function formatKimiPlan(data) {
513
523
  return null;
514
524
  return tail.charAt(0).toUpperCase() + tail.slice(1).toLowerCase();
515
525
  }
526
+ /**
527
+ * Fetch Droid usage via Factory's billing limits API — the same endpoint the
528
+ * droid CLI polls for its token-limit banner. The WorkOS access token comes
529
+ * from the locally decrypted ~/.factory/auth.v2.file (the same credential
530
+ * account identity in agents.ts reads).
531
+ *
532
+ * Deliberately NO token refresh, for a sharper reason than Kimi's: WorkOS
533
+ * refresh tokens are single-use and rotate on every exchange, so refreshing
534
+ * here would race a concurrently running droid session and can permanently
535
+ * invalidate the user's login chain. Droid refreshes its own credential when
536
+ * it runs; if the stored token is expired we skip the live fetch and let the
537
+ * SWR cache serve the last-seen snapshot.
538
+ */
539
+ async function getDroidUsageInfo(options) {
540
+ try {
541
+ const cred = decryptDroidAuthPayload(options?.home || os.homedir());
542
+ const accessToken = cred?.access_token;
543
+ if (typeof accessToken !== 'string' || !accessToken) {
544
+ return { snapshot: null, error: null };
545
+ }
546
+ const exp = decodeJwtPayload(accessToken)?.exp;
547
+ if (typeof exp === 'number' && Date.now() / 1000 >= exp) {
548
+ return { snapshot: null, error: null };
549
+ }
550
+ const response = await fetch(DROID_USAGE_URL, {
551
+ method: 'GET',
552
+ headers: {
553
+ Authorization: `Bearer ${accessToken}`,
554
+ Accept: 'application/json',
555
+ },
556
+ signal: AbortSignal.timeout(5000),
557
+ });
558
+ // 401 => revoked/expired token; render nothing rather than a misleading
559
+ // empty bar.
560
+ if (!response.ok) {
561
+ return { snapshot: null, error: null };
562
+ }
563
+ const data = await response.json();
564
+ const windows = normalizeDroidWindows(data);
565
+ if (windows.length === 0) {
566
+ return { snapshot: null, error: null };
567
+ }
568
+ return {
569
+ snapshot: {
570
+ source: 'live',
571
+ sourceLabel: 'live account data',
572
+ capturedAt: new Date(),
573
+ windows,
574
+ },
575
+ error: null,
576
+ };
577
+ }
578
+ catch {
579
+ return { snapshot: null, error: null };
580
+ }
581
+ }
582
+ /**
583
+ * Normalize the Factory billing-limits payload into the common UsageWindow
584
+ * shape. Orgs on the legacy (non token-rate-limit) billing model have no
585
+ * meaningful windows, so they render nothing — mirrors droid's own gate on
586
+ * `usesTokenRateLimitsBilling` before it reads `limits.standard`.
587
+ */
588
+ export function normalizeDroidWindows(data) {
589
+ if (data.usesTokenRateLimitsBilling !== true)
590
+ return [];
591
+ const standard = data.limits?.standard;
592
+ if (!standard)
593
+ return [];
594
+ const windows = [
595
+ normalizeDroidWindow(standard.fiveHour, 'session', 'Current session', 'S'),
596
+ normalizeDroidWindow(standard.weekly, 'week', 'Current week', 'W'),
597
+ normalizeDroidWindow(standard.monthly, 'month', 'Current month', 'M'),
598
+ ];
599
+ return windows.filter((window) => window !== null);
600
+ }
601
+ /** Normalize a single Droid billing-limits window. */
602
+ function normalizeDroidWindow(window, key, label, shortLabel) {
603
+ const usedPercent = normalizePercent(window?.usedPercent);
604
+ if (usedPercent === null)
605
+ return null;
606
+ return {
607
+ key,
608
+ label,
609
+ shortLabel,
610
+ usedPercent,
611
+ resetsAt: parseDateValue(window?.windowEnd),
612
+ windowMinutes: inferWindowMinutes(key),
613
+ };
614
+ }
516
615
  /** Collect Codex JSONL session files sorted newest-first. */
517
616
  function collectCodexSessionFiles(home) {
518
617
  const base = home || os.homedir();
@@ -609,19 +708,16 @@ function normalizeClaudeWindow(window, key, label, shortLabel) {
609
708
  windowMinutes: inferWindowMinutes(key),
610
709
  };
611
710
  }
612
- /** Load Claude OAuth credentials from the system keychain/keyring. */
613
- export async function loadClaudeOauth(home) {
614
- // Windows not yet supported
615
- if (process.platform !== 'darwin' && process.platform !== 'linux') {
616
- return null;
617
- }
711
+ /**
712
+ * Parse a wrapped Claude OAuth payload — the `{ claudeAiOauth, organizationUuid }`
713
+ * shape written by BOTH the macOS Keychain item and the Linux `.credentials.json`
714
+ * file into our credential struct. Returns null when there is no usable access
715
+ * token. Never throws (malformed JSON => null).
716
+ */
717
+ function parseClaudeOauthPayload(raw) {
618
718
  try {
619
- const service = getClaudeKeychainService(home);
620
- const stdout = getKeychainToken(service);
621
- const payload = JSON.parse(stdout.trim());
622
- if (typeof payload?.claudeAiOauth?.accessToken !== 'string')
623
- return null;
624
- if (!payload.claudeAiOauth) {
719
+ const payload = JSON.parse(raw.trim());
720
+ if (!payload?.claudeAiOauth || typeof payload.claudeAiOauth.accessToken !== 'string') {
625
721
  return null;
626
722
  }
627
723
  return {
@@ -633,6 +729,48 @@ export async function loadClaudeOauth(home) {
633
729
  return null;
634
730
  }
635
731
  }
732
+ /**
733
+ * Load a version home's Claude OAuth credential from the two stores Claude Code
734
+ * uses, tried in order:
735
+ *
736
+ * 1. The OS keychain (`getKeychainToken`). Canonical on macOS — Claude Code
737
+ * writes the token to the login keychain and we read it via `/usr/bin/security`.
738
+ * 2. `<home>/.claude/.credentials.json`. On a headless Linux box (the
739
+ * `agents view --host <linux>` case) there is no reachable Secret Service, so
740
+ * the Claude CLI stores its OAuth token in this plaintext file instead. The
741
+ * keychain read above finds nothing on that platform, so we fall back to the
742
+ * file. Same wrapped `{ claudeAiOauth }` shape, so one parser handles both.
743
+ * Mirrors `readClaudeCredentialsBlob` (cloud/rush.ts), the proven pattern.
744
+ *
745
+ * Without step 2 the live usage fetch got no token on Linux, so `agents view`
746
+ * (run remotely over SSH by `--host`) rendered no usage bars even though the
747
+ * account + plan — read from the plaintext `.claude.json` — showed fine.
748
+ */
749
+ export async function loadClaudeOauth(home) {
750
+ // The OS keychain/keyring step is macOS/Linux-only. Windows skips straight
751
+ // to the .credentials.json fallback — the Claude CLI has no keychain
752
+ // integration there and stores its OAuth token in that file too.
753
+ if (process.platform === 'darwin' || process.platform === 'linux') {
754
+ try {
755
+ const fromKeychain = parseClaudeOauthPayload(getKeychainToken(getClaudeKeychainService(home)));
756
+ if (fromKeychain)
757
+ return fromKeychain;
758
+ }
759
+ catch {
760
+ // No keychain item, or no reachable keyring (headless Linux) — fall through.
761
+ }
762
+ }
763
+ const credsPath = path.join(home ?? os.homedir(), '.claude', '.credentials.json');
764
+ try {
765
+ if (fs.existsSync(credsPath)) {
766
+ return parseClaudeOauthPayload(fs.readFileSync(credsPath, 'utf-8'));
767
+ }
768
+ }
769
+ catch {
770
+ // Unreadable file — treat as not signed in.
771
+ }
772
+ return null;
773
+ }
636
774
  /**
637
775
  * Save Claude OAuth credentials to the system keychain/keyring.
638
776
  * Reads the existing payload, merges the new OAuth fields, and writes back.
@@ -895,6 +1033,8 @@ function inferWindowMinutes(key) {
895
1033
  case 'week':
896
1034
  case 'sonnet_week':
897
1035
  return 10080;
1036
+ case 'month':
1037
+ return 43200;
898
1038
  }
899
1039
  }
900
1040
  /** Parse a date value from a number (epoch seconds or ms) or ISO string. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.63",
3
+ "version": "1.20.64",
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",