@phnx-labs/agents-cli 1.20.85 → 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 (53) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/events.d.ts +16 -0
  4. package/dist/commands/events.js +44 -5
  5. package/dist/commands/models.js +1 -1
  6. package/dist/commands/sessions-browser.d.ts +18 -0
  7. package/dist/commands/sessions-browser.js +126 -24
  8. package/dist/commands/sessions-picker.d.ts +21 -8
  9. package/dist/commands/sessions-picker.js +88 -11
  10. package/dist/commands/sessions.d.ts +19 -0
  11. package/dist/commands/sessions.js +147 -18
  12. package/dist/commands/ssh.js +59 -1
  13. package/dist/commands/teams-picker.d.ts +2 -0
  14. package/dist/commands/teams-picker.js +2 -1
  15. package/dist/commands/teams.d.ts +4 -1
  16. package/dist/commands/teams.js +106 -70
  17. package/dist/commands/view.js +14 -3
  18. package/dist/index.js +31 -1
  19. package/dist/lib/claude-account-token.d.ts +12 -0
  20. package/dist/lib/claude-account-token.js +63 -0
  21. package/dist/lib/devices/registry.d.ts +25 -0
  22. package/dist/lib/devices/registry.js +82 -1
  23. package/dist/lib/events.d.ts +8 -1
  24. package/dist/lib/events.js +13 -0
  25. package/dist/lib/exec.js +10 -1
  26. package/dist/lib/format.d.ts +7 -0
  27. package/dist/lib/format.js +11 -0
  28. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  29. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  30. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
  31. package/dist/lib/models.d.ts +21 -0
  32. package/dist/lib/models.js +133 -4
  33. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  34. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  35. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  36. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +2 -2
  37. package/dist/lib/session/db.d.ts +21 -0
  38. package/dist/lib/session/db.js +45 -4
  39. package/dist/lib/session/parse.d.ts +11 -0
  40. package/dist/lib/session/parse.js +24 -7
  41. package/dist/lib/session/remote-list.d.ts +7 -0
  42. package/dist/lib/session/remote-list.js +8 -4
  43. package/dist/lib/session/state.d.ts +6 -5
  44. package/dist/lib/session/state.js +84 -11
  45. package/dist/lib/session/team-filter.d.ts +22 -3
  46. package/dist/lib/session/team-filter.js +106 -17
  47. package/dist/lib/session/types.d.ts +8 -0
  48. package/dist/lib/signin-badge.d.ts +17 -0
  49. package/dist/lib/signin-badge.js +19 -0
  50. package/dist/lib/state.d.ts +2 -0
  51. package/dist/lib/state.js +2 -0
  52. package/dist/lib/usage.js +1 -60
  53. package/package.json +1 -1
