@phnx-labs/agents-cli 1.20.68 → 1.20.70

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 (53) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +30 -7
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/computer.d.ts +26 -0
  5. package/dist/commands/computer.js +173 -149
  6. package/dist/commands/doctor.js +5 -0
  7. package/dist/commands/exec.d.ts +18 -0
  8. package/dist/commands/exec.js +88 -6
  9. package/dist/commands/fork.d.ts +9 -0
  10. package/dist/commands/fork.js +67 -0
  11. package/dist/commands/run-account-picker.d.ts +14 -0
  12. package/dist/commands/run-account-picker.js +131 -0
  13. package/dist/commands/setup-browser.d.ts +18 -0
  14. package/dist/commands/setup-browser.js +142 -0
  15. package/dist/commands/setup-computer.d.ts +19 -0
  16. package/dist/commands/setup-computer.js +133 -0
  17. package/dist/commands/setup-share.d.ts +17 -0
  18. package/dist/commands/setup-share.js +85 -0
  19. package/dist/commands/setup.d.ts +1 -1
  20. package/dist/commands/setup.js +58 -1
  21. package/dist/commands/share.d.ts +15 -0
  22. package/dist/commands/share.js +10 -4
  23. package/dist/commands/ssh.js +202 -9
  24. package/dist/commands/view.d.ts +4 -14
  25. package/dist/commands/view.js +32 -30
  26. package/dist/index.js +2 -1
  27. package/dist/lib/agents.d.ts +10 -0
  28. package/dist/lib/agents.js +32 -0
  29. package/dist/lib/auth-health.d.ts +141 -0
  30. package/dist/lib/auth-health.js +277 -0
  31. package/dist/lib/browser/chrome.d.ts +9 -0
  32. package/dist/lib/browser/chrome.js +22 -0
  33. package/dist/lib/computer/download.d.ts +54 -0
  34. package/dist/lib/computer/download.js +146 -0
  35. package/dist/lib/computer-rpc.js +9 -3
  36. package/dist/lib/daemon.js +38 -0
  37. package/dist/lib/devices/connect.d.ts +15 -0
  38. package/dist/lib/devices/connect.js +17 -5
  39. package/dist/lib/devices/health-report.d.ts +11 -0
  40. package/dist/lib/devices/health-report.js +73 -4
  41. package/dist/lib/devices/stats-cache.d.ts +36 -0
  42. package/dist/lib/devices/stats-cache.js +115 -0
  43. package/dist/lib/devices/terminfo.d.ts +44 -0
  44. package/dist/lib/devices/terminfo.js +167 -0
  45. package/dist/lib/rotate.d.ts +12 -8
  46. package/dist/lib/rotate.js +22 -23
  47. package/dist/lib/session/fork.d.ts +32 -0
  48. package/dist/lib/session/fork.js +101 -0
  49. package/dist/lib/startup/command-registry.d.ts +1 -0
  50. package/dist/lib/startup/command-registry.js +2 -0
  51. package/dist/lib/usage.d.ts +23 -0
  52. package/dist/lib/usage.js +84 -0
  53. package/package.json +1 -1
