@phnx-labs/agents-cli 1.20.86 → 1.20.88

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 (82) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +3 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +0 -19
  5. package/dist/commands/doctor.js +219 -305
  6. package/dist/commands/exec.js +7 -19
  7. package/dist/commands/inspect.js +3 -5
  8. package/dist/commands/routines.js +2 -2
  9. package/dist/commands/sessions-browser.d.ts +18 -0
  10. package/dist/commands/sessions-browser.js +126 -24
  11. package/dist/commands/sessions-picker.d.ts +21 -8
  12. package/dist/commands/sessions-picker.js +83 -7
  13. package/dist/commands/sessions.d.ts +19 -0
  14. package/dist/commands/sessions.js +148 -18
  15. package/dist/commands/ssh.js +62 -4
  16. package/dist/commands/teams-picker.d.ts +2 -0
  17. package/dist/commands/teams-picker.js +2 -1
  18. package/dist/commands/teams.d.ts +4 -1
  19. package/dist/commands/teams.js +106 -70
  20. package/dist/commands/usage.d.ts +3 -2
  21. package/dist/commands/usage.js +2 -9
  22. package/dist/commands/view.js +14 -3
  23. package/dist/index.js +31 -1
  24. package/dist/lib/agents.d.ts +31 -1
  25. package/dist/lib/agents.js +55 -0
  26. package/dist/lib/claude-account-token.d.ts +12 -0
  27. package/dist/lib/claude-account-token.js +63 -0
  28. package/dist/lib/command-skills.d.ts +10 -0
  29. package/dist/lib/command-skills.js +14 -0
  30. package/dist/lib/commands.js +19 -1
  31. package/dist/lib/daemon.js +17 -2
  32. package/dist/lib/devices/doctor-findings.d.ts +167 -0
  33. package/dist/lib/devices/doctor-findings.js +893 -0
  34. package/dist/lib/devices/fleet-divergence.d.ts +22 -0
  35. package/dist/lib/devices/fleet-divergence.js +34 -10
  36. package/dist/lib/devices/fleet-inventory.d.ts +17 -6
  37. package/dist/lib/devices/fleet-inventory.js +56 -8
  38. package/dist/lib/devices/registry.d.ts +25 -0
  39. package/dist/lib/devices/registry.js +82 -1
  40. package/dist/lib/events.d.ts +8 -1
  41. package/dist/lib/events.js +13 -0
  42. package/dist/lib/exec.d.ts +14 -3
  43. package/dist/lib/exec.js +51 -9
  44. package/dist/lib/format.d.ts +7 -0
  45. package/dist/lib/format.js +11 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  47. package/dist/lib/project-resources.js +34 -20
  48. package/dist/lib/runner.d.ts +14 -1
  49. package/dist/lib/runner.js +37 -8
  50. package/dist/lib/sandbox.d.ts +2 -0
  51. package/dist/lib/sandbox.js +38 -0
  52. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  53. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  54. package/dist/lib/secrets/rc-hygiene.d.ts +0 -6
  55. package/dist/lib/secrets/rc-hygiene.js +0 -24
  56. package/dist/lib/session/active.d.ts +6 -6
  57. package/dist/lib/session/active.js +6 -6
  58. package/dist/lib/session/db.d.ts +21 -0
  59. package/dist/lib/session/db.js +45 -4
  60. package/dist/lib/session/discover.d.ts +5 -0
  61. package/dist/lib/session/discover.js +137 -1
  62. package/dist/lib/session/parse.d.ts +2 -0
  63. package/dist/lib/session/parse.js +76 -37
  64. package/dist/lib/session/remote-list.d.ts +7 -0
  65. package/dist/lib/session/remote-list.js +8 -4
  66. package/dist/lib/session/state.js +69 -2
  67. package/dist/lib/session/sync/agents.js +0 -0
  68. package/dist/lib/session/team-filter.d.ts +22 -3
  69. package/dist/lib/session/team-filter.js +106 -17
  70. package/dist/lib/session/types.d.ts +9 -1
  71. package/dist/lib/session/types.js +1 -1
  72. package/dist/lib/signin-badge.d.ts +17 -0
  73. package/dist/lib/signin-badge.js +19 -0
  74. package/dist/lib/staleness/detectors/commands.js +14 -5
  75. package/dist/lib/staleness/types.d.ts +2 -0
  76. package/dist/lib/staleness/writers/commands.js +13 -7
  77. package/dist/lib/state.d.ts +2 -0
  78. package/dist/lib/state.js +2 -0
  79. package/dist/lib/usage.d.ts +72 -1
  80. package/dist/lib/usage.js +22 -87
  81. package/dist/lib/versions.js +30 -13
  82. package/package.json +1 -1
@@ -9,6 +9,7 @@
9
9
  import * as fs from 'fs';
10
10
  import * as os from 'os';
11
11
  import * as path from 'path';
12
+ import { sanitizeForTerminal } from './parse.js';
12
13
  import { getTeamsAgentsDir } from '../state.js';
13
14
  const HOME = os.homedir();
14
15
  // Default path; tests can override via AGENTS_TEAMS_DIR env var.
