@phnx-labs/agents-cli 1.20.29 → 1.20.31

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 (58) hide show
  1. package/dist/commands/computer-actions.js +6 -2
  2. package/dist/commands/computer.d.ts +12 -0
  3. package/dist/commands/computer.js +88 -13
  4. package/dist/commands/inspect.js +1 -1
  5. package/dist/commands/models.js +8 -2
  6. package/dist/commands/sessions-picker.js +35 -10
  7. package/dist/commands/sessions.js +164 -44
  8. package/dist/commands/setup.js +8 -0
  9. package/dist/commands/ssh.js +123 -15
  10. package/dist/commands/sync.js +70 -14
  11. package/dist/lib/agents.d.ts +0 -4
  12. package/dist/lib/agents.js +122 -22
  13. package/dist/lib/browser/drivers/ssh.js +4 -35
  14. package/dist/lib/computer-rpc.d.ts +6 -1
  15. package/dist/lib/computer-rpc.js +86 -3
  16. package/dist/lib/devices/registry.d.ts +11 -0
  17. package/dist/lib/devices/registry.js +53 -1
  18. package/dist/lib/devices/sync.d.ts +42 -0
  19. package/dist/lib/devices/sync.js +85 -0
  20. package/dist/lib/exec.js +14 -0
  21. package/dist/lib/models.js +138 -5
  22. package/dist/lib/runner.js +7 -7
  23. package/dist/lib/session/active.d.ts +15 -0
  24. package/dist/lib/session/active.js +108 -19
  25. package/dist/lib/session/cloud.js +2 -0
  26. package/dist/lib/session/db.d.ts +11 -0
  27. package/dist/lib/session/db.js +62 -5
  28. package/dist/lib/session/digest.d.ts +50 -0
  29. package/dist/lib/session/digest.js +170 -0
  30. package/dist/lib/session/discover.d.ts +5 -0
  31. package/dist/lib/session/discover.js +81 -0
  32. package/dist/lib/session/parse.d.ts +15 -0
  33. package/dist/lib/session/parse.js +22 -2
  34. package/dist/lib/session/remote.d.ts +1 -1
  35. package/dist/lib/session/remote.js +8 -3
  36. package/dist/lib/session/render.d.ts +2 -0
  37. package/dist/lib/session/render.js +83 -10
  38. package/dist/lib/session/state.d.ts +82 -0
  39. package/dist/lib/session/state.js +221 -0
  40. package/dist/lib/session/tail.d.ts +18 -0
  41. package/dist/lib/session/tail.js +57 -0
  42. package/dist/lib/session/types.d.ts +9 -0
  43. package/dist/lib/session/width.d.ts +29 -0
  44. package/dist/lib/session/width.js +91 -0
  45. package/dist/lib/shims.d.ts +17 -1
  46. package/dist/lib/shims.js +130 -6
  47. package/dist/lib/ssh-tunnel.d.ts +127 -0
  48. package/dist/lib/ssh-tunnel.js +346 -0
  49. package/dist/lib/state.d.ts +4 -0
  50. package/dist/lib/state.js +19 -1
  51. package/dist/lib/sync-umbrella.d.ts +5 -0
  52. package/dist/lib/sync-umbrella.js +10 -0
  53. package/dist/lib/teams/agents.d.ts +11 -1
  54. package/dist/lib/teams/agents.js +16 -2
  55. package/dist/lib/types.d.ts +1 -0
  56. package/dist/lib/versions.d.ts +19 -0
  57. package/dist/lib/versions.js +84 -24
  58. package/package.json +1 -1
@@ -16,8 +16,11 @@ import * as path from 'path';
16
16
  import chalk from 'chalk';
17
17
  import ora from 'ora';
18
18
  import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
19
- import { getDevice, loadDevices, removeDevice, upsertDevice, } from '../lib/devices/registry.js';
19
+ import { machineId } from '../lib/session/sync/config.js';
20
+ import { addIgnored, getDevice, loadDevices, loadIgnored, removeDevice, removeIgnored, upsertDevice, } from '../lib/devices/registry.js';
20
21
  import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from '../lib/devices/tailscale.js';
22
+ import { planDeviceReconciliation, runDeviceSync } from '../lib/devices/sync.js';
23
+ import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
21
24
  import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
22
25
  import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassShim, } from '../lib/devices/connect.js';
23
26
  /** Parse `user@host` or `host` into pieces. */
@@ -27,8 +30,9 @@ function parseTarget(target) {
27
30
  return { host: target };
28
31
  return { user: target.slice(0, at), host: target.slice(at + 1) };
29
32
  }