@@ -11,6 +11,8 @@ import { type UsageSnapshot } from './usage.js';
11
11
  export interface RotateCandidate {
12
12
  agent: AgentId;
13
13
  version: string;
14
+ accountKey: string | null;
15
+ accountLabel: string;
14
16
  email: string | null;
15
17
  /**
16
18
  * Per-org usage/quota key (e.g. `claude:org=<orgUuid>`) — the unit rate
@@ -21,7 +23,9 @@ export interface RotateCandidate {
21
23
  usageKey: string | null;
22
24
  usageStatus: AccountInfo['usageStatus'];
23
25
  usageSnapshot: UsageSnapshot | null;
24
- authValid: boolean;
26
+ usageError: string | null;
27
+ plan: string | null;
28
+ signedIn: boolean;
25
29
  lastActive: Date | null;
26
30
  }
27
31
  export interface RotateResult {
@@ -58,7 +62,7 @@ export declare function getConfiguredRunStrategy(agent: AgentId, startPath?: str
58
62
  export declare function setGlobalRunStrategy(agent: AgentId, strategy: RunStrategy): void;
59
63
  /**
60
64
  * Whether a specific account can serve a run right now, and — when it can't —
61
- * why. `signed_out` covers no-email / invalid-auth; `rate_limited` and
65
+ * why. `signed_out` covers a missing usable credential; `rate_limited` and
62
66
  * `out_of_credits` name the throttle. Used to pre-warn on a version-pinned
63
67
  * teammate whose account rotation won't route around (a pin IS the target).
64
68
  */
@@ -71,7 +75,7 @@ export type AccountReadiness = {
71
75
  };
72
76
  /**
73
77
  * Pure decision reusing the router's own eligibility gate (`hasUsageAvailable`
74
- * + email/auth, i.e. `isRotationEligible`), so a pre-flight warning can NEVER
78
+ * + canonical signed-in state, i.e. `isRotationEligible`), so a pre-flight warning can NEVER
75
79
  * disagree with what rotation would actually do. The `reason` combines the two
76
80
  * signals `hasUsageAvailable` reads: the live snapshot (session-inclusive
77
81
  * rate-limit) and the coarse cached `usageStatus` (out-of-credits, which a
@@ -100,17 +104,16 @@ export declare function checkRunAccountReadiness(agent: AgentId, version: string
100
104
  * headroom, with no stampede on the lowest-usage one. Stateless — parallel
101
105
  * callers naturally fan out via the random roll.
102
106
  *
103
- * Eligibility: signed in (email present), auth valid, and not currently
107
+ * Eligibility: signed in according to AccountInfo and not currently
104
108
  * rate-limited — no blocking window (session OR weekly) at 100%, matching the
105
109
  * `agents view` badge; or the local cached status is usable when no live
106
110
  * snapshot exists. Note the split: eligibility considers the session window
107
111
  * (a session-maxed account can't run now), but the capacity *weight* above is
108
112
  * driven by weekly headroom so a brief session spike doesn't distort routing.
109
113
  *
110
- * Dedupe: when multiple versions share an email, collapse to one candidate
111
- * per email (the least-recently-active version). Prevents two parallel pods
112
- * from "balancing" to different versions but hitting the same Anthropic
113
- * account and both 429ing.
114
+ * Dedupe: when multiple versions share a usage/account identity, collapse to
115
+ * one candidate (the least-recently-active version). The org-scoped usage key
116
+ * wins over email so same-email personal and Team accounts remain distinct.
114
117
  *
115
118
  * Returns null if no candidate is eligible — callers fall back to the pinned
116
119
  * version so behavior stays predictable.
@@ -122,6 +125,7 @@ export declare function pickBalancedCandidate(candidates: RotateCandidate[]): Ro
122
125
  * usage headroom.
123
126
  */
124
127
  export declare function pickAvailableCandidate(candidates: RotateCandidate[], preferredVersion?: string | null): RotateResult | null;
128
+ export declare function collectRunCandidates(agent: AgentId): Promise<RotateCandidate[]>;
125
129
  /**
126
130
  * Pick a healthy version for `agent` using weighted random by remaining
127
131
  * capacity. See `pickBalancedCandidate` for algorithm details.
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
- import { getAccountInfo } from './agents.js';
9
+ import { accountDisplayLabel, getAccountInfo } from './agents.js';
10
10
  import { readMeta, writeMeta, getHelpersDir } from './state.js';
11
11
  import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
12
12
  import { getProjectRunConfigs } from './run-config.js';
@@ -65,14 +65,10 @@ export function setGlobalRunStrategy(agent, strategy) {
65
65
  writeMeta(meta);
66
66
  }
67
67
  function isRotationEligible(candidate) {
68
- return !!candidate.email
69
- && candidate.authValid
70
- && hasUsageAvailable(candidate);
68
+ return candidate.signedIn && hasUsageAvailable(candidate);
71
69
  }
72
70
  function isAvailableEligible(candidate) {
73
- return !!candidate.email
74
- && candidate.authValid
75
- && hasUsageAvailable(candidate);
71
+ return isRotationEligible(candidate);
76
72
  }
77
73
  function hasUsageAvailable(candidate) {
78
74
  const snapshot = candidate.usageSnapshot;
@@ -95,7 +91,7 @@ function hasUsageAvailable(candidate) {
95
91
  }
96
92
  /**
97
93
  * Pure decision reusing the router's own eligibility gate (`hasUsageAvailable`
98
- * + email/auth, i.e. `isRotationEligible`), so a pre-flight warning can NEVER
94
+ * + canonical signed-in state, i.e. `isRotationEligible`), so a pre-flight warning can NEVER
99
95
  * disagree with what rotation would actually do. The `reason` combines the two
100
96
  * signals `hasUsageAvailable` reads: the live snapshot (session-inclusive
101
97
  * rate-limit) and the coarse cached `usageStatus` (out-of-credits, which a
@@ -104,7 +100,7 @@ function hasUsageAvailable(candidate) {
104
100
  * reported while the account is actually serving requests.
105
101
  */
106
102
  export function readinessFromCandidate(candidate) {
107
- if (!candidate.email || !candidate.authValid) {
103
+ if (!candidate.signedIn) {
108
104
  return { ready: false, reason: 'signed_out', email: candidate.email };
109
105
  }
110
106
  if (hasUsageAvailable(candidate)) {
@@ -162,7 +158,7 @@ function compareCandidates(a, b) {
162
158
  * key; fall back to email only when no usage identity is available.
163
159
  */
164
160
  function candidateIdentity(c) {
165
- return c.usageKey ?? c.email;
161
+ return c.usageKey ?? c.accountKey ?? c.email ?? `${c.agent}@${c.version}`;
166
162
  }
167
163
  function dedupeAndSortCandidates(candidates) {
168
164
  const byIdentity = new Map();
@@ -189,17 +185,16 @@ function dedupeAndSortCandidates(candidates) {
189
185
  * headroom, with no stampede on the lowest-usage one. Stateless — parallel
190
186
  * callers naturally fan out via the random roll.
191
187
  *
192
- * Eligibility: signed in (email present), auth valid, and not currently
188
+ * Eligibility: signed in according to AccountInfo and not currently
193
189
  * rate-limited — no blocking window (session OR weekly) at 100%, matching the
194
190
  * `agents view` badge; or the local cached status is usable when no live
195
191
  * snapshot exists. Note the split: eligibility considers the session window
196
192
  * (a session-maxed account can't run now), but the capacity *weight* above is
197
193
  * driven by weekly headroom so a brief session spike doesn't distort routing.
198
194
  *
199
- * Dedupe: when multiple versions share an email, collapse to one candidate
200
- * per email (the least-recently-active version). Prevents two parallel pods
201
- * from "balancing" to different versions but hitting the same Anthropic
202
- * account and both 429ing.
195
+ * Dedupe: when multiple versions share a usage/account identity, collapse to
196
+ * one candidate (the least-recently-active version). The org-scoped usage key
197
+ * wins over email so same-email personal and Team accounts remain distinct.
203
198
  *
204
199
  * Returns null if no candidate is eligible — callers fall back to the pinned
205
200
  * version so behavior stays predictable.
@@ -278,12 +273,11 @@ export function pickAvailableCandidate(candidates, preferredVersion) {
278
273
  : undefined;
279
274
  return { picked: preferred ?? sorted[0], healthy: sorted, excluded };
280
275
  }
281
- async function collectRunCandidates(agent) {
276
+ export async function collectRunCandidates(agent) {
282
277
  const versions = listInstalledVersions(agent);
283
278
  const rows = await Promise.all(versions.map(async (version) => {
284
279
  const home = getVersionHomePath(agent, version);
285
280
  const info = await getAccountInfo(agent, home);
286
- // `info.email` (from .claude.json's oauthAccount) is the auth heuristic.
287
281
  // We used to additionally call isClaudeAuthValid(home), which reads
288
282
  // "Claude Code-credentials-<hash>" from the system keychain. That item is
289
283
  // written by Claude Code itself with its own process in the ACL, so our
@@ -291,15 +285,17 @@ async function collectRunCandidates(agent) {
291
285
  // one per installed version, every time `agents run` cold-starts. If
292
286
  // claude's stored token has actually expired, the spawned agent detects
293
287
  // it at its own startup and re-auths; that's the correct UX.
294
- const authValid = info.email != null;
295
288
  return {
296
289
  agent,
297
290
  version,
298
291
  home,
299
292
  info,
293
+ accountKey: info.accountKey,
294
+ accountLabel: accountDisplayLabel(info),
300
295
  email: info.email,
301
296
  usageStatus: info.usageStatus,
302
- authValid,
297
+ plan: info.plan,
298
+ signedIn: info.signedIn,
303
299
  lastActive: info.lastActive,
304
300
  };
305
301
  }));
@@ -311,10 +307,13 @@ async function collectRunCandidates(agent) {
311
307
  })));
312
308
  return rows.map(({ home: _home, info, ...candidate }) => {
313
309
  const usageKey = getUsageLookupKey(info);
314
- const usageSnapshot = usageKey
315
- ? usageByKey.get(usageKey)?.snapshot ?? null
316
- : null;
317
- return { ...candidate, usageKey, usageSnapshot };
310
+ const usage = usageKey ? usageByKey.get(usageKey) : undefined;
311
+ return {
312
+ ...candidate,
313
+ usageKey,
314
+ usageSnapshot: usage?.snapshot ?? null,
315
+ usageError: usage?.error ?? null,
316
+ };
318
317
  });
319
318
  }
320
319
  /**
@@ -0,0 +1,32 @@
1
+ import type { SessionMeta } from './types.js';
2
+ /** Agents that `fork` can branch today (see the module doc for why). */
3
+ export declare const FORKABLE_AGENTS: readonly ["claude"];
4
+ /** Whether a session's agent can be forked by {@link forkSession}. */
5
+ export declare function isForkableAgent(agent: string): boolean;
6
+ /** Outcome of a successful fork. */
7
+ export interface ForkResult {
8
+ /** The new session's full id. */
9
+ newId: string;
10
+ /** The new session's short id (first 8 chars), for display/resume. */
11
+ shortId: string;
12
+ /** Absolute path of the copied transcript. */
13
+ filePath: string;
14
+ /** The label applied to the fork. */
15
+ label: string;
16
+ }
17
+ /**
18
+ * Fork a Claude session into a new, independent one.
19
+ *
20
+ * Copies `source.filePath` to a new `<uuid>.jsonl` beside it, rewrites the
21
+ * embedded session id, registers the new session in the index, and records a
22
+ * `--name`-style label. Returns the new ids/path. Throws if the source
23
+ * transcript is missing.
24
+ *
25
+ * @param source The resolved metadata of the session being forked.
26
+ * @param opts.name Optional explicit label; defaults to `fork of <original>`.
27
+ * @param now ISO timestamp to stamp the fork with (injectable for tests).
28
+ */
29
+ export declare function forkSession(source: SessionMeta, opts?: {
30
+ name?: string;
31
+ now?: string;
32
+ }): ForkResult;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Session forking — branch an existing conversation into a new, independent
3
+ * session that can be continued separately, leaving the original untouched.
4
+ *
5
+ * `resume` continues the SAME conversation (same id, same file — it appends).
6
+ * `fork` copies the transcript under a FRESH session id, so continuing the fork
7
+ * diverges from the original instead of mutating it. This is the "git branch"
8
+ * of conversations.
9
+ *
10
+ * v1 supports Claude, whose session id IS its `<id>.jsonl` filename and which
11
+ * resumes natively via `--resume`. A fork is therefore: copy the transcript to
12
+ * a new-uuid filename in the same directory, rewrite the embedded `sessionId`
13
+ * on each line, register the new session in the index, and label it. Other
14
+ * agents (codex single-file; grok/kimi multi-file; opencode DB-only) are a
15
+ * natural follow-up and are refused up front for now.
16
+ */
17
+ import { randomUUID } from 'crypto';
18
+ import * as fs from 'fs';
19
+ import * as path from 'path';
20
+ import { upsertSession } from './db.js';
21
+ import { recordRunName } from './run-names.js';
22
+ /** Agents that `fork` can branch today (see the module doc for why). */
23
+ export const FORKABLE_AGENTS = ['claude'];
24
+ /** Whether a session's agent can be forked by {@link forkSession}. */
25
+ export function isForkableAgent(agent) {
26
+ return FORKABLE_AGENTS.includes(agent);
27
+ }
28
+ /**
29
+ * Rewrite the per-line `sessionId` field of a Claude JSONL transcript to a new
30
+ * id. Claude resolves a conversation by its filename, so this is belt-and-braces
31
+ * (keeps the in-file id consistent with the new filename); malformed lines are
32
+ * passed through untouched.
33
+ */
34
+ function rewriteSessionId(transcript, newId) {
35
+ return transcript
36
+ .split('\n')
37
+ .map((line) => {
38
+ if (!line.trim())
39
+ return line;
40
+ try {
41
+ const obj = JSON.parse(line);
42
+ if (typeof obj.sessionId === 'string') {
43
+ obj.sessionId = newId;
44
+ return JSON.stringify(obj);
45
+ }
46
+ return line;
47
+ }
48
+ catch {
49
+ return line;
50
+ }
51
+ })
52
+ .join('\n');
53
+ }
54
+ /**
55
+ * Fork a Claude session into a new, independent one.
56
+ *
57
+ * Copies `source.filePath` to a new `<uuid>.jsonl` beside it, rewrites the
58
+ * embedded session id, registers the new session in the index, and records a
59
+ * `--name`-style label. Returns the new ids/path. Throws if the source
60
+ * transcript is missing.
61
+ *
62
+ * @param source The resolved metadata of the session being forked.
63
+ * @param opts.name Optional explicit label; defaults to `fork of <original>`.
64
+ * @param now ISO timestamp to stamp the fork with (injectable for tests).
65
+ */
66
+ export function forkSession(source, opts = {}) {
67
+ if (!fs.existsSync(source.filePath)) {
68
+ throw new Error(`transcript not found for session ${source.shortId}: ${source.filePath}`);
69
+ }
70
+ const newId = randomUUID();
71
+ const shortId = newId.slice(0, 8);
72
+ const dir = path.dirname(source.filePath);
73
+ const filePath = path.join(dir, `${newId}.jsonl`);
74
+ const transcript = fs.readFileSync(source.filePath, 'utf-8');
75
+ const rewritten = rewriteSessionId(transcript, newId);
76
+ fs.writeFileSync(filePath, rewritten);
77
+ const original = source.label || source.topic || source.shortId;
78
+ const label = opts.name || `fork of ${original}`;
79
+ // Label sidecar (seeds the DB label; survives rescans until an agent title
80
+ // supersedes it), mirroring `agents run --name`.
81
+ recordRunName({ sessionId: newId, name: label, agent: source.agent, cwd: source.cwd });
82
+ // Register the new session so it resolves immediately (by `agents resume`,
83
+ // `agents sessions`, etc.) without waiting for the next scan.
84
+ const stamp = opts.now ?? new Date().toISOString();
85
+ const meta = {
86
+ ...source,
87
+ id: newId,
88
+ shortId,
89
+ filePath,
90
+ label,
91
+ timestamp: stamp,
92
+ lastActivity: stamp,
93
+ // The fork has not opened its own PR / team; drop origin-specific refs.
94
+ prUrl: undefined,
95
+ prNumber: undefined,
96
+ teamOrigin: undefined,
97
+ spawnedTeam: undefined,
98
+ };
99
+ upsertSession(meta, rewritten);
100
+ return { newId, shortId, filePath, label };
101
+ }
@@ -45,6 +45,7 @@ export declare const loadDaemon: ModuleLoader;
45
45
  export declare const loadRoutines: ModuleLoader;
46
46
  export declare const loadMonitors: ModuleLoader;
47
47
  export declare const loadRun: ModuleLoader;
48
+ export declare const loadFork: ModuleLoader;
48
49
  export declare const loadDefaults: ModuleLoader;
49
50
  export declare const loadModels: ModuleLoader;
50
51
  export declare const loadPrune: ModuleLoader;
@@ -23,6 +23,7 @@ export const loadDaemon = async () => (await import('../../commands/daemon.js'))
23
23
  export const loadRoutines = async () => (await import('../../commands/routines.js')).registerRoutinesCommands;
24
24
  export const loadMonitors = async () => (await import('../../commands/monitors.js')).registerMonitorsCommands;
25
25
  export const loadRun = async () => (await import('../../commands/exec.js')).registerRunCommand;
26
+ export const loadFork = async () => (await import('../../commands/fork.js')).registerForkCommand;
26
27
  export const loadDefaults = async () => (await import('../../commands/defaults.js')).registerDefaultsCommands;
27
28
  export const loadModels = async () => (await import('../../commands/models.js')).registerModelsCommand;
28
29
  export const loadPrune = async () => (await import('../../commands/prune.js')).registerPruneCommand;
@@ -126,6 +127,7 @@ export const COMMAND_LOADERS = {
126
127
  routines: [loadRoutines],
127
128
  monitors: [loadMonitors],
128
129
  run: [loadRun],
130
+ fork: [loadFork],
129
131
  defaults: [loadDefaults],
130
132
  models: [loadModels],
131
133
  trash: [loadTrash],
@@ -184,6 +184,29 @@ export interface DroidBillingLimitsResponse {
184
184
  } | null;
185
185
  } | null;
186
186
  }
187
+ /**
188
+ * Live auth probes — the same authenticated GET the usage fetchers above do,
189
+ * but surfacing the raw HTTP status instead of swallowing 401/expired to null.
190
+ * These back `agents fleet ping` and the fleet auth-health cache: completing a
191
+ * real request is the only proof a token is accepted. The local "signed in"
192
+ * flag cannot tell a revoked-but-unexpired token from a good one. Classification
193
+ * of the returned status into a verdict lives in lib/auth-health.ts (kept there
194
+ * so it stays pure/testable and to avoid an import cycle).
195
+ */
196
+ export interface ProviderProbe {
197
+ /** HTTP status of the probe request, or null when no request was made (missing/expired token) or the request threw. */
198
+ status: number | null;
199
+ /** Local credential state observed before the request. */
200
+ token: 'present' | 'missing' | 'expired';
201
+ /** Network/parse error message when status is null but a token was present. */
202
+ error?: string;
203
+ }
204
+ /** Probe Claude's OAuth token against the usage endpoint. Refreshes an expired access token (safe for Claude). */
205
+ export declare function probeClaudeStatus(home?: string, cliVersion?: string | null): Promise<ProviderProbe>;
206
+ /** Probe Kimi's OAuth token against the /usages endpoint. Never refreshes (single-use rotation — see getKimiUsageInfo). */
207
+ export declare function probeKimiStatus(home?: string): Promise<ProviderProbe>;
208
+ /** Probe Droid's WorkOS token against the billing-limits endpoint. Never refreshes (single-use rotation — see getDroidUsageInfo). */
209
+ export declare function probeDroidStatus(home?: string): Promise<ProviderProbe>;
187
210
  /**
188
211
  * Normalize the Factory billing-limits payload into the common UsageWindow
189
212
  * shape. Orgs on the legacy (non token-rate-limit) billing model have no
package/dist/lib/usage.js CHANGED
@@ -609,6 +609,90 @@ async function getDroidUsageInfo(options) {
609
609
  return { snapshot: null, error: null };
610
610
  }
611
611
  }
612
+ /** Probe Claude's OAuth token against the usage endpoint. Refreshes an expired access token (safe for Claude). */
613
+ export async function probeClaudeStatus(home, cliVersion) {
614
+ const oauth = await loadClaudeOauth(home);
615
+ if (!oauth?.accessToken)
616
+ return { status: null, token: 'missing' };
617
+ let access = null;
618
+ try {
619
+ access = await getClaudeAccessToken(oauth, home);
620
+ }
621
+ catch {
622
+ access = null;
623
+ }
624
+ // Fall back to the stored (possibly stale) token so the server returns the
625
+ // real verdict — a 401 here IS the revoked/expired signal we want to surface.
626
+ const bearer = access || oauth.accessToken.trim();
627
+ try {
628
+ const response = await fetch(CLAUDE_USAGE_URL, {
629
+ method: 'GET',
630
+ headers: {
631
+ Authorization: `Bearer ${bearer}`,
632
+ 'Content-Type': 'application/json',
633
+ 'anthropic-beta': CLAUDE_OAUTH_BETA_HEADER,
634
+ 'User-Agent': getClaudeUserAgent(cliVersion),
635
+ },
636
+ signal: AbortSignal.timeout(8000),
637
+ });
638
+ return { status: response.status, token: 'present' };
639
+ }
640
+ catch (err) {
641
+ return { status: null, token: 'present', error: err instanceof Error ? err.message : String(err) };
642
+ }
643
+ }
644
+ /** Probe Kimi's OAuth token against the /usages endpoint. Never refreshes (single-use rotation — see getKimiUsageInfo). */
645
+ export async function probeKimiStatus(home) {
646
+ const credPath = resolveKimiCredentialPath(home);
647
+ if (!credPath)
648
+ return { status: null, token: 'missing' };
649
+ let accessToken;
650
+ let expiresAt = null;
651
+ try {
652
+ const cred = JSON.parse(fs.readFileSync(credPath, 'utf-8'));
653
+ accessToken = typeof cred?.access_token === 'string' ? cred.access_token : undefined;
654
+ expiresAt = typeof cred?.expires_at === 'number' ? cred.expires_at : null;
655
+ }
656
+ catch {
657
+ return { status: null, token: 'missing' };
658
+ }
659
+ if (!accessToken)
660
+ return { status: null, token: 'missing' };
661
+ if (expiresAt !== null && Date.now() / 1000 >= expiresAt)
662
+ return { status: null, token: 'expired' };
663
+ try {
664
+ const response = await fetch(KIMI_USAGES_URL, {
665
+ method: 'GET',
666
+ headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
667
+ signal: AbortSignal.timeout(8000),
668
+ });
669
+ return { status: response.status, token: 'present' };
670
+ }
671
+ catch (err) {
672
+ return { status: null, token: 'present', error: err instanceof Error ? err.message : String(err) };
673
+ }
674
+ }
675
+ /** Probe Droid's WorkOS token against the billing-limits endpoint. Never refreshes (single-use rotation — see getDroidUsageInfo). */
676
+ export async function probeDroidStatus(home) {
677
+ const cred = decryptDroidAuthPayload(home || os.homedir());
678
+ const accessToken = cred?.access_token;
679
+ if (typeof accessToken !== 'string' || !accessToken)
680
+ return { status: null, token: 'missing' };
681
+ const exp = decodeJwtPayload(accessToken)?.exp;
682
+ if (typeof exp === 'number' && Date.now() / 1000 >= exp)
683
+ return { status: null, token: 'expired' };
684
+ try {
685
+ const response = await fetch(DROID_USAGE_URL, {
686
+ method: 'GET',
687
+ headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
688
+ signal: AbortSignal.timeout(8000),
689
+ });
690
+ return { status: response.status, token: 'present' };
691
+ }
692
+ catch (err) {
693
+ return { status: null, token: 'present', error: err instanceof Error ? err.message : String(err) };
694
+ }
695
+ }
612
696
  /**
613
697
  * Normalize the Factory billing-limits payload into the common UsageWindow
614
698
  * shape. Orgs on the legacy (non token-rate-limit) billing model have no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.68",
3
+ "version": "1.20.70",
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",