@phnx-labs/agents-cli 1.20.86 → 1.20.87

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 (41) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/sessions-browser.d.ts +18 -0
  4. package/dist/commands/sessions-browser.js +126 -24
  5. package/dist/commands/sessions-picker.d.ts +21 -8
  6. package/dist/commands/sessions-picker.js +83 -7
  7. package/dist/commands/sessions.d.ts +19 -0
  8. package/dist/commands/sessions.js +147 -18
  9. package/dist/commands/ssh.js +59 -1
  10. package/dist/commands/teams-picker.d.ts +2 -0
  11. package/dist/commands/teams-picker.js +2 -1
  12. package/dist/commands/teams.d.ts +4 -1
  13. package/dist/commands/teams.js +106 -70
  14. package/dist/commands/view.js +14 -3
  15. package/dist/index.js +31 -1
  16. package/dist/lib/claude-account-token.d.ts +12 -0
  17. package/dist/lib/claude-account-token.js +63 -0
  18. package/dist/lib/devices/registry.d.ts +25 -0
  19. package/dist/lib/devices/registry.js +82 -1
  20. package/dist/lib/events.d.ts +8 -1
  21. package/dist/lib/events.js +13 -0
  22. package/dist/lib/exec.js +10 -1
  23. package/dist/lib/format.d.ts +7 -0
  24. package/dist/lib/format.js +11 -0
  25. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  26. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  27. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  28. package/dist/lib/session/db.d.ts +21 -0
  29. package/dist/lib/session/db.js +45 -4
  30. package/dist/lib/session/remote-list.d.ts +7 -0
  31. package/dist/lib/session/remote-list.js +8 -4
  32. package/dist/lib/session/state.js +69 -2
  33. package/dist/lib/session/team-filter.d.ts +22 -3
  34. package/dist/lib/session/team-filter.js +106 -17
  35. package/dist/lib/session/types.d.ts +8 -0
  36. package/dist/lib/signin-badge.d.ts +17 -0
  37. package/dist/lib/signin-badge.js +19 -0
  38. package/dist/lib/state.d.ts +2 -0
  39. package/dist/lib/state.js +2 -0
  40. package/dist/lib/usage.js +1 -60
  41. package/package.json +1 -1
@@ -12,14 +12,33 @@ import type { SessionMeta, TeamOrigin } from './types.js';
12
12
  *
13
13
  * Primary signal is `session.isTeamOrigin`, captured at scan time from the
14
14
  * JSONL `entrypoint` field ('sdk-cli' for team spawns, 'cli' for real CLI).
15
- * When a team meta.json exists we additionally enrich with handle/mode — but
16
- * its absence no longer demotes a session: older team runs whose meta dir
17
- * was cleaned up still get recognized via the entrypoint flag.
15
+ * When a team meta.json exists we additionally enrich with handle/mode/team and
16
+ * the orchestrator that spawned it but its absence no longer demotes a
17
+ * session: older team runs whose meta dir was cleaned up still get recognized
18
+ * via the entrypoint flag.
18
19
  *
19
20
  * Returns the TeamOrigin metadata when the session is team-origin, or null
20
21
  * when it is a normal interactive session.
21
22
  */
22
23
  export declare function classifyTeamSession(session: SessionMeta): TeamOrigin | null;
24
+ /**
25
+ * A team name / handle as it is safe to render: a real string, terminal escapes
26
+ * stripped, or undefined.
27
+ *
28
+ * These values reach the row and the preview pane, and for a peer's row they are
29
+ * whatever JSON that machine sent — `parseRemoteList` copies the object through
30
+ * without inspecting its fields, so neither the type nor the content is ours to
31
+ * assume. A non-string `spawnedTeam` used to throw out of `teamBadge`, which runs
32
+ * on every row, taking down the whole listing rather than one entry.
33
+ */
34
+ export declare function safeTeamText(value: unknown): string | undefined;
35
+ /** Drop the cached teammate index — for tests that rewrite AGENTS_TEAMS_DIR. */
36
+ export declare function _resetTeamOriginIndex(): void;
37
+ /**
38
+ * Attach `teamOrigin` to every team-spawned row in `sessions`, from the shared
39
+ * teammate index rather than a stat per row.
40
+ */
41
+ export declare function enrichTeamOrigins(sessions: SessionMeta[]): SessionMeta[];
23
42
  /** Result of splitting sessions into visible and hidden (team-origin) groups. */
24
43
  export interface FilterResult {
25
44
  visible: SessionMeta[];
@@ -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
@@ -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 {
@@ -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;
@@ -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/). */
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';
@@ -999,65 +999,6 @@ function deleteCachedClaudeOauth(service) {
999
999
  /* best-effort — cache is an optimization */
1000
1000
  }
1001
1001
  }
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
1002
  /**
1062
1003
  * Load a version home's Claude OAuth credential from the two stores Claude Code
1063
1004
  * uses, tried in order:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.86",
3
+ "version": "1.20.87",
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",