30
- /** One-line summary of a device for `list`. */
31
- function deviceSummary(d) {
33
+ /** One-line summary of a device for `list`. `isSelf` marks the machine this
34
+ * command is running on so it stands out from the rest of the tailnet. */
35
+ function deviceSummary(d, isSelf = false) {
32
36
  const addr = hostNameFor(d) ?? chalk.gray('no address');
33
37
  const online = d.tailscale
34
38
  ? d.tailscale.online
@@ -36,7 +40,10 @@ function deviceSummary(d) {
36
40
  : chalk.gray('offline')
37
41
  : chalk.gray('unknown');
38
42
  const reach = d.tailscale?.online && !d.tailscale.direct ? chalk.yellow(' (relayed)') : '';
39
- return ` ${chalk.bold(d.name.padEnd(16))} ${String(d.platform).padEnd(8)} ${(d.user ? d.user + '@' : '') + addr} ${online}${reach}`;
43
+ const marker = isSelf ? chalk.cyan('▸ ') : ' ';
44
+ const name = isSelf ? chalk.bold.cyan(d.name.padEnd(16)) : chalk.bold(d.name.padEnd(16));
45
+ const here = isSelf ? chalk.cyan(' ← this machine') : '';
46
+ return `${marker}${name} ${String(d.platform).padEnd(8)} ${(d.user ? d.user + '@' : '') + addr} ${online}${reach}${here}`;
40
47
  }
41
48
  /** Resolve a device or exit with a clear error. */
42
49
  async function mustGetDevice(name) {
@@ -47,6 +54,72 @@ async function mustGetDevice(name) {
47
54
  }
48
55
  return d;
49
56
  }
57
+ /**
58
+ * Interactive `agents devices sync`: discover tailscale nodes, present a
59
+ * checkbox pre-checked with what's already registered, and reconcile the
60
+ * choice. Checked = registered (and un-ignored). Unchecked = removed from the
61
+ * registry AND added to the ignore-list, so auto-discovery never re-suggests
62
+ * it — this is the "click to register/unregister" surface, with dismissals that
63
+ * stick.
64
+ */
65
+ async function runInteractiveDeviceSync() {
66
+ const spinner = ora('Reading tailscale status...').start();
67
+ let nodes;
68
+ try {
69
+ nodes = parseTailscaleStatus(tailscaleStatusJson());
70
+ }
71
+ catch (err) {
72
+ spinner.fail(err.message);
73
+ process.exit(1);
74
+ }
75
+ const [reg, ignored] = await Promise.all([loadDevices(), loadIgnored()]);
76
+ const registered = new Set(Object.keys(reg));
77
+ spinner.stop();
78
+ if (nodes.length === 0) {
79
+ console.log(chalk.gray('No tailscale nodes found.'));
80
+ return;
81
+ }
82
+ const { checkbox } = await import('@inquirer/prompts');
83
+ let selected;
84
+ try {
85
+ selected = await checkbox({
86
+ // Everything not already dismissed starts checked, so pressing Enter keeps
87
+ // the fleet as-is (matching what auto-sync would register). Unchecking a
88
+ // device removes it AND dismisses it so auto-sync never re-adds it.
89
+ message: 'Your fleet — uncheck a device to remove and stop suggesting it:',
90
+ pageSize: Math.min(nodes.length, 20),
91
+ choices: nodes.map((n) => {
92
+ const flags = [n.platform, n.online ? undefined : 'offline', ignored.has(n.name) ? 'ignored' : undefined]
93
+ .filter(Boolean)
94
+ .join(', ');
95
+ return { value: n.name, name: `${n.name} ${chalk.gray(`(${flags})`)}`, checked: !ignored.has(n.name) };
96
+ }),
97
+ });
98
+ }
99
+ catch (err) {
100
+ if (isPromptCancelled(err)) {
101
+ console.log(chalk.gray('Cancelled — no changes.'));
102
+ return;
103
+ }
104
+ throw err;
105
+ }
106
+ const byName = new Map(nodes.map((n) => [n.name, n]));
107
+ const plan = planDeviceReconciliation(byName.keys(), selected, registered, ignored);
108
+ for (const name of plan.toRegister)
109
+ await upsertDevice(name, nodeToDeviceInput(byName.get(name)));
110
+ for (const name of plan.toUnignore)
111
+ await removeIgnored(name);
112
+ for (const name of plan.toRemove)
113
+ await removeDevice(name);
114
+ for (const name of plan.toIgnore)
115
+ await addIgnored(name);
116
+ const parts = [
117
+ chalk.green(`${plan.toRegister.length} registered`),
118
+ plan.toRemove.length ? chalk.yellow(`${plan.toRemove.length} removed`) : null,
119
+ plan.toIgnore.length ? chalk.gray(`${plan.toIgnore.length} ignored`) : null,
120
+ ].filter(Boolean);
121
+ console.log(parts.join(chalk.gray(' · ')));
122
+ }
50
123
  /** Register the `agents devices` command tree. */
51
124
  function registerDevicesCommands(program) {
52
125
  const devicesCmd = program
@@ -54,43 +127,78 @@ function registerDevicesCommands(program) {
54
127
  .description('Registry of SSH device profiles (platform, user, address, auth), self-populated from Tailscale.')
55
128
  .addHelpText('after', `
56
129
  Typical workflow:
57
- agents devices sync # ingest tailscale nodes (auto-detect platform)
130
+ agents devices sync # curate: pick which tailscale nodes to keep (TTY)
131
+ agents devices sync --yes # non-interactive: register all non-ignored nodes
58
132
  agents devices list # see what's registered
133
+ agents devices ignore ipad165 # dismiss a node so it's never re-suggested
59
134
  agents devices set win-mini --auth password --bundle muqsit
60
135
  agents devices render --write # write ~/.ssh/config.d/agents include
61
136
  `);
62
137
  devicesCmd
63
138
  .command('sync')
64
- .description('Ingest `tailscale status --json` and create/update device profiles (auto-detects platform, address, reachability).')
65
- .action(async () => {
139
+ .description('Ingest `tailscale status --json` into device profiles. In a terminal, opens a checkbox to register/unregister nodes; with --yes, registers every non-ignored node.')
140
+ .option('--yes', 'skip the picker; register all discovered non-ignored nodes')
141
+ .action(async (opts) => {
142
+ if (isInteractiveTerminal() && !opts.yes) {
143
+ await runInteractiveDeviceSync();
144
+ return;
145
+ }
66
146
  const spinner = ora('Reading tailscale status...').start();
67
147
  try {
68
- const nodes = parseTailscaleStatus(tailscaleStatusJson());
69
- spinner.text = `Updating ${nodes.length} device${nodes.length === 1 ? '' : 's'}...`;
70
- for (const node of nodes) {
71
- await upsertDevice(node.name, nodeToDeviceInput(node));
72
- }
73
- spinner.succeed(`Synced ${nodes.length} device${nodes.length === 1 ? '' : 's'} from Tailscale`);
148
+ const res = await runDeviceSync();
149
+ const extra = res.pending.length ? chalk.gray(` (${res.pending.length} new)`) : '';
150
+ spinner.succeed(`Synced ${res.synced} device${res.synced === 1 ? '' : 's'} from Tailscale${extra}`);
74
151
  }
75
152
  catch (err) {
76
153
  spinner.fail(err.message);
77
154
  process.exit(1);
78
155
  }
79
156
  });
157
+ devicesCmd
158
+ .command('ignore <name>')
159
+ .description('Dismiss a node from auto-discovery so it is never re-suggested (and remove it from the registry if present).')
160
+ .action(async (name) => {
161
+ try {
162
+ await removeDevice(name);
163
+ await addIgnored(name);
164
+ console.log(chalk.green(`Ignored '${name}'`) + chalk.gray(" — it won't be suggested again. Undo with `agents devices unignore`."));
165
+ }
166
+ catch (err) {
167
+ console.error(chalk.red(err.message));
168
+ process.exit(1);
169
+ }
170
+ });
171
+ devicesCmd
172
+ .command('unignore <name>')
173
+ .description('Undo `ignore`: allow a node to be discovered and registered again.')
174
+ .action(async (name) => {
175
+ const ok = await removeIgnored(name);
176
+ if (!ok) {
177
+ console.error(chalk.gray(`'${name}' was not ignored.`));
178
+ return;
179
+ }
180
+ console.log(chalk.green(`No longer ignoring '${name}'`) + chalk.gray(' — run `agents devices sync` to register it.'));
181
+ });
80
182
  devicesCmd
81
183
  .command('list')
82
184
  .alias('ls')
83
185
  .description('List registered devices with platform, address, and reachability.')
84
- .action(async () => {
186
+ .option('--json', 'output the registry as a JSON array (for scripts and hooks)')
187
+ .action(async (opts) => {
85
188
  const reg = await loadDevices();
86
189
  const names = Object.keys(reg).sort();
190
+ if (opts.json) {
191
+ process.stdout.write(JSON.stringify(names.map((n) => reg[n]), null, 2) + '\n');
192
+ return;
193
+ }
87
194
  if (names.length === 0) {
88
195
  console.log(chalk.gray("No devices. Run 'agents devices sync' or 'agents devices add <name> <user@host>'."));
89
196
  return;
90
197
  }
198
+ const self = machineId();
91
199
  console.log(chalk.bold(`Devices (${names.length})`));
92
200
  for (const name of names)
93
- console.log(deviceSummary(reg[name]));
201
+ console.log(deviceSummary(reg[name], name === self));
94
202
  });
95
203
  devicesCmd
96
204
  .command('show <name>')
@@ -30,7 +30,7 @@
30
30
  import * as path from 'path';
31
31
  import chalk from 'chalk';
32
32
  import { agentLabel, resolveAgentName } from '../lib/agents.js';
33
- import { isVersionInstalled, syncResourcesToVersion, parseAgentSpec, resolveVersion, resolveVersionAlias, listInstalledVersions, getAvailableResources, getActuallySyncedResources, getProjectOnlyResources, getNewResources, hasNewResources, promptResourceSelection, promptNewResourceSelection, } from '../lib/versions.js';
33
+ import { isVersionInstalled, syncResourcesToVersion, parseAgentSpec, resolveVersion, resolveVersionAlias, listInstalledVersions, getAvailableResources, getActuallySyncedResources, getProjectOnlyResources, getNewResources, hasNewResources, promptResourceSelection, promptNewResourceSelection, buildRepoScopedSelection, listRepoNames, } from '../lib/versions.js';
34
34
  import { compileRulesForProject } from '../lib/rules/compile.js';
35
35
  import { runLaunchSync } from '../lib/project-launch.js';
36
36
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
@@ -38,11 +38,12 @@ import { runUmbrellaSync } from '../lib/sync-umbrella.js';
38
38
  /** Register the `agents sync` command. */
39
39
  export function registerSyncCommand(program) {
40
40
  program
41
- .command('sync [agentSpec]')
41
+ .command('sync [agentSpec] [repo]')
42
42
  .summary('Make this machine current, or sync resources into one agent')
43
- .description('With an [agentSpec], syncs resources (commands, skills, hooks, rules, MCPs, plugins, etc.) into that installed agent version — previews changes and lets you pick. e.g. "claude", "claude@2.1.142", or a selector: @latest / @oldest / @pinned (= @default).\n\nWith NO agent, runs the umbrella verb: fetch remote state (config repos + secrets + sessions) then reconcile it into every installed agent. Scope it with --repos / --secrets / --sessions, --cloud (fetch only), or --local (reconcile only).')
43
+ .description('With an [agentSpec], syncs resources (commands, skills, hooks, rules, MCPs, plugins, etc.) into that installed agent version — previews changes and lets you pick. e.g. "claude", "claude@2.1.142", a selector: @latest / @oldest / @pinned (= @default), or @all for every installed version.\n\nAppend a [repo] (or pass --repo) to scope the sync to a single DotAgent repo — system / user / project / <alias>. e.g. "agents sync claude@all system" reconciles only the system repo\'s resources into every installed Claude.\n\nWith NO agent, runs the umbrella verb: fetch remote state (config repos + secrets + sessions) then reconcile it into every installed agent. Scope it with --repos / --secrets / --sessions, --cloud (fetch only), or --local (reconcile only).')
44
44
  .option('--agent <agent>', 'Agent identifier (legacy form; prefer the positional spec)')
45
45
  .option('--agent-version <version>', 'Version to sync into (legacy form; prefer "agent@version")')
46
+ .option('--repo <name>', 'Scope the sync to a single DotAgent repo: system / user / project / <alias> (also accepted as a positional)')
46
47
  .option('--project-dir <path>', 'Path to project-level .agents/ directory containing project-scoped resources')
47
48
  .option('--cwd <path>', 'Working directory for discovering project manifest and resources')
48
49
  .option('--launch', 'Hot-path mode (shim only): skip version-home reconciliation, run project-scoped compile + workspace mirror + plugin marketplaces', false)
@@ -55,8 +56,8 @@ export function registerSyncCommand(program) {
55
56
  .option('--sessions', 'Umbrella: sync session transcripts across machines', false)
56
57
  .option('--cloud', 'Umbrella: fetch all remote state but skip the local reconcile', false)
57
58
  .option('--local', "Umbrella: reconcile resources into installed agents only (no fetch)", false)
58
- .action(async (agentSpec, opts) => {
59
- await runSync(agentSpec, opts);
59
+ .action(async (agentSpec, repo, opts) => {
60
+ await runSync(agentSpec, repo, opts);
60
61
  });
61
62
  }
62
63
  /**
@@ -109,7 +110,7 @@ async function runUmbrella(opts, quiet, outLog, errLog) {
109
110
  process.exitCode = 1;
110
111
  }
111
112
  }
112
- async function runSync(agentSpec, opts) {
113
+ async function runSync(agentSpec, repoArg, opts) {
113
114
  const quiet = !!opts.quiet;
114
115
  const errLog = (msg) => { if (!quiet)
115
116
  console.error(msg); };
@@ -119,16 +120,17 @@ async function runSync(agentSpec, opts) {
119
120
  let agentId;
120
121
  let version;
121
122
  // A positional @selector typed by the user (latest/oldest/pinned/default/
122
- // explicit). parseAgentSpec defaults a missing version to 'latest', so a bare
123
- // `agents sync claude` and `agents sync claude@latest` are indistinguishable
124
- // after parsing — we only treat the version as a selector when an '@' was
125
- // actually typed, keeping bare `claude` on the default-version path.
123
+ // all/explicit). parseAgentSpec defaults a missing version to 'latest', so a
124
+ // bare `agents sync claude` and `agents sync claude@latest` are
125
+ // indistinguishable after parsing — we only treat the version as a selector
126
+ // when an '@' was actually typed, keeping bare `claude` on the
127
+ // default-version path.
126
128
  let selector;
127
129
  if (agentSpec) {
128
130
  const parsed = parseAgentSpec(agentSpec);
129
131
  if (!parsed) {
130
132
  errLog(chalk.red(`Invalid agent spec '${agentSpec}'.`));
131
- errLog(chalk.gray('Examples: claude, claude@2.1.142, claude@latest, claude@oldest, claude@pinned'));
133
+ errLog(chalk.gray('Examples: claude, claude@2.1.142, claude@latest, claude@oldest, claude@pinned, claude@all'));
132
134
  process.exitCode = 1;
133
135
  return;
134
136
  }
@@ -136,6 +138,18 @@ async function runSync(agentSpec, opts) {
136
138
  if (agentSpec.includes('@'))
137
139
  selector = parsed.version;
138
140
  }
141
+ // Repo scope: --repo flag wins over the positional. Validate against the
142
+ // known DotAgent repos so a typo fails loudly instead of syncing nothing.
143
+ const repoScope = opts.repo || repoArg;
144
+ if (repoScope !== undefined) {
145
+ const known = listRepoNames();
146
+ if (!known.includes(repoScope)) {
147
+ errLog(chalk.red(`Unknown repo '${repoScope}'.`));
148
+ errLog(chalk.gray(`Known repos: ${known.join(', ')}`));
149
+ process.exitCode = 1;
150
+ return;
151
+ }
152
+ }
139
153
  if (opts.agent) {
140
154
  const resolved = resolveAgentName(opts.agent);
141
155
  if (!resolved) {
@@ -156,6 +170,37 @@ async function runSync(agentSpec, opts) {
156
170
  await runUmbrella(opts, quiet, outLog, errLog);
157
171
  return;
158
172
  }
173
+ const projectDir = opts.projectDir;
174
+ const cwd = opts.cwd || process.cwd();
175
+ const force = !!opts.force;
176
+ // ---------- 2a. @all: reconcile every installed version of this agent ----------
177
+ // Non-interactive by design — fanning an interactive preview across N
178
+ // versions is unusable. Honors an optional repo scope.
179
+ if (selector === 'all') {
180
+ const installed = listInstalledVersions(agentId);
181
+ if (installed.length === 0) {
182
+ errLog(chalk.red(`No ${agentLabel(agentId)} versions installed.`));
183
+ errLog(chalk.gray(`Install one: agents add ${agentId}@latest`));
184
+ process.exitCode = 1;
185
+ return;
186
+ }
187
+ let selection;
188
+ if (repoScope) {
189
+ selection = buildRepoScopedSelection(repoScope, cwd);
190
+ if (Object.keys(selection).length === 0) {
191
+ outLog(chalk.gray(`Nothing from repo '${repoScope}' to sync.`));
192
+ return;
193
+ }
194
+ }
195
+ const scopeLabel = repoScope ? chalk.gray(` (repo: ${repoScope})`) : '';
196
+ outLog(chalk.cyan(`Syncing ${installed.length} ${agentLabel(agentId)} version(s)${scopeLabel}.`));
197
+ for (const v of installed) {
198
+ const result = syncResourcesToVersion(agentId, v, selection, { projectDir, cwd, force });
199
+ if (!quiet)
200
+ printSyncDetail(result, agentId, v, cwd);
201
+ }
202
+ return;
203
+ }
159
204
  // ---------- 2. Resolve version (project pin → global default → sole installed) ----------
160
205
  // A positional @selector wins over the default-resolution below.
161
206
  // @latest / @oldest → newest / oldest installed (process.exit if none)
@@ -197,15 +242,26 @@ async function runSync(agentSpec, opts) {
197
242
  process.exitCode = 1;
198
243
  return;
199
244
  }
200
- const projectDir = opts.projectDir;
201
- const cwd = opts.cwd || process.cwd();
202
245
  // ---------- 3. --launch mode bypasses everything below ----------
203
246
  if (opts.launch) {
204
247
  runLaunchMode(agentId, version, cwd, quiet);
205
248
  return;
206
249
  }
250
+ // ---------- 3b. Repo-scoped single-version sync ----------
251
+ // An explicit --repo / positional repo is a targeted request, so skip the
252
+ // interactive preview and reconcile just that repo's resources.
253
+ if (repoScope) {
254
+ const scoped = buildRepoScopedSelection(repoScope, cwd);
255
+ if (Object.keys(scoped).length === 0) {
256
+ outLog(chalk.gray(`Nothing from repo '${repoScope}' to sync into ${agentLabel(agentId)}@${version}.`));
257
+ return;
258
+ }
259
+ const result = syncResourcesToVersion(agentId, version, scoped, { projectDir, cwd, force });
260
+ if (!quiet)
261
+ printSyncDetail(result, agentId, version, cwd);
262
+ return;
263
+ }
207
264
  // ---------- 4. Decide selection (interactive preview vs auto) ----------
208
- const force = !!opts.force;
209
265
  const yes = !!opts.yes;
210
266
  const interactive = !quiet && !yes && isInteractiveTerminal();
211
267
  let selection;
@@ -111,10 +111,6 @@ export interface AccountInfo {
111
111
  }
112
112
  /** Return the email address associated with the agent's auth config, or null. */
113
113
  export declare function getAccountEmail(agentId: AgentId, home?: string): Promise<string | null>;
114
- /**
115
- * Extract full account information (identity, plan, usage status, credits) from
116
- * the agent's local auth/config files. Supports Claude, Codex, and Gemini.
117
- */
118
114
  export declare function getAccountInfo(agentId: AgentId, home?: string): Promise<AccountInfo>;
119
115
  /**
120
116
  * Determine when the agent was last used by checking session file mtimes,
@@ -410,6 +410,7 @@ export const AGENTS = {
410
410
  npmPackage: '',
411
411
  installScript: 'curl -fsSL https://antigravity.google/cli/install.sh | bash',
412
412
  configDir: path.join(HOME, '.gemini', 'antigravity-cli'),
413
+ authFiles: ['antigravity-oauth-token'],
413
414
  commandsDir: path.join(HOME, '.gemini', 'antigravity-cli', 'commands'),
414
415
  commandsSubdir: 'commands',
415
416
  skillsDir: path.join(HOME, '.gemini', 'antigravity-cli', 'skills'),
@@ -470,6 +471,7 @@ export const AGENTS = {
470
471
  npmPackage: '@moonshot-ai/kimi-code',
471
472
  installScript: 'curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash',
472
473
  configDir: path.join(HOME, '.kimi-code'),
474
+ authFiles: ['credentials/kimi-code.json'],
473
475
  commandsDir: '',
474
476
  commandsSubdir: '',
475
477
  skillsDir: path.join(HOME, '.kimi-code', 'skills'),
@@ -494,7 +496,8 @@ export const AGENTS = {
494
496
  },
495
497
  // Factory AI Droid CLI (`droid`) — agentic coding CLI from factory.ai.
496
498
  // Install: `curl -fsSL https://app.factory.ai/cli | sh` (no npm package).
497
- // Binary is NOT in node_modules/.bin — resolved via resolveDroidBinary().
499
+ // Binary is NOT in node_modules/.bin — the shim resolves the fixed install
500
+ // path ~/.local/bin/droid directly (see the droid branch in shims.ts).
498
501
  // Config: `~/.factory/` (settings.json, mcp.json, droids/, commands/).
499
502
  // Memory: native AGENTS.md. Subagents = custom droids (top-level .md files
500
503
  // in ~/.factory/droids/). Config isolation rides the ~/.factory symlink
@@ -508,6 +511,7 @@ export const AGENTS = {
508
511
  npmPackage: '',
509
512
  installScript: 'curl -fsSL https://app.factory.ai/cli | sh',
510
513
  configDir: path.join(HOME, '.factory'),
514
+ authFiles: ['auth.v2.file', 'auth.v2.key'],
511
515
  commandsDir: path.join(HOME, '.factory', 'commands'),
512
516
  commandsSubdir: 'commands',
513
517
  skillsDir: '', // no skills concept
@@ -707,6 +711,7 @@ export const UNMANAGED_DETECTION_CANDIDATES = [
707
711
  'gemini',
708
712
  'grok',
709
713
  'copilot',
714
+ 'droid',
710
715
  ];
711
716
  /**
712
717
  * Detect existing agent installations that are NOT yet managed by agents-cli.
@@ -770,6 +775,64 @@ export async function getAccountEmail(agentId, home) {
770
775
  * Extract full account information (identity, plan, usage status, credits) from
771
776
  * the agent's local auth/config files. Supports Claude, Codex, and Gemini.
772
777
  */
778
+ /**
779
+ * Resolve a file-auth agent's credential file. Sign-in is account-global, but
780
+ * each installed version gets an isolated home; the credential physically lives
781
+ * only in the home the user logged in under (the one the `~/.<config>` symlink
782
+ * targets). Check the per-version `base` first, then fall back to the active
783
+ * config location under the real HOME so every installed version reflects the
784
+ * true account state (droid/antigravity/kimi all stored login per-version-home
785
+ * and showed non-active versions as "not signed in"). Returns the first
786
+ * existing path, or null.
787
+ */
788
+ function resolveAccountCredentialPath(base, ...segments) {
789
+ const perVersion = path.join(base, ...segments);
790
+ try {
791
+ if (fs.existsSync(perVersion))
792
+ return perVersion;
793
+ }
794
+ catch { /* unreadable */ }
795
+ const active = path.join(process.env.AGENTS_REAL_HOME || os.homedir(), ...segments);
796
+ if (active !== perVersion) {
797
+ try {
798
+ if (fs.existsSync(active))
799
+ return active;
800
+ }
801
+ catch { /* unreadable */ }
802
+ }
803
+ return null;
804
+ }
805
+ let cachedAgyKeychainSignedIn;
806
+ /**
807
+ * Antigravity (`agy`, a Codeium/Windsurf-based CLI) stores its OAuth token in
808
+ * the macOS keychain — service `gemini`, account `antigravity` — NOT a file.
809
+ * The file path (`antigravity-oauth-token`) only exists on Linux, where the Go
810
+ * keyring falls back to disk. Probe the keychain for existence (metadata only;
811
+ * `-w` omitted so it never prompts). Cached per process — the keychain is
812
+ * account-global, so one probe covers every installed version. Returns false on
813
+ * non-macOS (the file path handles those).
814
+ */
815
+ async function antigravityKeychainSignedIn() {
816
+ if (cachedAgyKeychainSignedIn !== undefined)
817
+ return cachedAgyKeychainSignedIn;
818
+ // Test isolation: the real macOS keychain can't be sandboxed per-test, so
819
+ // allow suites asserting "signed out" to opt out of the probe (same spirit as
820
+ // AGENTS_REAL_HOME). Not cached, so tests can toggle it.
821
+ if (process.env.AGENTS_NO_KEYCHAIN_PROBE === '1')
822
+ return false;
823
+ if (process.platform !== 'darwin') {
824
+ cachedAgyKeychainSignedIn = false;
825
+ return false;
826
+ }
827
+ try {
828
+ await execFileAsync('security', ['find-generic-password', '-s', 'gemini', '-a', 'antigravity'], { timeout: 3000 });
829
+ cachedAgyKeychainSignedIn = true;
830
+ }
831
+ catch {
832
+ cachedAgyKeychainSignedIn = false;
833
+ }
834
+ return cachedAgyKeychainSignedIn;
835
+ }
773
836
  export async function getAccountInfo(agentId, home) {
774
837
  const base = home || os.homedir();
775
838
  const empty = {
@@ -906,40 +969,60 @@ export async function getAccountInfo(agentId, home) {
906
969
  return { ...empty, email, signedIn: !!email, lastActive };
907
970
  }
908
971
  case 'grok': {
909
- // Grok stores auth in ~/.grok/auth.json
972
+ // Grok stores auth in ~/.grok/auth.json as a map keyed by
973
+ // "<oidc_issuer>::<client_id>" -> { email, user_id, refresh_token,
974
+ // create_time, expires_at, team_id, ... }. (Older builds wrote a flat
975
+ // object with a top-level email.) The old code only read a TOP-LEVEL
976
+ // `email`, so the current nested format always looked signed-out even
977
+ // when logged in. Read the newest account record: a refresh token means
978
+ // signed in, and we surface the email/ids like claude/codex.
979
+ const authPath = resolveAccountCredentialPath(base, '.grok', 'auth.json');
980
+ if (!authPath)
981
+ return { ...empty, lastActive };
910
982
  try {
911
- const authPath = path.join(base, '.grok', 'auth.json');
912
- if (fs.existsSync(authPath)) {
913
- const data = JSON.parse(await fs.promises.readFile(authPath, 'utf-8'));
914
- const email = data.email || data.user?.email || data.account?.email || null;
915
- return { ...empty, email, signedIn: !!email, lastActive };
983
+ const data = JSON.parse(await fs.promises.readFile(authPath, 'utf-8'));
984
+ const records = (data && typeof data === 'object' ? [data, ...Object.values(data)] : [])
985
+ .filter((r) => !!r && typeof r === 'object');
986
+ const account = records
987
+ .filter(r => typeof r.refresh_token === 'string' || typeof r.email === 'string')
988
+ .sort((a, b) => String(b.create_time || '').localeCompare(String(a.create_time || '')))[0];
989
+ if (account) {
990
+ const email = typeof account.email === 'string' ? account.email : null;
991
+ const accountId = normalizeIdentityPart(account.user_id ?? account.principal_id);
992
+ const organizationId = normalizeIdentityPart(account.team_id);
993
+ const accountKey = buildIdentityKey(agentId, [['user', accountId], ['org', organizationId]]);
994
+ return { ...empty, email, accountId, organizationId, accountKey, signedIn: true, lastActive };
916
995
  }
917
996
  }
918
997
  catch { }
919
998
  return { ...empty, lastActive };
920
999
  }
921
1000
  case 'antigravity': {
922
- // Antigravity (`agy`) stores a Google OAuth token at
923
- // ~/.gemini/antigravity-cli/antigravity-oauth-token. It's a consumer
924
- // OAuth grant (access + refresh token, no id_token), so there's no email
925
- // claim to read locally — presence of a refresh token is the only
926
- // signed-in signal we can derive without a network call.
927
- const tokenPath = path.join(base, '.gemini', 'antigravity-cli', 'antigravity-oauth-token');
928
- if (!fs.existsSync(tokenPath))
929
- return { ...empty, lastActive };
930
- const data = JSON.parse(await fs.promises.readFile(tokenPath, 'utf-8'));
931
- const hasToken = typeof data?.token?.refresh_token === 'string' && !!data.token.refresh_token;
932
- if (!hasToken)
933
- return { ...empty, lastActive };
934
- return { ...empty, signedIn: true, lastActive };
1001
+ // Antigravity (`agy`) stores a consumer Google OAuth grant (access +
1002
+ // refresh token, no id_token) — presence of a refresh token is the only
1003
+ // signed-in signal we can derive without a network call. Storage is
1004
+ // platform-split: on Linux it's a file at
1005
+ // ~/.gemini/antigravity-cli/antigravity-oauth-token; on macOS the Go
1006
+ // keyring puts it in the keychain (service 'gemini', account
1007
+ // 'antigravity'), so no file exists — check both.
1008
+ const tokenPath = resolveAccountCredentialPath(base, '.gemini', 'antigravity-cli', 'antigravity-oauth-token');
1009
+ if (tokenPath) {
1010
+ const data = JSON.parse(await fs.promises.readFile(tokenPath, 'utf-8'));
1011
+ if (typeof data?.token?.refresh_token === 'string' && data.token.refresh_token) {
1012
+ return { ...empty, signedIn: true, lastActive };
1013
+ }
1014
+ }
1015
+ if (await antigravityKeychainSignedIn())
1016
+ return { ...empty, signedIn: true, lastActive };
1017
+ return { ...empty, lastActive };
935
1018
  }
936
1019
  case 'kimi': {
937
1020
  // Kimi Code stores OAuth credentials at
938
1021
  // ~/.kimi-code/credentials/kimi-code.json. The access token is a JWT
939
1022
  // whose payload carries an opaque user_id (no email), so we report
940
1023
  // signed-in state plus a stable account key for usage dedup.
941
- const credPath = path.join(base, '.kimi-code', 'credentials', 'kimi-code.json');
942
- if (!fs.existsSync(credPath))
1024
+ const credPath = resolveAccountCredentialPath(base, '.kimi-code', 'credentials', 'kimi-code.json');
1025
+ if (!credPath)
943
1026
  return { ...empty, lastActive };
944
1027
  const data = JSON.parse(await fs.promises.readFile(credPath, 'utf-8'));
945
1028
  const accessToken = data?.access_token;
@@ -950,6 +1033,18 @@ export async function getAccountInfo(agentId, home) {
950
1033
  const accountKey = buildIdentityKey(agentId, [['user', userId]]);
951
1034
  return { ...empty, signedIn: true, accountId: userId, accountKey, lastActive };
952
1035
  }
1036
+ case 'droid': {
1037
+ // Factory Droid stores auth at ~/.factory/auth.v2.file (+ auth.v2.key,
1038
+ // an encrypted blob). No email/JWT is readable locally, so presence of
1039
+ // the auth file is the only signed-in signal we can derive without a
1040
+ // network call — same pattern as antigravity/kimi. `.factory` is the
1041
+ // config dir on every platform (macOS/Linux ~/.factory, Windows
1042
+ // %USERPROFILE%\.factory), so path.join keeps this cross-platform.
1043
+ const authPath = resolveAccountCredentialPath(base, '.factory', 'auth.v2.file');
1044
+ if (!authPath)
1045
+ return { ...empty, lastActive };
1046
+ return { ...empty, signedIn: true, lastActive };
1047
+ }
953
1048
  default:
954
1049
  return { ...empty, lastActive };
955
1050
  }
@@ -1051,6 +1146,8 @@ function getSessionDir(agentId, base) {
1051
1146
  // Copilot persists sessions at ~/.copilot/session-state/<id>/events.jsonl.
1052
1147
  // The events.jsonl is the canonical NDJSON event stream per session.
1053
1148
  return path.join(base, '.copilot', 'session-state');
1149
+ case 'droid':
1150
+ return path.join(base, '.factory', 'sessions');
1054
1151
  default:
1055
1152
  return null;
1056
1153
  }
@@ -1061,6 +1158,7 @@ function getSessionExtension(agentId) {
1061
1158
  case 'claude':
1062
1159
  case 'codex':
1063
1160
  case 'copilot':
1161
+ case 'droid':
1064
1162
  return '.jsonl';
1065
1163
  case 'gemini':
1066
1164
  return '.json';
@@ -1661,6 +1759,8 @@ export const AGENT_NAME_ALIASES = {
1661
1759
  gk: 'grok',
1662
1760
  kimi: 'kimi',
1663
1761
  'kimi-code': 'kimi',
1762
+ factory: 'droid',
1763
+ 'factory-ai': 'droid',
1664
1764
  };
1665
1765
  /**
1666
1766
  * Resolve a user-provided agent name (alias, shorthand, or canonical) to its AgentId.
@@ -8,6 +8,10 @@ import { writeProfileRuntime, clearProfileRuntime } from '../runtime-state.js';
8
8
  // so existing importers of `shellQuote` from this module keep working.
9
9
  import { shellQuote } from '../../ssh-exec.js';
10
10
  export { shellQuote };
11
+ // The `ssh -L` tunnel spawn is shared with `agents computer --host`; it lives in
12
+ // the single ssh-tunnel helper. Calling it with no options preserves this
13
+ // driver's original foreground, stderr-captured behavior exactly.
14
+ import { startSSHTunnel } from '../../ssh-tunnel.js';
11
15
  export async function connectSSH(endpoint, profile) {
12
16
  const url = new URL(endpoint);
13
17
  if (url.protocol !== 'ssh:') {
@@ -102,41 +106,6 @@ export async function connectSSH(endpoint, profile) {
102
106
  },
103
107
  };
104
108
  }
105
- function startSSHTunnel(user, host, localPort, remotePort) {
106
- return new Promise((resolve, reject) => {
107
- const args = [
108
- '-L',
109
- `${localPort}:127.0.0.1:${remotePort}`,
110
- `${user}@${host}`,
111
- '-N',
112
- '-o',
113
- 'StrictHostKeyChecking=accept-new',
114
- '-o',
115
- 'BatchMode=yes',
116
- '-o',
117
- 'ConnectTimeout=10',
118
- ];
119
- const tunnel = spawn('ssh', args, {
120
- stdio: ['ignore', 'ignore', 'pipe'],
121
- detached: false,
122
- });
123
- let stderr = '';
124
- tunnel.stderr?.on('data', (data) => {
125
- stderr += data.toString();
126
- });
127
- tunnel.on('error', (err) => {
128
- reject(new Error(`SSH tunnel failed: ${err.message}`));
129
- });
130
- setTimeout(() => {
131
- if (tunnel.killed) {
132
- reject(new Error(`SSH tunnel died: ${stderr}`));
133
- }
134
- else {
135
- resolve(tunnel);
136
- }
137
- }, 500);
138
- });
139
- }
140
109
  async function waitForPort(port, timeoutMs) {
141
110
  const start = Date.now();
142
111
  while (Date.now() - start < timeoutMs) {