@@ -0,0 +1,12 @@
1
+ /** The per-account key an email maps to inside the `auth` bundle. */
2
+ export declare function claudeAccountTokenKey(account: string): string;
3
+ /** Signed-in account email for a version home, from `.claude.json` (no keychain). */
4
+ export declare function readClaudeAccountEmail(home?: string): string | null;
5
+ /**
6
+ * Resolve a long-lived `claude setup-token` for the account signed into `home`
7
+ * from the reserved FILE-BASED `auth` bundle. Returns the token or null. Reads
8
+ * ONLY when the bundle is file-backed (never keychain), so this path itself can
9
+ * never trigger a Touch ID prompt — that is the entire point: usage/probe reads
10
+ * authenticate with the shareable setup-token, not the ACL-bound login item.
11
+ */
12
+ export declare function resolveClaudeSetupToken(home?: string): string | null;
@@ -0,0 +1,63 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { bundleBackend, bundleExists, readAndResolveBundleEnv } from './secrets/bundles.js';
5
+ /**
6
+ * Reserved FILE-BASED secrets bundle holding long-lived, non-rotating Claude
7
+ * setup-tokens. Usage/probe reads authenticate with these instead of Claude
8
+ * Code's ACL-bound login item, so they never pop Touch ID. Keyed strictly
9
+ * per-account (`CLAUDE_CODE_OAUTH_TOKEN_<slug>` from the account email) — never a
10
+ * bare key, so one account's token can't be misapplied to another in a
11
+ * multi-account fleet.
12
+ */
13
+ const AUTH_BUNDLE = 'auth';
14
+ /** The per-account key an email maps to inside the `auth` bundle. */
15
+ export function claudeAccountTokenKey(account) {
16
+ const slug = account
17
+ .trim()
18
+ .toUpperCase()
19
+ .replace(/@/g, '_AT_')
20
+ .replace(/\./g, '_DOT_')
21
+ .replace(/[^A-Z0-9_]/g, '_');
22
+ return `CLAUDE_CODE_OAUTH_TOKEN_${slug}`;
23
+ }
24
+ /** Signed-in account email for a version home, from `.claude.json` (no keychain). */
25
+ export function readClaudeAccountEmail(home) {
26
+ const base = home ?? os.homedir();
27
+ for (const p of [path.join(base, '.claude', '.claude.json'), path.join(base, '.claude.json')]) {
28
+ try {
29
+ const email = JSON.parse(fs.readFileSync(p, 'utf-8')).oauthAccount?.emailAddress;
30
+ if (typeof email === 'string' && email.trim().length > 0)
31
+ return email.trim();
32
+ }
33
+ catch {
34
+ // Missing/unreadable at this location — try the next.
35
+ }
36
+ }
37
+ return null;
38
+ }
39
+ /**
40
+ * Resolve a long-lived `claude setup-token` for the account signed into `home`
41
+ * from the reserved FILE-BASED `auth` bundle. Returns the token or null. Reads
42
+ * ONLY when the bundle is file-backed (never keychain), so this path itself can
43
+ * never trigger a Touch ID prompt — that is the entire point: usage/probe reads
44
+ * authenticate with the shareable setup-token, not the ACL-bound login item.
45
+ */
46
+ export function resolveClaudeSetupToken(home) {
47
+ try {
48
+ // Require a known account (email) up front: without it we cannot key a
49
+ // per-account token, and we must NOT fall back to a bare shared key that
50
+ // would misapply one account's setup-token to another.
51
+ const email = readClaudeAccountEmail(home);
52
+ if (!email)
53
+ return null;
54
+ if (!bundleExists(AUTH_BUNDLE) || bundleBackend(AUTH_BUNDLE) !== 'file')
55
+ return null;
56
+ const { env } = readAndResolveBundleEnv(AUTH_BUNDLE, { caller: 'usage', agentOnly: true });
57
+ const v = (env[claudeAccountTokenKey(email)] ?? '').trim();
58
+ return v.length > 0 ? v : null;
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
@@ -142,3 +142,28 @@ export declare function addIgnored(name: string): Promise<Set<string>>;
142
142
  /** Remove a node name from the ignore-list (un-ignore). Returns false if it was
143
143
  * not ignored. */
144
144
  export declare function removeIgnored(name: string): Promise<boolean>;
145
+ /**
146
+ * Auto-launch preferences: which registered devices are eligible for Factory's
147
+ * auto-host selection, and which are preferred. Stored as a sibling to the
148
+ * registry and ignore-list under ~/.agents/.history/devices/.
149
+ */
150
+ export interface AutoLaunchPreference {
151
+ enabled?: boolean;
152
+ preferred?: boolean;
153
+ }
154
+ export interface AutoLaunchPreferences {
155
+ devices: Record<string, AutoLaunchPreference>;
156
+ updatedAt: string;
157
+ }
158
+ /** Load auto-launch preferences. Missing or malformed file => empty map. */
159
+ export declare function loadAutoLaunchPreferences(): Promise<Record<string, AutoLaunchPreference>>;
160
+ /** True if the device is enabled for auto-launch. Missing entry defaults to true. */
161
+ export declare function isAutoLaunchEnabled(name: string): Promise<boolean>;
162
+ /** Set whether a device is enabled for auto-launch. Setting to the default
163
+ * (enabled) removes the entry to keep the file minimal. */
164
+ export declare function setAutoLaunchEnabled(name: string, enabled: boolean): Promise<void>;
165
+ /** True if the device is preferred for auto-launch ranking. */
166
+ export declare function isAutoLaunchPreferred(name: string): Promise<boolean>;
167
+ /** Set whether a device is preferred for auto-launch. Setting to the default
168
+ * (not preferred) removes the flag to keep the file minimal. */
169
+ export declare function setAutoLaunchPreferred(name: string, preferred: boolean): Promise<void>;
@@ -18,7 +18,7 @@ import * as fsSync from 'fs';
18
18
  import * as path from 'path';
19
19
  import { randomBytes } from 'crypto';
20
20
  import lockfile from 'proper-lockfile';
21
- import { getDevicesRegistryPath, getDevicesIgnoredPath } from '../state.js';
21
+ import { getDevicesRegistryPath, getDevicesIgnoredPath, getDevicesAutoLaunchPath } from '../state.js';
22
22
  /** A device's effective role, defaulting to `worker` when unset. */
23
23
  export function deviceRole(d) {
24
24
  return d.role ?? 'worker';
@@ -288,3 +288,84 @@ export async function removeIgnored(name) {
288
288
  return true;
289
289
  });
290
290
  }
291
+ function autoLaunchPath() {
292
+ return getDevicesAutoLaunchPath();
293
+ }
294
+ /** Load auto-launch preferences. Missing or malformed file => empty map. */
295
+ export async function loadAutoLaunchPreferences() {
296
+ const p = autoLaunchPath();
297
+ let raw;
298
+ try {
299
+ raw = await fs.readFile(p, 'utf-8');
300
+ }
301
+ catch (err) {
302
+ if (err && err.code === 'ENOENT')
303
+ return {};
304
+ throw err;
305
+ }
306
+ try {
307
+ const parsed = JSON.parse(raw);
308
+ return parsed.devices && typeof parsed.devices === 'object' ? parsed.devices : {};
309
+ }
310
+ catch (err) {
311
+ throw new Error(`Device auto-launch preferences corrupted at ${p}: ${err?.message ?? err}. Inspect and restore from backup.`);
312
+ }
313
+ }
314
+ /** True if the device is enabled for auto-launch. Missing entry defaults to true. */
315
+ export async function isAutoLaunchEnabled(name) {
316
+ assertValidDeviceName(name);
317
+ const prefs = await loadAutoLaunchPreferences();
318
+ return prefs[name]?.enabled !== false;
319
+ }
320
+ /** Set whether a device is enabled for auto-launch. Setting to the default
321
+ * (enabled) removes the entry to keep the file minimal. */
322
+ export async function setAutoLaunchEnabled(name, enabled) {
323
+ assertValidDeviceName(name);
324
+ const p = autoLaunchPath();
325
+ await withRegistryLock(p, async () => {
326
+ const prefs = await loadAutoLaunchPreferences();
327
+ if (enabled) {
328
+ if (prefs[name]) {
329
+ const { enabled: _, ...rest } = prefs[name];
330
+ if (Object.keys(rest).length === 0) {
331
+ delete prefs[name];
332
+ }
333
+ else {
334
+ prefs[name] = rest;
335
+ }
336
+ }
337
+ }
338
+ else {
339
+ prefs[name] = { ...prefs[name], enabled: false };
340
+ }
341
+ await atomicWriteJson(p, { devices: prefs, updatedAt: new Date().toISOString() });
342
+ });
343
+ }
344
+ /** True if the device is preferred for auto-launch ranking. */
345
+ export async function isAutoLaunchPreferred(name) {
346
+ assertValidDeviceName(name);
347
+ const prefs = await loadAutoLaunchPreferences();
348
+ return prefs[name]?.preferred === true;
349
+ }
350
+ /** Set whether a device is preferred for auto-launch. Setting to the default
351
+ * (not preferred) removes the flag to keep the file minimal. */
352
+ export async function setAutoLaunchPreferred(name, preferred) {
353
+ assertValidDeviceName(name);
354
+ const p = autoLaunchPath();
355
+ await withRegistryLock(p, async () => {
356
+ const prefs = await loadAutoLaunchPreferences();
357
+ if (preferred) {
358
+ prefs[name] = { ...prefs[name], preferred: true };
359
+ }
360
+ else if (prefs[name]) {
361
+ const { preferred: _, ...rest } = prefs[name];
362
+ if (Object.keys(rest).length === 0) {
363
+ delete prefs[name];
364
+ }
365
+ else {
366
+ prefs[name] = rest;
367
+ }
368
+ }
369
+ await atomicWriteJson(p, { devices: prefs, updatedAt: new Date().toISOString() });
370
+ });
371
+ }
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { type ActorKind } from './actor.js';
15
15
  export type EventLevel = 'audit' | 'warn' | 'info' | 'debug';
16
- export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.unlocked' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'error' | 'warn' | 'info' | 'debug';
16
+ export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.unlocked' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
17
17
  export declare function levelFor(event: EventType): EventLevel;
18
18
  export interface EventMeta {
19
19
  ts: string;
@@ -156,6 +156,13 @@ export declare function emitCommand(command: string, args?: string[], payload?:
156
156
  * Emit an error event with full details.
157
157
  */
158
158
  export declare function emitError(err: Error | string, payload?: EventPayload): void;
159
+ /**
160
+ * Emit a friction event — a structured, point-of-use record of a failure or
161
+ * block the CLI just hit. `surface` is the subsystem (teams, browser, secrets,
162
+ * guard, …); `failureId` is a stable slug that lets the nightly routine group
163
+ * the same failure across sessions (e.g. 'remote-cwd-on-add', 'not-installed').
164
+ */
165
+ export declare function emitFriction(surface: string, failureId: string, payload?: EventPayload): void;
159
166
  /**
160
167
  * Remove log files older than the retention period.
161
168
  * Removes numbered gzip archives whose filesystem mtime exceeds retention.
@@ -523,6 +523,19 @@ export function emitError(err, payload = {}) {
523
523
  errorStack: truncate(error.stack, 1000),
524
524
  });
525
525
  }
526
+ /**
527
+ * Emit a friction event — a structured, point-of-use record of a failure or
528
+ * block the CLI just hit. `surface` is the subsystem (teams, browser, secrets,
529
+ * guard, …); `failureId` is a stable slug that lets the nightly routine group
530
+ * the same failure across sessions (e.g. 'remote-cwd-on-add', 'not-installed').
531
+ */
532
+ export function emitFriction(surface, failureId, payload = {}) {
533
+ emit('friction', {
534
+ ...payload,
535
+ surface,
536
+ failureId,
537
+ });
538
+ }
526
539
  // ─── Gzip rotation ──────────────────────────────────────────────────────────
527
540
  /** Rotate the active file while its append lock is held. */
528
541
  function maybeGzipRotateLocked(logPath) {
package/dist/lib/exec.js CHANGED
@@ -28,6 +28,7 @@ import { mailboxDir, isValidMailboxId } from './mailbox.js';
28
28
  import { composeWin32CommandLine } from './platform/index.js';
29
29
  import { isTmuxInstalled } from './tmux/binary.js';
30
30
  import { shellQuote } from './ssh-exec.js';
31
+ import { resolveClaudeSetupToken } from './claude-account-token.js';
31
32
  /**
32
33
  * Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
33
34
  *
@@ -259,7 +260,15 @@ export function buildExecEnv(options) {
259
260
  ? resolvedVersion
260
261
  : (resolvedVersion && isVersionInstalled('claude', resolvedVersion) ? resolvedVersion : null);
261
262
  if (version) {
262
- result.CLAUDE_CONFIG_DIR = path.join(getVersionHomePath('claude', version), '.claude');
263
+ const versionHome = getVersionHomePath('claude', version);
264
+ result.CLAUDE_CONFIG_DIR = path.join(versionHome, '.claude');
265
+ const setupToken = resolveClaudeSetupToken(versionHome);
266
+ if (setupToken) {
267
+ // A token keyed to this version home's own account replaces any ambient
268
+ // shared value inherited from the launcher. options.env still wins below
269
+ // for explicit caller overrides.
270
+ result.CLAUDE_CODE_OAUTH_TOKEN = setupToken;
271
+ }
263
272
  // A managed pin lives in a per-version dir; Claude Code's own background
264
273
  // auto-updater would rewrite that pinned binary in place (and has left it
265
274
  // half-swapped and broken). Disable it so a pin stays a pin. Honor an
@@ -27,6 +27,13 @@ export declare function formatDie(msg: string, opts?: DieOptions): {
27
27
  * keep the original red-stderr behavior.
28
28
  */
29
29
  export declare function die(msg: string, code?: number, opts?: DieOptions): never;
30
+ /**
31
+ * `die()` with a structured friction event attached. Use this at CLI error
32
+ * chokepoints so the nightly routine can classify and rank recurring failures
33
+ * without re-parsing transcripts. `surface` is the subsystem (teams, browser,
34
+ * secrets, guard, …); `failureId` is a stable slug (e.g. 'remote-cwd-on-add').
35
+ */
36
+ export declare function dieFriction(surface: string, failureId: string, msg: string, code?: number, opts?: DieOptions): never;
30
37
  /**
31
38
  * Truncate `s` to at most `max` characters, appending a single-char ellipsis
32
39
  * (`…`) when shortened. Character-count based (not ANSI/width aware — use
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import chalk from 'chalk';
11
11
  import { readSync } from 'node:fs';
12
+ import { emitFriction } from './events.js';
12
13
  /**
13
14
  * Render a fatal error to the right stream. Pure — no I/O, no `process.exit` — so
14
15
  * the human-vs-agent split is unit-testable. A `--json` caller gets
@@ -43,6 +44,16 @@ export function die(msg, code = 1, opts = {}) {
43
44
  console.error(text);
44
45
  process.exit(code);
45
46
  }
47
+ /**
48
+ * `die()` with a structured friction event attached. Use this at CLI error
49
+ * chokepoints so the nightly routine can classify and rank recurring failures
50
+ * without re-parsing transcripts. `surface` is the subsystem (teams, browser,
51
+ * secrets, guard, …); `failureId` is a stable slug (e.g. 'remote-cwd-on-add').
52
+ */
53
+ export function dieFriction(surface, failureId, msg, code = 1, opts = {}) {
54
+ emitFriction(surface, failureId, { error: msg });
55
+ die(msg, code, opts);
56
+ }
46
57
  /**
47
58
  * Truncate `s` to at most `max` characters, appending a single-char ellipsis
48
59
  * (`…`) when shortened. Character-count based (not ANSI/width aware — use
@@ -6,7 +6,7 @@
6
6
  <dict>
7
7
  <key>Resources/AppIcon.icns</key>
8
8
  <data>
9
- Vd5nfogg74zSRNspluv4uvDecUA=
9
+ DFq5H08EkhgWIC3UvGMR9B58BZw=
10
10
  </data>
11
11
  </dict>
12
12
  <key>files2</key>
@@ -15,7 +15,7 @@
15
15
  <dict>
16
16
  <key>hash2</key>
17
17
  <data>
18
- DznVe0VgYOux7+B/aiHNgGYLCvSJKzgXDTPJn2jNkNg=
18
+ mBSjM6jlvN7J1jQowsvqTl7CHM0pur0e6qsHMuzk5k0=
19
19
  </data>
20
20
  </dict>
21
21
  </dict>
@@ -57,6 +57,27 @@ export interface ModelSource {
57
57
  * Returns null if nothing usable is found.
58
58
  */
59
59
  export declare function locateModelSource(agent: AgentId, version: string): ModelSource | null;
60
+ /**
61
+ * Parse `grok models` stdout into a catalog. Exported for unit tests.
62
+ *
63
+ * Output shape (verified 0.2.118):
64
+ * You are logged in with grok.com.
65
+ *
66
+ * Default model: grok-4.5
67
+ *
68
+ * Available models:
69
+ * * grok-4.5 (default)
70
+ *
71
+ * The `Default model:` line is authoritative; rows may also carry a leading `*`
72
+ * and a `(default)` flag. Grok has no `--json` on this subcommand. Settings live
73
+ * in `config.toml` / `models_cache.json`, not `settings.json`, so the native
74
+ * settings.json reader cannot surface the default — the catalog is the source
75
+ * that makes `resolveConfiguredModel` return a cli-default for Grok.
76
+ */
77
+ export declare function parseGrokModelsStdout(stdout: string): {
78
+ models: ModelInfo[];
79
+ aliases: Record<string, string>;
80
+ };
60
81
  /**
61
82
  * Build (or load from cache) the model catalog for a specific (agent, version).
62
83
  * Cache is keyed on source-file mtime (binary or js module), so re-extracts
@@ -3,15 +3,15 @@
3
3
  *
4
4
  * Each agent ships its model list differently -- Claude and Codex embed it in
5
5
  * compiled bundles/binaries, Gemini exports it from a JS module, and OpenCode/
6
- * Cursor/OpenClaw expose it via CLI commands. This module provides a unified
7
- * `getModelCatalog()` and `resolveModel()` interface over all of them, backed
8
- * by a file-system cache keyed on source mtime.
6
+ * Cursor/OpenClaw/Antigravity/Kimi/Grok expose it via CLI commands. This
7
+ * module provides a unified `getModelCatalog()` and `resolveModel()` interface
8
+ * over all of them, backed by a file-system cache keyed on source mtime.
9
9
  */
10
10
  import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import { execFileSync } from 'child_process';
13
13
  import chalk from 'chalk';
14
- import { getVersionDir, getVersionHomePath } from './versions.js';
14
+ import { getVersionDir, getVersionHomePath, getBinaryPath } from './versions.js';
15
15
  import { getModelsCachePath } from './state.js';
16
16
  import { agentConfigDirName } from './agents.js';
17
17
  import { resolveRunDefaults } from './run-defaults.js';
@@ -188,6 +188,36 @@ export function locateModelSource(agent, version) {
188
188
  return { path: pathBin, kind: 'cli' };
189
189
  return null;
190
190
  }
191
+ if (agent === 'grok') {
192
+ // Grok ships a native binary under the version home's `.grok/downloads/`,
193
+ // not node_modules/.bin. Prefer a real binary over a failed-download stub
194
+ // (a 99-byte placeholder sometimes left beside a prior good download).
195
+ const preferred = getBinaryPath('grok', version);
196
+ if (isUsableGrokBinary(preferred))
197
+ return { path: preferred, kind: 'cli' };
198
+ const downloads = path.join(getVersionHomePath('grok', version), '.grok', 'downloads');
199
+ try {
200
+ const candidates = fs
201
+ .readdirSync(downloads)
202
+ .filter((e) => e.startsWith('grok-'))
203
+ .map((e) => path.join(downloads, e))
204
+ .filter(isUsableGrokBinary)
205
+ .sort((a, b) => {
206
+ try {
207
+ return fs.statSync(b).size - fs.statSync(a).size;
208
+ }
209
+ catch {
210
+ return 0;
211
+ }
212
+ });
213
+ if (candidates[0])
214
+ return { path: candidates[0], kind: 'cli' };
215
+ }
216
+ catch {
217
+ /* empty downloads */
218
+ }
219
+ return null;
220
+ }
191
221
  if (agent === 'cursor') {
192
222
  // cursor-agent is installed via curl script, not agents-cli. Version argument
193
223
  // is accepted for API symmetry but ignored -- cursor lives on PATH.
@@ -198,6 +228,16 @@ export function locateModelSource(agent, version) {
198
228
  }
199
229
  return null;
200
230
  }
231
+ /** Real Grok binaries are ~100MB+; failed-download stubs are tens of bytes. */
232
+ function isUsableGrokBinary(filePath) {
233
+ try {
234
+ const st = fs.statSync(filePath);
235
+ return st.isFile() && st.size > 1024 * 1024;
236
+ }
237
+ catch {
238
+ return false;
239
+ }
240
+ }
201
241
  /** Search PATH for a command and return its absolute path, or null. */
202
242
  function findOnPath(command) {
203
243
  const pathEnv = process.env.PATH || '';
@@ -679,6 +719,93 @@ function extractAntigravityCatalog(binaryPath) {
679
719
  }
680
720
  return { models, aliases: {} };
681
721
  }
722
+ /**
723
+ * Parse `grok models` stdout into a catalog. Exported for unit tests.
724
+ *
725
+ * Output shape (verified 0.2.118):
726
+ * You are logged in with grok.com.
727
+ *
728
+ * Default model: grok-4.5
729
+ *
730
+ * Available models:
731
+ * * grok-4.5 (default)
732
+ *
733
+ * The `Default model:` line is authoritative; rows may also carry a leading `*`
734
+ * and a `(default)` flag. Grok has no `--json` on this subcommand. Settings live
735
+ * in `config.toml` / `models_cache.json`, not `settings.json`, so the native
736
+ * settings.json reader cannot surface the default — the catalog is the source
737
+ * that makes `resolveConfiguredModel` return a cli-default for Grok.
738
+ */
739
+ export function parseGrokModelsStdout(stdout) {
740
+ // Strip ANSI in case a spinner or color codes slip through.
741
+ // eslint-disable-next-line no-control-regex
742
+ const plain = stdout.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
743
+ let defaultId = null;
744
+ const defaultLine = plain.match(/Default model:\s*(\S+)/i);
745
+ if (defaultLine)
746
+ defaultId = defaultLine[1];
747
+ const models = [];
748
+ const seen = new Set();
749
+ for (const raw of plain.split('\n')) {
750
+ const line = raw.trim();
751
+ if (!line)
752
+ continue;
753
+ // Rows: "* grok-4.5 (default)" or "grok-4.5" or " grok-code-fast-1"
754
+ const m = line.match(/^\*?\s*([A-Za-z0-9][A-Za-z0-9._-]*)(?:\s+\(([^)]*)\))?\s*$/);
755
+ if (!m)
756
+ continue;
757
+ const id = m[1];
758
+ // Real model ids are grok-* (or match the Default model: line). Skip banner words.
759
+ if (!/^grok[-_]/i.test(id) && id !== defaultId)
760
+ continue;
761
+ if (seen.has(id))
762
+ continue;
763
+ seen.add(id);
764
+ const flags = (m[2] ?? '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
765
+ models.push({
766
+ id,
767
+ isDefault: (defaultId != null && id === defaultId) || flags.includes('default'),
768
+ });
769
+ }
770
+ // If Default model was set but did not appear as a row, still surface it.
771
+ if (defaultId && !seen.has(defaultId)) {
772
+ models.unshift({ id: defaultId, isDefault: true });
773
+ }
774
+ // Normalize: exactly one default when we know the Default model: id.
775
+ if (defaultId) {
776
+ for (const model of models)
777
+ model.isDefault = model.id === defaultId;
778
+ }
779
+ else if (models.length > 0 && !models.some((model) => model.isDefault)) {
780
+ models[0].isDefault = true;
781
+ }
782
+ return { models, aliases: {} };
783
+ }
784
+ /** Extract Grok's catalog via `grok models` (see parseGrokModelsStdout). */
785
+ function extractGrokCatalog(binaryPath) {
786
+ const env = { ...process.env };
787
+ // Point GROK_HOME at the version home that owns this binary so auth +
788
+ // models_cache come from the right install, not a host ~/.grok symlink.
789
+ // binary: <home>/.grok/downloads/grok-<ver>-...
790
+ const downloadsDir = path.dirname(binaryPath);
791
+ if (path.basename(downloadsDir) === 'downloads') {
792
+ env.GROK_HOME = path.dirname(downloadsDir);
793
+ }
794
+ let stdout;
795
+ try {
796
+ stdout = execFileSync(binaryPath, ['models'], {
797
+ encoding: 'utf-8',
798
+ stdio: ['ignore', 'pipe', 'ignore'],
799
+ timeout: 15_000,
800
+ maxBuffer: 8 * 1024 * 1024,
801
+ env,
802
+ });
803
+ }
804
+ catch {
805
+ return { models: [], aliases: {} };
806
+ }
807
+ return parseGrokModelsStdout(stdout);
808
+ }
682
809
  /**
683
810
  * Extract Kimi's catalog via `kimi provider list --json`, which emits the raw
684
811
  * providers/models config. Model ids are the `models` object keys (e.g.
@@ -789,6 +916,8 @@ export function getModelCatalog(agent, version) {
789
916
  ({ models, aliases } = extractAntigravityCatalog(src.path));
790
917
  else if (agent === 'kimi')
791
918
  ({ models, aliases } = extractKimiCatalog(src.path));
919
+ else if (agent === 'grok')
920
+ ({ models, aliases } = extractGrokCatalog(src.path));
792
921
  }
793
922
  const catalog = {
794
923
  agent,
@@ -6,7 +6,7 @@
6
6
  <dict>
7
7
  <key>Resources/AppIcon.icns</key>
8
8
  <data>
9
- Vd5nfogg74zSRNspluv4uvDecUA=
9
+ DFq5H08EkhgWIC3UvGMR9B58BZw=
10
10
  </data>
11
11
  </dict>
12
12
  <key>files2</key>
@@ -15,7 +15,7 @@
15
15
  <dict>
16
16
  <key>hash2</key>
17
17
  <data>
18
- DznVe0VgYOux7+B/aiHNgGYLCvSJKzgXDTPJn2jNkNg=
18
+ mBSjM6jlvN7J1jQowsvqTl7CHM0pur0e6qsHMuzk5k0=
19
19
  </data>
20
20
  </dict>
21
21
  <key>embedded.provisionprofile</key>
@@ -8,6 +8,10 @@
8
8
  */
9
9
  import Database from '../sqlite.js';
10
10
  import type { SessionAgentId, SessionMeta } from './types.js';
11
+ /** Current schema version; bumped when migrations are added. Exported so tests
12
+ * assert against the constant instead of hardcoding a number that every bump
13
+ * then has to chase (docs/05-sessions.md calls the constant the source of truth). */
14
+ export declare const SCHEMA_VERSION = 21;
11
15
  /** Raw row shape returned from the sessions table. */
12
16
  export interface SessionRow {
13
17
  id: string;
@@ -40,6 +44,7 @@ export interface SessionRow {
40
44
  pr_number: number | null;
41
45
  worktree_slug: string | null;
42
46
  ticket_id: string | null;
47
+ spawned_team: string | null;
43
48
  plan: string | null;
44
49
  machine: string | null;
45
50
  todos: string | null;
@@ -297,6 +302,22 @@ export declare function queryAffinityRollup(options: {
297
302
  export declare function queryUsageRollup(options: QueryOptions & {
298
303
  groupBy: UsageRollupGroup;
299
304
  }): UsageRollupRow[];
305
+ /** Who spawned a team: the orchestrator session, from its transcript. */
306
+ export interface TeamSpawner {
307
+ sessionId: string;
308
+ shortId: string;
309
+ /** The human the orchestrator ran as, when the row carries actor provenance. */
310
+ actor?: string;
311
+ }
312
+ /**
313
+ * Map every team name to the session that ran `agents teams create/add` for it.
314
+ *
315
+ * One scan over the rows that carry a `spawned_team`, rather than a query per
316
+ * team — `agents teams list` needs the whole map at once, and the column has no
317
+ * index. When two sessions spawned the same team name (a team re-created after a
318
+ * disband), the most recent wins, which is the one whose work the name refers to.
319
+ */
320
+ export declare function teamSpawners(): Map<string, TeamSpawner>;
300
321
  /** A session with its cost, for the top-N-by-cost listing. */
301
322
  export interface TopCostSession {
302
323
  meta: SessionMeta;