@@ -21,33 +22,121 @@ function teamsAgentsDir() {
21
22
  *
22
23
  * Primary signal is `session.isTeamOrigin`, captured at scan time from the
23
24
  * JSONL `entrypoint` field ('sdk-cli' for team spawns, 'cli' for real CLI).
24
- * When a team meta.json exists we additionally enrich with handle/mode — but
25
- * its absence no longer demotes a session: older team runs whose meta dir
26
- * was cleaned up still get recognized via the entrypoint flag.
25
+ * When a team meta.json exists we additionally enrich with handle/mode/team and
26
+ * the orchestrator that spawned it but its absence no longer demotes a
27
+ * session: older team runs whose meta dir was cleaned up still get recognized
28
+ * via the entrypoint flag.
27
29
  *
28
30
  * Returns the TeamOrigin metadata when the session is team-origin, or null
29
31
  * when it is a normal interactive session.
30
32
  */
31
33
  export function classifyTeamSession(session) {
32
- const metaPath = path.join(teamsAgentsDir(), session.id, 'meta.json');
33
- if (fs.existsSync(metaPath)) {
34
- try {
35
- const raw = fs.readFileSync(metaPath, 'utf-8');
36
- const meta = JSON.parse(raw);
37
- const name = typeof meta.name === 'string' && meta.name ? meta.name : undefined;
38
- const handle = name ?? session.id.slice(0, 8);
39
- const mode = typeof meta.mode === 'string' ? meta.mode : undefined;
40
- return { handle, mode };
41
- }
42
- catch {
43
- return { handle: session.id.slice(0, 8) };
44
- }
45
- }
34
+ const origin = teamOriginIndex().get(session.id);
35
+ if (origin)
36
+ return origin;
46
37
  if (session.isTeamOrigin) {
47
38
  return { handle: session.id.slice(0, 8) };
48
39
  }
49
40
  return null;
50
41
  }
42
+ /**
43
+ * Parse one teammate `meta.json` into a {@link TeamOrigin}. Degrades to a bare
44
+ * handle when the file is unreadable or malformed — a teammate whose record we
45
+ * can't parse is still a teammate.
46
+ */
47
+ function readTeamOrigin(metaPath, agentId) {
48
+ try {
49
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
50
+ const str = (v) => (typeof v === 'string' && v ? v : undefined);
51
+ return {
52
+ origin: {
53
+ handle: str(meta.name) ?? agentId.slice(0, 8),
54
+ mode: str(meta.mode),
55
+ team: str(meta.task_name),
56
+ parentSessionId: str(meta.parent_session_id),
57
+ },
58
+ sessionId: str(meta.remote_session_id),
59
+ };
60
+ }
61
+ catch {
62
+ return { origin: { handle: agentId.slice(0, 8) } };
63
+ }
64
+ }
65
+ /**
66
+ * A team name / handle as it is safe to render: a real string, terminal escapes
67
+ * stripped, or undefined.
68
+ *
69
+ * These values reach the row and the preview pane, and for a peer's row they are
70
+ * whatever JSON that machine sent — `parseRemoteList` copies the object through
71
+ * without inspecting its fields, so neither the type nor the content is ours to
72
+ * assume. A non-string `spawnedTeam` used to throw out of `teamBadge`, which runs
73
+ * on every row, taking down the whole listing rather than one entry.
74
+ */
75
+ export function safeTeamText(value) {
76
+ if (typeof value !== 'string' || value === '')
77
+ return undefined;
78
+ return sanitizeForTerminal(value);
79
+ }
80
+ /** Cached teammate index; the directory is small (teams GC it after 7 days). */
81
+ let originIndexCache = null;
82
+ /** Drop the cached teammate index — for tests that rewrite AGENTS_TEAMS_DIR. */
83
+ export function _resetTeamOriginIndex() {
84
+ originIndexCache = null;
85
+ }
86
+ /**
87
+ * Every teammate record, keyed by the session ids it can be reached under.
88
+ *
89
+ * A teammate's directory name is its **agent id**, which is only sometimes the
90
+ * id of the transcript it produced: the harness mints its own session id, and
91
+ * the spawn records it separately as `remote_session_id`. Keying the lookup on
92
+ * the directory name alone therefore missed most teammates — on a live box, 14
93
+ * of 16 records were reachable only by `remote_session_id` — so a teammate row
94
+ * could not name its team however good the record was. Both keys are registered.
95
+ *
96
+ * Read once per process rather than per row: the old per-session `existsSync` +
97
+ * `readFileSync` cost a pair of syscalls for every row in the pool, which the
98
+ * interactive browser re-pays on each hotkey.
99
+ */
100
+ function teamOriginIndex() {
101
+ if (originIndexCache)
102
+ return originIndexCache;
103
+ const dir = teamsAgentsDir();
104
+ const index = new Map();
105
+ let entries;
106
+ try {
107
+ entries = fs.readdirSync(dir);
108
+ }
109
+ catch {
110
+ entries = [];
111
+ }
112
+ for (const agentId of entries) {
113
+ const metaPath = path.join(dir, agentId, 'meta.json');
114
+ if (!fs.existsSync(metaPath))
115
+ continue;
116
+ const { origin, sessionId } = readTeamOrigin(metaPath, agentId);
117
+ index.set(agentId, origin);
118
+ if (sessionId)
119
+ index.set(sessionId, origin);
120
+ }
121
+ originIndexCache = index;
122
+ return index;
123
+ }
124
+ /**
125
+ * Attach `teamOrigin` to every team-spawned row in `sessions`, from the shared
126
+ * teammate index rather than a stat per row.
127
+ */
128
+ export function enrichTeamOrigins(sessions) {
129
+ const index = teamOriginIndex();
130
+ return sessions.map((session) => {
131
+ // A peer's rows are classified on the peer (its meta.json is on its disk, not
132
+ // ours) and ride across in the --json fan-out already populated. Re-deriving
133
+ // here would find no local record and downgrade a named teammate to a bare id.
134
+ if (session.teamOrigin)
135
+ return session;
136
+ const origin = index.get(session.id) ?? (session.isTeamOrigin ? { handle: session.id.slice(0, 8) } : null);
137
+ return origin ? { ...session, teamOrigin: origin } : session;
138
+ });
139
+ }
51
140
  /**
52
141
  * Split `sessions` into visible and hidden (team-origin) groups.
53
142
  * When `showTeams` is true every session is visible and `teamOrigin` is
@@ -7,7 +7,7 @@
7
7
  * speaks these types.
8
8
  */
9
9
  /** Agents that store session data on disk and can be discovered by `agents sessions`. */
10
- export type SessionAgentId = 'claude' | 'codex' | 'gemini' | 'antigravity' | 'opencode' | 'openclaw' | 'rush' | 'hermes' | 'grok' | 'kimi' | 'droid';
10
+ export type SessionAgentId = 'claude' | 'codex' | 'gemini' | 'antigravity' | 'opencode' | 'openclaw' | 'rush' | 'hermes' | 'grok' | 'kimi' | 'droid' | 'cursor';
11
11
  /** All agents with session discovery support, in display order. */
12
12
  export declare const SESSION_AGENTS: SessionAgentId[];
13
13
  /**
@@ -88,6 +88,14 @@ export interface TeamOrigin {
88
88
  handle?: string;
89
89
  /** Agent mode: 'plan', 'edit', 'auto', or 'skip' ('full' accepted as legacy alias for 'skip'). */
90
90
  mode?: string;
91
+ /** The team this teammate belongs to (`task_name` in its meta.json). */
92
+ team?: string;
93
+ /**
94
+ * The orchestrator session that spawned this teammate (`parent_session_id`).
95
+ * Absent for a team started outside any agent session, and for teammates whose
96
+ * meta dir has aged past the teams cleanup window.
97
+ */
98
+ parentSessionId?: string;
91
99
  }
92
100
  /** Lightweight metadata for a discovered session, used in listings and pickers. */
93
101
  export interface SessionMeta {
@@ -7,7 +7,7 @@
7
7
  * speaks these types.
8
8
  */
9
9
  /** All agents with session discovery support, in display order. */
10
- export const SESSION_AGENTS = ['claude', 'codex', 'gemini', 'antigravity', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi', 'droid'];
10
+ export const SESSION_AGENTS = ['claude', 'codex', 'gemini', 'antigravity', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi', 'droid', 'cursor'];
11
11
  /**
12
12
  * True when `agent` stores session data `agents sessions` can discover (a member
13
13
  * of {@link SESSION_AGENTS}). The single predicate every session-index writer
@@ -23,6 +23,23 @@ export declare function loginHint(agentId: AgentId): string;
23
23
  * agents (the ones the feature is for), so the resume's `forceInteractive` flag is
24
24
  * consulted directly.
25
25
  */
26
+ /**
27
+ * Is a Claude run on this box going to authenticate from an ambient
28
+ * `CLAUDE_CODE_OAUTH_TOKEN` rather than a per-version login?
29
+ *
30
+ * `AccountInfo.signedIn` is `!!email` read from a version home's `.claude.json`
31
+ * (agents.ts), so a version with no account written there reports signed-out —
32
+ * even though Claude Code authenticates fine from the env token and the run
33
+ * succeeds. Rendering that as "logged out" sends people hunting a login that is
34
+ * not missing (a real fleet incident: every version on a box read as locked out
35
+ * while all of them answered a live prompt).
36
+ *
37
+ * It is also the more useful warning: an ambient token is ONE account, so every
38
+ * version on the box resolves to it and balanced rotation across them rotates
39
+ * nothing. `env` is a parameter so the branch is testable without mutating the
40
+ * process environment.
41
+ */
42
+ export declare function ambientClaudeToken(agentId: AgentId | string, env?: NodeJS.ProcessEnv): boolean;
26
43
  export declare function shouldCheckLoginBeforeLaunch(o: {
27
44
  interactive?: boolean;
28
45
  forceInteractive?: boolean;
@@ -46,6 +46,25 @@ export function loginHint(agentId) {
46
46
  * agents (the ones the feature is for), so the resume's `forceInteractive` flag is
47
47
  * consulted directly.
48
48
  */
49
+ /**
50
+ * Is a Claude run on this box going to authenticate from an ambient
51
+ * `CLAUDE_CODE_OAUTH_TOKEN` rather than a per-version login?
52
+ *
53
+ * `AccountInfo.signedIn` is `!!email` read from a version home's `.claude.json`
54
+ * (agents.ts), so a version with no account written there reports signed-out —
55
+ * even though Claude Code authenticates fine from the env token and the run
56
+ * succeeds. Rendering that as "logged out" sends people hunting a login that is
57
+ * not missing (a real fleet incident: every version on a box read as locked out
58
+ * while all of them answered a live prompt).
59
+ *
60
+ * It is also the more useful warning: an ambient token is ONE account, so every
61
+ * version on the box resolves to it and balanced rotation across them rotates
62
+ * nothing. `env` is a parameter so the branch is testable without mutating the
63
+ * process environment.
64
+ */
65
+ export function ambientClaudeToken(agentId, env = process.env) {
66
+ return agentId === 'claude' && (env.CLAUDE_CODE_OAUTH_TOKEN ?? '').trim().length > 0;
67
+ }
49
68
  export function shouldCheckLoginBeforeLaunch(o) {
50
69
  if (o.json || o.quiet || o.authCheckDisabled || o.rotated)
51
70
  return false;
@@ -1,13 +1,14 @@
1
1
  /**
2
- * Commands detector — mirrors versions.ts:343-357. Inspects the version home,
2
+ * Commands detector — mirrors the command dispatch in versions.ts. Inspects the version home,
3
3
  * returns command names. Honors the commands-as-skills marker for skills-only
4
- * agents (kimi, Codex >= 0.117.0, …); falls back to scanning
5
- * `{agentDir}/<commandsSubdir>/` for the native path.
4
+ * agents (Kimi, Codex >= 0.117.0, …), treats the native file as authoritative
5
+ * for dual-write targets (the skill copy is deliberately absent on a name
6
+ * collision), and scans `{agentDir}/<commandsSubdir>/` for native-only targets.
6
7
  */
7
8
  import * as fs from 'fs';
8
9
  import * as path from 'path';
9
10
  import { AGENTS, MANAGED_AGENT_IDS, agentConfigDirName } from '../../agents.js';
10
- import { shouldInstallCommandAsSkill, listCommandSkillsInVersion } from '../../command-skills.js';
11
+ import { listCommandSkillsInVersion, shouldInstallCommandAsSkill, } from '../../command-skills.js';
11
12
  import { lazyAgentMap } from '../writers/lazy-map.js';
12
13
  function buildCommandsDetector(agent) {
13
14
  return {
@@ -23,9 +24,17 @@ function buildCommandsDetector(agent) {
23
24
  if (!fs.existsSync(commandsDir))
24
25
  return [];
25
26
  const ext = agentConfig.format === 'toml' ? '.toml' : '.md';
26
- return fs.readdirSync(commandsDir)
27
+ const nativeCommands = fs.readdirSync(commandsDir)
27
28
  .filter(f => f.endsWith(ext))
28
29
  .map(f => f.replace(new RegExp(`\\${ext}$`), ''));
30
+ // For a dual-write target the native file is the authoritative record that
31
+ // the command synced. The skill copy is derived, and
32
+ // installCommandSkillToVersion deliberately writes none when a real skill
33
+ // source already owns the name -- requiring both copies reported those
34
+ // commands missing forever and drove an `agents refresh` loop no sync could
35
+ // clear. This also matches `agents doctor`/`prune`, which read the
36
+ // unfiltered listCommandsInVersionHome.
37
+ return nativeCommands;
29
38
  },
30
39
  };
31
40
  }
@@ -44,6 +44,8 @@ export interface SyncManifest {
44
44
  v: typeof MANIFEST_VERSION;
45
45
  syncedAt: string;
46
46
  commands: Record<string, FileEntry>;
47
+ /** Command names the version writer emitted during the preceding full sync. */
48
+ writtenCommands?: string[];
47
49
  skills: Record<string, DirEntry>;
48
50
  hooks: Record<string, FileEntry>;
49
51
  rules: RulesEntry;
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * Commands writer.
3
3
  *
4
- * Two physical formats, picked per-(agent, version) at write time:
4
+ * Two physical formats, selected per-(agent, version) at write time. Most
5
+ * agents receive one format; dual-write registry targets receive both:
5
6
  *
6
7
  * - command-as-skill — fires when `shouldInstallCommandAsSkill(agent, version)`
7
8
  * is true. Used for Codex >= 0.117.0 (commands capability ends, skills
8
9
  * capability remains) and agents with skills but no native command-file dir
9
- * such as kimi. Writes `{agentDir}/skills/<name>/SKILL.md` with the
10
+ * such as Kimi. Cursor also receives this format in addition to its IDE
11
+ * command file. Writes `{agentDir}/skills/<name>/SKILL.md` with the
10
12
  * `agents_command` marker; the agent picks it up as a slash-command equivalent.
11
13
  *
12
14
  * - native command file — `{agentDir}/<commandsSubdir>/<name>.md` (or .toml
@@ -23,7 +25,7 @@ import { supports } from '../../capabilities.js';
23
25
  import { safeJoin } from '../../paths.js';
24
26
  import { markdownToToml } from '../../convert.js';
25
27
  import { commandAppliesTo, parseCommandMetadata } from '../../commands.js';
26
- import { installCommandSkillToVersion, shouldInstallCommandAsSkill } from '../../command-skills.js';
28
+ import { installCommandSkillToVersion, shouldAlsoInstallCommandAsSkill, shouldInstallCommandAsSkill, } from '../../command-skills.js';
27
29
  import { installGooseCommandToVersion } from '../../goose-commands.js';
28
30
  import { resolveCommandSource, trustedSkillRoots } from './sources.js';
29
31
  import { lazyAgentMap } from './lazy-map.js';
@@ -35,6 +37,7 @@ function buildCommandsWriter(agent) {
35
37
  const agentConfig = AGENTS[agent];
36
38
  const agentDir = path.join(versionHome, agentConfigDirName(agent));
37
39
  const commandsAsSkills = shouldInstallCommandAsSkill(agent, version);
40
+ const commandsAlsoAsSkills = shouldAlsoInstallCommandAsSkill(agent, version);
38
41
  const supportsCommands = supports(agent, 'commands', version).ok;
39
42
  // Version-gated agents (e.g. goose skills >= 1.25.0) are registered but
40
43
  // may be called at a version too old for both paths — skip gracefully.
@@ -54,23 +57,25 @@ function buildCommandsWriter(agent) {
54
57
  const metadata = parseCommandMetadata(srcFile);
55
58
  if (!commandAppliesTo(agent, version, metadata).ok)
56
59
  continue;
57
- if (commandsAsSkills) {
60
+ if (commandsAsSkills || commandsAlsoAsSkills) {
58
61
  const installed = installCommandSkillToVersion(agentDir, cmd, srcFile, skillRoots);
62
+ // installed.skipped means a real skill source already owns this name,
63
+ // which is a deliberate no-op, not a failure — the native file is still written.
59
64
  if (!installed.success)
60
65
  continue;
61
66
  }
62
- else if (agent === 'goose') {
67
+ if (supportsCommands && agent === 'goose') {
63
68
  // Goose: recipe YAML + config.yaml slash_commands entry, not a file copy.
64
69
  const installed = installGooseCommandToVersion(versionHome, cmd, srcFile);
65
70
  if (!installed.success)
66
71
  continue;
67
72
  }
68
- else if (agentConfig.format === 'toml') {
73
+ else if (supportsCommands && agentConfig.format === 'toml') {
69
74
  const content = fs.readFileSync(srcFile, 'utf-8');
70
75
  const tomlContent = markdownToToml(cmd, content);
71
76
  fs.writeFileSync(safeJoin(commandsTarget, `${cmd}.toml`), tomlContent);
72
77
  }
73
- else {
78
+ else if (supportsCommands) {
74
79
  fs.copyFileSync(srcFile, safeJoin(commandsTarget, `${cmd}.md`));
75
80
  }
76
81
  synced.push(cmd);
@@ -84,6 +89,7 @@ function buildCommandsWriter(agent) {
84
89
  // Registration covers two cases:
85
90
  // - native commands (claude, codex < 0.117.0, grok, etc.) — `commands` cap
86
91
  // - commands-as-skills (kimi, codex >= 0.117.0)
92
+ // - dual-write commands plus command-skills (cursor)
87
93
  //
88
94
  // Agents that have skills but use a NATIVE non-file slash-command system
89
95
  // (openclaw → Gateway-based commands) are NOT registered. They declare
@@ -181,6 +181,8 @@ export declare function getTeamsRegistryPath(): string;
181
181
  export declare function getDevicesRegistryPath(): string;
182
182
  /** Path to the device ignore-list — tailscale node names the user dismissed, so auto-discovery never re-suggests them. Per-machine, same dir as the registry. */
183
183
  export declare function getDevicesIgnoredPath(): string;
184
+ /** Path to the device auto-launch preference file — which registered devices are eligible/preferred for Factory's auto-host selection. Per-machine, same dir as the registry. */
185
+ export declare function getDevicesAutoLaunchPath(): string;
184
186
  /** Dir of "pending device" sentinels (~/.agents/.cache/state/devices-pending/) — one empty-ish file per newly-discovered, not-yet-approved tailnet node. Written by the daemon probe, read by the menu-bar helper (mirrors the attention sentinel dir). */
185
187
  export declare function getDevicesPendingDir(): string;
186
188
  /** Path to cloud dispatch cache (~/.agents/.cache/cloud/). */
package/dist/lib/state.js CHANGED
@@ -402,6 +402,8 @@ function getDevicesDir() {
402
402
  export function getDevicesRegistryPath() { return path.join(getDevicesDir(), 'registry.json'); }
403
403
  /** Path to the device ignore-list — tailscale node names the user dismissed, so auto-discovery never re-suggests them. Per-machine, same dir as the registry. */
404
404
  export function getDevicesIgnoredPath() { return path.join(getDevicesDir(), 'ignored.json'); }
405
+ /** Path to the device auto-launch preference file — which registered devices are eligible/preferred for Factory's auto-host selection. Per-machine, same dir as the registry. */
406
+ export function getDevicesAutoLaunchPath() { return path.join(getDevicesDir(), 'auto-launch.json'); }
405
407
  /** Dir of "pending device" sentinels (~/.agents/.cache/state/devices-pending/) — one empty-ish file per newly-discovered, not-yet-approved tailnet node. Written by the daemon probe, read by the menu-bar helper (mirrors the attention sentinel dir). */
406
408
  export function getDevicesPendingDir() { return path.join(RUNTIME_STATE_DIR, 'devices-pending'); }
407
409
  /** Path to cloud dispatch cache (~/.agents/.cache/cloud/). */
@@ -66,7 +66,35 @@ interface ClaudeOauthCredentials {
66
66
  rateLimitTier?: string | null;
67
67
  organizationUuid?: string | null;
68
68
  }
69
- /** Fetch usage info for a given agent, dispatching to the agent-specific implementation. */
69
+ /** The single registry of agent usage sources and their transport. */
70
+ declare const USAGE_SOURCES: {
71
+ readonly claude: {
72
+ readonly fetch: typeof getClaudeUsageInfo;
73
+ readonly network: true;
74
+ };
75
+ readonly codex: {
76
+ readonly fetch: typeof getCodexUsageInfo;
77
+ readonly network: false;
78
+ };
79
+ readonly kimi: {
80
+ readonly fetch: typeof getKimiUsageInfo;
81
+ readonly network: true;
82
+ };
83
+ readonly droid: {
84
+ readonly fetch: typeof getDroidUsageInfo;
85
+ readonly network: true;
86
+ };
87
+ readonly grok: {
88
+ readonly fetch: typeof getGrokUsageInfo;
89
+ readonly network: false;
90
+ };
91
+ readonly cursor: {
92
+ readonly fetch: typeof getCursorUsageInfo;
93
+ readonly network: true;
94
+ };
95
+ };
96
+ export declare const USAGE_SOURCE_AGENT_IDS: (keyof typeof USAGE_SOURCES)[];
97
+ /** Fetch usage info for a given agent through the canonical source registry. */
70
98
  export declare function getUsageInfo(agentId: AgentId, options?: UsageOptions): Promise<UsageInfo>;
71
99
  /** Derive a stable lookup key from account info for usage deduplication. */
72
100
  export declare function getUsageLookupKey(info?: Pick<AccountInfo, 'usageKey' | 'accountKey'> | null): string | null;
@@ -151,6 +179,8 @@ export declare function deriveUsageStatusFromSnapshot(snapshot: UsageSnapshot |
151
179
  export declare function formatUsageStatusBadge(usageStatus: 'available' | 'rate_limited' | 'out_of_credits' | null | undefined): string;
152
180
  /** Format a multi-line usage section for detailed agent views. */
153
181
  export declare function formatUsageSection(usage: UsageInfo): string[];
182
+ /** Fetch Codex usage by scanning the most recent session files for rate-limit events. */
183
+ declare function getCodexUsageInfo(options?: UsageOptions): Promise<UsageInfo>;
154
184
  /**
155
185
  * The access token to use for a READ-ONLY Claude usage fetch, or null when the
156
186
  * stored token is within the refresh leeway.
@@ -167,6 +197,8 @@ export declare function formatUsageSection(usage: UsageInfo): string[];
167
197
  * Pure — unit-tested.
168
198
  */
169
199
  export declare function claudeUsageAccessTokenNoRefresh(oauth: Pick<ClaudeOauthCredentials, 'accessToken' | 'expiresAt'>): string | null;
200
+ /** Fetch Claude usage via the Anthropic OAuth usage API. */
201
+ declare function getClaudeUsageInfo(options?: UsageOptions): Promise<UsageInfo>;
170
202
  /** Raw quota bucket from the Kimi /usages response (numbers arrive as strings). */
171
203
  interface KimiUsageQuota {
172
204
  limit?: string | number | null;
@@ -192,6 +224,18 @@ export interface KimiUsagesResponse {
192
224
  } | null> | null;
193
225
  subType?: string | null;
194
226
  }
227
+ /**
228
+ * Fetch Kimi usage via the Kimi Code /usages API. Kimi's JWT has no email
229
+ * claim, so the account row can't show an address — but /usages returns quota
230
+ * windows and the membership tier, which is what we render.
231
+ *
232
+ * Deliberately NO token refresh: `agents view` is a read/inspect command and
233
+ * must not rotate the user's Kimi OAuth credential (rewriting the file,
234
+ * invalidating the old refresh token, racing a concurrently-running kimi CLI).
235
+ * The kimi CLI refreshes on its own launch; if the stored token is expired we
236
+ * skip the live fetch and let the SWR cache serve the last-seen snapshot.
237
+ */
238
+ declare function getKimiUsageInfo(options?: UsageOptions): Promise<UsageInfo>;
195
239
  /** Normalize the Kimi /usages payload into the common UsageWindow shape. */
196
240
  export declare function normalizeKimiWindows(data: KimiUsagesResponse): UsageWindow[];
197
241
  /** Derive a display plan label from Kimi's membership tier or subscription type. */
@@ -212,6 +256,22 @@ export interface DroidBillingLimitsResponse {
212
256
  } | null;
213
257
  } | null;
214
258
  }
259
+ /**
260
+ * Fetch Droid usage via Factory's billing limits API — the same endpoint the
261
+ * droid CLI polls for its token-limit banner. The WorkOS access token comes
262
+ * from the locally decrypted ~/.factory/auth.v2.file (the same credential
263
+ * account identity in agents.ts reads).
264
+ *
265
+ * Deliberately NO token refresh, for a sharper reason than Kimi's: WorkOS
266
+ * refresh tokens are single-use and rotate on every exchange, so refreshing
267
+ * here would race a concurrently running droid session and can permanently
268
+ * invalidate the user's login chain. Droid refreshes its own credential when
269
+ * it runs; if the stored token is expired we skip the live fetch and let the
270
+ * SWR cache serve the last-seen snapshot. This same single-use-rotation property
271
+ * is why `agents apply` refuses to propagate droid credentials across machines
272
+ * (see `isCredentialSafeToPropagate` in `../fleet/auth-sync.ts`).
273
+ */
274
+ declare function getDroidUsageInfo(options?: UsageOptions): Promise<UsageInfo>;
215
275
  /**
216
276
  * Live auth probes — the same authenticated GET the usage fetchers above do,
217
277
  * but surfacing the raw HTTP status instead of swallowing 401/expired to null.
@@ -296,6 +356,8 @@ export declare function writeClaudeUsageCache(usageKey: string, snapshot: UsageS
296
356
  * Returns true only when a valid access token can be obtained.
297
357
  */
298
358
  export declare function isClaudeAuthValid(home?: string): Promise<boolean>;
359
+ /** Parse the latest billing info from Grok's unified log. */
360
+ declare function getGrokUsageInfo(options?: UsageOptions): Promise<UsageInfo>;
299
361
  /** Per-model bucket in Cursor's /api/usage response. */
300
362
  interface CursorUsageModel {
301
363
  numRequests?: number | null;
@@ -319,4 +381,13 @@ export interface CursorUsageResponse {
319
381
  * bar to draw here and return no windows rather than a misleading empty gauge.
320
382
  */
321
383
  export declare function normalizeCursorUsage(data: CursorUsageResponse): UsageWindow[];
384
+ /**
385
+ * Fetch Cursor usage from the dashboard usage endpoint. Cursor authenticates the
386
+ * request with a `WorkosCursorSessionToken` cookie of the form
387
+ * `<oauth-subject>::<access-token>` (the same pair the web dashboard sends), not a
388
+ * bearer header. Free/legacy plans return a monthly request bar; usage-based plans
389
+ * have no request cap, so they return a live-but-window-less snapshot (the account
390
+ * row still renders, without a misleading empty gauge).
391
+ */
392
+ declare function getCursorUsageInfo(options?: UsageOptions): Promise<UsageInfo>;
322
393
  export {};
package/dist/lib/usage.js CHANGED
@@ -18,7 +18,7 @@ import chalk from 'chalk';
18
18
  import { decodeJwtPayload, decryptDroidAuthPayload } from './agents.js';
19
19
  import { walkForFiles } from './fs-walk.js';
20
20
  import { getKeychainToken, setKeychainToken, deleteKeychainToken, isKeychainBackendOverridden, } from './secrets/index.js';
21
- import { bundleExists, bundleBackend, readAndResolveBundleEnv } from './secrets/bundles.js';
21
+ import { resolveClaudeSetupToken } from './claude-account-token.js';
22
22
  import { getCacheDir } from './state.js';
23
23
  const execFileAsync = promisify(execFile);
24
24
  const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
@@ -59,24 +59,23 @@ const COMPACT_BAR_LEN = 5;
59
59
  const USAGE_BAR_LEN = 10;
60
60
  const FULL = '\u2588';
61
61
  const EMPTY = '\u2591';
62
- /** Fetch usage info for a given agent, dispatching to the agent-specific implementation. */
62
+ /** The single registry of agent usage sources and their transport. */
63
+ const USAGE_SOURCES = {
64
+ claude: { fetch: getClaudeUsageInfo, network: true },
65
+ codex: { fetch: getCodexUsageInfo, network: false },
66
+ kimi: { fetch: getKimiUsageInfo, network: true },
67
+ droid: { fetch: getDroidUsageInfo, network: true },
68
+ grok: { fetch: getGrokUsageInfo, network: false },
69
+ cursor: { fetch: getCursorUsageInfo, network: true },
70
+ };
71
+ export const USAGE_SOURCE_AGENT_IDS = Object.keys(USAGE_SOURCES);
72
+ function getUsageSource(agentId) {
73
+ return USAGE_SOURCES[agentId];
74
+ }
75
+ /** Fetch usage info for a given agent through the canonical source registry. */
63
76
  export async function getUsageInfo(agentId, options) {
64
- switch (agentId) {
65
- case 'claude':
66
- return getClaudeUsageInfo(options);
67
- case 'codex':
68
- return getCodexUsageInfo(options);
69
- case 'kimi':
70
- return getKimiUsageInfo(options);
71
- case 'droid':
72
- return getDroidUsageInfo(options);
73
- case 'grok':
74
- return getGrokUsageInfo(options);
75
- case 'cursor':
76
- return getCursorUsageInfo(options);
77
- default:
78
- return { snapshot: null, error: null };
79
- }
77
+ const source = getUsageSource(agentId);
78
+ return source ? source.fetch(options) : { snapshot: null, error: null };
80
79
  }
81
80
  /** Derive a stable lookup key from account info for usage deduplication. */
82
81
  export function getUsageLookupKey(info) {
@@ -117,12 +116,7 @@ export function buildCanonicalUsageContext(inputs) {
117
116
  * versus simply not applicable (Antigravity, OpenCode).
118
117
  */
119
118
  export function agentReportsUsage(agentId) {
120
- return (agentId === 'claude' ||
121
- agentId === 'codex' ||
122
- agentId === 'kimi' ||
123
- agentId === 'droid' ||
124
- agentId === 'grok' ||
125
- agentId === 'cursor');
119
+ return getUsageSource(agentId) !== undefined;
126
120
  }
127
121
  /** Fetch usage info for all unique accounts in parallel, keyed by usage key. */
128
122
  export async function getUsageInfoByIdentity(inputs, opts) {
@@ -160,14 +154,14 @@ const inFlightRefreshes = new Map();
160
154
  export async function getUsageInfoForIdentity(input, opts) {
161
155
  const usageKey = getUsageLookupKey(input.info);
162
156
  const forceRefresh = opts?.forceRefresh === true;
163
- // Agents whose usage comes from a live network call (Claude, Kimi, Droid) go
157
+ // Agents whose registered usage source makes a live network call go
164
158
  // through the stale-while-revalidate cache below so `agents run`/`agents view`
165
159
  // stay off the network on the hot path. Everything else (Codex reads local
166
160
  // session logs) takes the legacy blocking path. The on-disk cache is shared and
167
161
  // keyed by usageKey, which is namespaced per agent (`claude:org=…`,
168
- // `kimi:user=…`, `droid:org=…`), so one cache file holds every account without
169
- // collision.
170
- const usesNetworkUsage = input.agentId === 'claude' || input.agentId === 'kimi' || input.agentId === 'droid';
162
+ // `kimi:user=…`, `droid:org=…`, `cursor:user=…`), so one cache file holds every
163
+ // account without collision.
164
+ const usesNetworkUsage = getUsageSource(input.agentId)?.network === true;
171
165
  if (!usesNetworkUsage || !usageKey) {
172
166
  return getUsageInfo(input.agentId, {
173
167
  home: input.home,
@@ -999,65 +993,6 @@ function deleteCachedClaudeOauth(service) {
999
993
  /* best-effort — cache is an optimization */
1000
994
  }
1001
995
  }
1002
- /**
1003
- * Reserved FILE-BASED secrets bundle holding long-lived, non-rotating Claude
1004
- * setup-tokens. Usage/probe reads authenticate with these instead of Claude
1005
- * Code's ACL-bound login item, so they never pop Touch ID. Keyed strictly
1006
- * per-account (`CLAUDE_CODE_OAUTH_TOKEN_<slug>` from the account email) — never a
1007
- * bare key, so one account's token can't be misapplied to another in a
1008
- * multi-account fleet.
1009
- */
1010
- const AUTH_BUNDLE = 'auth';
1011
- /** The per-account key an email maps to inside the `auth` bundle. */
1012
- function claudeAccountTokenKey(account) {
1013
- const slug = account
1014
- .trim()
1015
- .toUpperCase()
1016
- .replace(/@/g, '_AT_')
1017
- .replace(/\./g, '_DOT_')
1018
- .replace(/[^A-Z0-9_]/g, '_');
1019
- return `CLAUDE_CODE_OAUTH_TOKEN_${slug}`;
1020
- }
1021
- /** Signed-in account email for a version home, from `.claude.json` (no keychain). */
1022
- function readClaudeAccountEmail(home) {
1023
- const base = home ?? os.homedir();
1024
- for (const p of [path.join(base, '.claude', '.claude.json'), path.join(base, '.claude.json')]) {
1025
- try {
1026
- const email = JSON.parse(fs.readFileSync(p, 'utf-8')).oauthAccount?.emailAddress;
1027
- if (typeof email === 'string' && email.trim().length > 0)
1028
- return email.trim();
1029
- }
1030
- catch {
1031
- // Missing/unreadable at this location — try the next.
1032
- }
1033
- }
1034
- return null;
1035
- }
1036
- /**
1037
- * Resolve a long-lived `claude setup-token` for the account signed into `home`
1038
- * from the reserved FILE-BASED `auth` bundle. Returns the token or null. Reads
1039
- * ONLY when the bundle is file-backed (never keychain), so this path itself can
1040
- * never trigger a Touch ID prompt — that is the entire point: usage/probe reads
1041
- * authenticate with the shareable setup-token, not the ACL-bound login item.
1042
- */
1043
- function resolveClaudeSetupToken(home) {
1044
- try {
1045
- // Require a known account (email) up front: without it we cannot key a
1046
- // per-account token, and we must NOT fall back to a bare shared key that
1047
- // would misapply one account's setup-token to another.
1048
- const email = readClaudeAccountEmail(home);
1049
- if (!email)
1050
- return null;
1051
- if (!bundleExists(AUTH_BUNDLE) || bundleBackend(AUTH_BUNDLE) !== 'file')
1052
- return null;
1053
- const { env } = readAndResolveBundleEnv(AUTH_BUNDLE, { caller: 'usage', agentOnly: true });
1054
- const v = (env[claudeAccountTokenKey(email)] ?? '').trim();
1055
- return v.length > 0 ? v : null;
1056
- }
1057
- catch {
1058
- return null;
1059
- }
1060
- }
1061
996
  /**
1062
997
  * Load a version home's Claude OAuth credential from the two stores Claude Code
1063
998
  * uses, tried in order: