@phnx-labs/agents-cli 1.20.32 → 1.20.34

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 (110) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +28 -2
  3. package/dist/commands/commands.js +3 -3
  4. package/dist/commands/computer-actions.js +1 -0
  5. package/dist/commands/computer.d.ts +23 -0
  6. package/dist/commands/computer.js +45 -3
  7. package/dist/commands/cost.js +2 -2
  8. package/dist/commands/doctor.d.ts +10 -0
  9. package/dist/commands/doctor.js +51 -2
  10. package/dist/commands/exec.js +56 -1
  11. package/dist/commands/hooks.js +3 -3
  12. package/dist/commands/import.js +1 -1
  13. package/dist/commands/inspect.js +13 -17
  14. package/dist/commands/mcp.js +3 -3
  15. package/dist/commands/permissions.js +3 -3
  16. package/dist/commands/rules.js +3 -3
  17. package/dist/commands/secrets-migrate.js +23 -11
  18. package/dist/commands/secrets.d.ts +20 -0
  19. package/dist/commands/secrets.js +53 -1
  20. package/dist/commands/sessions.js +18 -1
  21. package/dist/commands/skills.js +3 -3
  22. package/dist/commands/status.d.ts +12 -0
  23. package/dist/commands/status.js +81 -0
  24. package/dist/commands/sync.js +2 -2
  25. package/dist/commands/teams.js +77 -18
  26. package/dist/commands/usage.js +2 -2
  27. package/dist/commands/utils.d.ts +8 -0
  28. package/dist/commands/utils.js +20 -0
  29. package/dist/commands/versions.js +4 -3
  30. package/dist/commands/view.d.ts +39 -0
  31. package/dist/commands/view.js +217 -84
  32. package/dist/commands/workflows.js +3 -3
  33. package/dist/index.js +16 -2
  34. package/dist/lib/acp/harnesses.d.ts +1 -1
  35. package/dist/lib/acp/harnesses.js +2 -2
  36. package/dist/lib/agent-spec/index.d.ts +18 -0
  37. package/dist/lib/agent-spec/index.js +35 -0
  38. package/dist/lib/agent-spec/primitives.d.ts +28 -0
  39. package/dist/lib/agent-spec/primitives.js +57 -0
  40. package/dist/lib/agent-spec/provider.d.ts +2 -0
  41. package/dist/lib/agent-spec/provider.js +9 -0
  42. package/dist/lib/agent-spec/resolve.d.ts +33 -0
  43. package/dist/lib/agent-spec/resolve.js +174 -0
  44. package/dist/lib/agent-spec/types.d.ts +57 -0
  45. package/dist/lib/agent-spec/types.js +18 -0
  46. package/dist/lib/agents.d.ts +12 -0
  47. package/dist/lib/agents.js +115 -32
  48. package/dist/lib/browser/chrome.js +20 -0
  49. package/dist/lib/browser/drivers/ssh.d.ts +19 -0
  50. package/dist/lib/browser/drivers/ssh.js +18 -3
  51. package/dist/lib/crabbox/cli.d.ts +98 -0
  52. package/dist/lib/crabbox/cli.js +218 -0
  53. package/dist/lib/crabbox/lease.d.ts +41 -0
  54. package/dist/lib/crabbox/lease.js +73 -0
  55. package/dist/lib/crabbox/runtimes.d.ts +57 -0
  56. package/dist/lib/crabbox/runtimes.js +109 -0
  57. package/dist/lib/doctor-diff.js +29 -2
  58. package/dist/lib/drift-sync.d.ts +43 -0
  59. package/dist/lib/drift-sync.js +179 -0
  60. package/dist/lib/exec.d.ts +15 -0
  61. package/dist/lib/exec.js +21 -11
  62. package/dist/lib/hosts/dispatch.d.ts +27 -10
  63. package/dist/lib/hosts/dispatch.js +55 -19
  64. package/dist/lib/hosts/option.d.ts +14 -0
  65. package/dist/lib/hosts/option.js +19 -0
  66. package/dist/lib/hosts/passthrough.d.ts +30 -0
  67. package/dist/lib/hosts/passthrough.js +141 -0
  68. package/dist/lib/hosts/remote-cmd.d.ts +36 -0
  69. package/dist/lib/hosts/remote-cmd.js +56 -0
  70. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  71. package/dist/lib/platform/winpath.d.ts +31 -2
  72. package/dist/lib/platform/winpath.js +133 -24
  73. package/dist/lib/pwsh.d.ts +11 -0
  74. package/dist/lib/pwsh.js +13 -0
  75. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  76. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  77. package/dist/lib/secrets/agent.d.ts +42 -1
  78. package/dist/lib/secrets/agent.js +89 -11
  79. package/dist/lib/secrets/bundles.js +61 -21
  80. package/dist/lib/secrets/filestore.js +31 -1
  81. package/dist/lib/secrets/index.d.ts +44 -1
  82. package/dist/lib/secrets/index.js +108 -10
  83. package/dist/lib/secrets/linux.d.ts +14 -0
  84. package/dist/lib/secrets/linux.js +21 -0
  85. package/dist/lib/secrets/windows.d.ts +74 -0
  86. package/dist/lib/secrets/windows.js +440 -0
  87. package/dist/lib/session/active.d.ts +8 -0
  88. package/dist/lib/session/active.js +18 -1
  89. package/dist/lib/session/provenance.d.ts +56 -0
  90. package/dist/lib/session/provenance.js +157 -0
  91. package/dist/lib/shims.d.ts +20 -0
  92. package/dist/lib/shims.js +53 -20
  93. package/dist/lib/ssh-exec.d.ts +22 -0
  94. package/dist/lib/ssh-exec.js +59 -2
  95. package/dist/lib/ssh-tunnel.d.ts +0 -5
  96. package/dist/lib/ssh-tunnel.js +65 -8
  97. package/dist/lib/startup/command-registry.d.ts +1 -0
  98. package/dist/lib/startup/command-registry.js +2 -0
  99. package/dist/lib/sync-status.d.ts +102 -0
  100. package/dist/lib/sync-status.js +135 -0
  101. package/dist/lib/teams/agents.d.ts +24 -0
  102. package/dist/lib/teams/agents.js +30 -1
  103. package/dist/lib/types.d.ts +20 -1
  104. package/dist/lib/usage.d.ts +30 -0
  105. package/dist/lib/usage.js +159 -2
  106. package/dist/lib/versions.d.ts +2 -4
  107. package/dist/lib/versions.js +7 -20
  108. package/package.json +2 -1
  109. package/dist/lib/agent-spec.d.ts +0 -36
  110. package/dist/lib/agent-spec.js +0 -157
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Runtime detection + picker + credential-script builder for `agents run --lease`.
3
+ *
4
+ * The picker asks which coding-agent runtime(s) to provision on a leased box.
5
+ * The default selection is whatever the user is currently signed into locally
6
+ * (via `getAccountInfo`, the same source `agents view` uses). The chosen runtimes
7
+ * drive both what gets installed on the box and which auth token file is copied
8
+ * over — the token contents ride the uploaded `--script-stdin` body, never argv.
9
+ *
10
+ * SECURITY: copying a runtime's auth token to an ephemeral cloud box is a
11
+ * credential transfer. It is strictly opt-in (a confirm prompt in the command
12
+ * layer), the token never appears in argv/`ps`, and `--lease` one-shot runs tear
13
+ * the box down afterward so the credential's lifetime is bounded by the run.
14
+ */
15
+ import * as os from 'os';
16
+ import * as path from 'path';
17
+ import * as fs from 'fs';
18
+ import { getAccountInfo } from '../agents.js';
19
+ export const LEASE_RUNTIMES = [
20
+ { id: 'claude', label: 'Claude Code', localCandidates: ['.claude/.claude.json', '.claude.json'], remote: '.claude.json' },
21
+ { id: 'codex', label: 'Codex CLI', localCandidates: ['.codex/auth.json'], remote: '.codex/auth.json' },
22
+ { id: 'gemini', label: 'Gemini CLI', localCandidates: ['.gemini/google_accounts.json'], remote: '.gemini/google_accounts.json' },
23
+ { id: 'grok', label: 'Grok CLI', localCandidates: ['.grok/auth.json'], remote: '.grok/auth.json' },
24
+ ];
25
+ /** First existing candidate path under the real home, or null. */
26
+ function findLocalCred(cred) {
27
+ const home = process.env.AGENTS_REAL_HOME || os.homedir();
28
+ for (const rel of cred.localCandidates) {
29
+ const p = path.join(home, rel);
30
+ try {
31
+ if (fs.existsSync(p))
32
+ return p;
33
+ }
34
+ catch {
35
+ /* unreadable — skip */
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+ /** Which lease-capable runtimes the user is signed into on this machine. */
41
+ export async function detectSignedInRuntimes() {
42
+ const out = [];
43
+ for (const cred of LEASE_RUNTIMES) {
44
+ let info;
45
+ try {
46
+ info = await getAccountInfo(cred.id);
47
+ }
48
+ catch {
49
+ info = null;
50
+ }
51
+ out.push({
52
+ id: cred.id,
53
+ label: cred.label,
54
+ email: info?.email ?? null,
55
+ signedIn: !!info?.signedIn,
56
+ credPath: findLocalCred(cred),
57
+ });
58
+ }
59
+ return out;
60
+ }
61
+ /**
62
+ * Interactive checkbox: which runtimes to provision on the box. Defaults to the
63
+ * signed-in ones. Runtimes with no local credential are shown disabled.
64
+ * `prompt` is injected so tests don't require a TTY.
65
+ */
66
+ export async function pickRuntimes(detected, prompt) {
67
+ const choices = detected.map((d) => ({
68
+ name: `${d.label}${d.email ? ` (${d.email})` : d.signedIn ? ' (signed in)' : ''}`,
69
+ value: d.id,
70
+ checked: d.signedIn && !!d.credPath,
71
+ disabled: d.credPath ? false : 'no local credential — sign in first',
72
+ }));
73
+ if (prompt)
74
+ return prompt(choices);
75
+ const { checkbox } = await import('@inquirer/prompts');
76
+ return checkbox({ message: 'Provision which runtime(s) on the leased box?', choices });
77
+ }
78
+ // A long random sentinel makes an accidental (or malicious) collision with a
79
+ // token's contents effectively impossible, so the quoted heredoc can never be
80
+ // closed early by the credential body.
81
+ const CRED_EOF = 'AGENTS_LEASE_CRED_EOF_9f3c1a7b5e2d4068';
82
+ /**
83
+ * Build a bash snippet that writes each picked runtime's token file to the box's
84
+ * home-level config path (0600), from the token contents read locally. Returns
85
+ * `''` when no runtimes were selected. The snippet is meant to be embedded in
86
+ * the `--script-stdin` body (never argv).
87
+ */
88
+ export function buildCredentialScript(picked, detected) {
89
+ const byId = new Map(detected.map((d) => [d.id, d]));
90
+ const parts = [];
91
+ for (const id of picked) {
92
+ const d = byId.get(id);
93
+ const cred = LEASE_RUNTIMES.find((c) => c.id === id);
94
+ if (!d?.credPath || !cred)
95
+ continue;
96
+ let contents;
97
+ try {
98
+ contents = fs.readFileSync(d.credPath, 'utf-8');
99
+ }
100
+ catch {
101
+ continue;
102
+ }
103
+ const dir = path.posix.dirname(cred.remote);
104
+ const mkdir = dir && dir !== '.' ? `mkdir -p "$HOME/${dir}"\n` : '';
105
+ parts.push(`${mkdir}cat > "$HOME/${cred.remote}" <<'${CRED_EOF}'\n${contents}${contents.endsWith('\n') ? '' : '\n'}${CRED_EOF}\n` +
106
+ `chmod 600 "$HOME/${cred.remote}"`);
107
+ }
108
+ return parts.join('\n');
109
+ }
@@ -29,6 +29,8 @@ import { pluginInstallDir, repairableManifestFields } from './plugin-marketplace
29
29
  import { markdownToToml } from './convert.js';
30
30
  import { resolveImports, supportsRulesImports } from './rules/compile.js';
31
31
  import { listCommandsInVersionHome, getVersionCommandsDir } from './commands.js';
32
+ import { shouldInstallCommandAsSkill, commandSkillMatches, commandSkillName } from './command-skills.js';
33
+ import { supports } from './capabilities.js';
32
34
  import { listSkillsInVersionHome, getVersionSkillsDir } from './skills.js';
33
35
  import { listHooksInVersionHome, listHookEntriesFromDir } from './hooks.js';
34
36
  const RULES_DOC_FILENAME = 'README.md';
@@ -87,6 +89,15 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
87
89
  const isToml = agentConfig.format === 'toml';
88
90
  const ext = isToml ? '.toml' : '.md';
89
91
  const homeDir = getVersionCommandsDir(agent, version);
92
+ // Command-as-skill agents (kimi, codex>=0.117, grok) install every command as a
93
+ // SKILL wrapper at <agentDir>/skills/<cmd>/SKILL.md, not a native command file.
94
+ // The compare must follow that or every command false-reports as drifted.
95
+ const asSkill = shouldInstallCommandAsSkill(agent, version);
96
+ // Agents that hold commands neither natively nor as skills (e.g. goose) must not
97
+ // report source commands as "missing" — they structurally can't take them.
98
+ if (!asSkill && !supports(agent, 'commands', version).ok)
99
+ return [];
100
+ const agentDir = path.join(getVersionHomePath(agent, version), agentConfigDirName(agent));
90
101
  const installed = new Set(listCommandsInVersionHome(agent, version));
91
102
  const layerBases = buildLayerBases(cwd, 'commands', { excludeProject });
92
103
  const sourceByName = new Map();
@@ -113,11 +124,24 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
113
124
  const seen = new Set();
114
125
  for (const [name, src] of sourceByName) {
115
126
  seen.add(name);
116
- const homePath = path.join(homeDir, `${name}${ext}`);
117
127
  if (!installed.has(name)) {
118
128
  rows.push({ kind: 'commands', name, status: 'missing', source: src.layer, sourcePath: src.path });
119
129
  continue;
120
130
  }
131
+ if (asSkill) {
132
+ // Compare against the installed command-skill wrapper, not a native path.
133
+ const matches = commandSkillMatches(agentDir, name, src.path);
134
+ rows.push({
135
+ kind: 'commands',
136
+ name,
137
+ status: matches ? 'ok' : 'diff',
138
+ source: src.layer,
139
+ sourcePath: src.path,
140
+ homePath: path.join(agentDir, 'skills', commandSkillName(name), 'SKILL.md'),
141
+ });
142
+ continue;
143
+ }
144
+ const homePath = path.join(homeDir, `${name}${ext}`);
121
145
  const installedContent = readSafe(homePath);
122
146
  const sourceContent = readSafe(src.path);
123
147
  if (installedContent == null || sourceContent == null) {
@@ -138,7 +162,10 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
138
162
  for (const name of installed) {
139
163
  if (seen.has(name))
140
164
  continue;
141
- rows.push({ kind: 'commands', name, status: 'extra', homePath: path.join(homeDir, `${name}${ext}`) });
165
+ const extraHome = asSkill
166
+ ? path.join(agentDir, 'skills', commandSkillName(name), 'SKILL.md')
167
+ : path.join(homeDir, `${name}${ext}`);
168
+ rows.push({ kind: 'commands', name, status: 'extra', homePath: extraHome });
142
169
  }
143
170
  return rows.sort((a, b) => a.name.localeCompare(b.name));
144
171
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Interactive drift-sync flow — the single "we detected drift, want to fix it?"
3
+ * action, shared by `agents status`, `agents doctor`, and the menu-bar "NEEDS
4
+ * SYNC" row.
5
+ *
6
+ * It composes existing pieces, re-implementing nothing:
7
+ * - computeSyncStatus() — the unified detection engine (sync-status.ts)
8
+ * - pullRepo() — fast-forward the `.system` repo (git.ts)
9
+ * - promptAgentVersionSelection() — the "which agent types / versions?" picker
10
+ * - heal({ mode: 'full' }) — the reconcile engine `doctor --fix` uses
11
+ *
12
+ * Combined flow (one confirmation): if `.system` is behind AND resources drifted,
13
+ * a single "Sync all detected" both pulls `.system` and reconciles the chosen
14
+ * version homes. The security posture is preserved — the `.system` pull only ever
15
+ * happens on an explicit user choice here, never silently (see auto-pull-worker.ts
16
+ * for why system auto-pull is off by default).
17
+ */
18
+ import { type VersionHealResult } from './heal.js';
19
+ import { type UnifiedSyncStatus } from './sync-status.js';
20
+ export interface DriftSyncOptions {
21
+ cwd?: string;
22
+ /** Reconcile everything detected with no prompts — the "kick it" path, also the
23
+ * non-TTY / menu-bar-launched-with-flag behavior. Pulls `.system` if behind. */
24
+ yes?: boolean;
25
+ /** Pre-computed status, to avoid a second scan when the caller already has one. */
26
+ status?: UnifiedSyncStatus;
27
+ /** Skip the drift summary — set by callers that already printed their own. */
28
+ quiet?: boolean;
29
+ }
30
+ export interface DriftSyncResult {
31
+ systemBehindBefore: number;
32
+ systemPulled: boolean;
33
+ healed: VersionHealResult[];
34
+ /** True when the user declined at the prompt (nothing was changed). */
35
+ cancelled: boolean;
36
+ /** True when there was no drift to act on in the first place. */
37
+ nothingToDo: boolean;
38
+ }
39
+ /**
40
+ * The unified "drift detected — sync now?" flow. Returns a structured result so
41
+ * callers (menu-bar, doctor) can report without re-scanning.
42
+ */
43
+ export declare function promptDriftSync(opts?: DriftSyncOptions): Promise<DriftSyncResult>;
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Interactive drift-sync flow — the single "we detected drift, want to fix it?"
3
+ * action, shared by `agents status`, `agents doctor`, and the menu-bar "NEEDS
4
+ * SYNC" row.
5
+ *
6
+ * It composes existing pieces, re-implementing nothing:
7
+ * - computeSyncStatus() — the unified detection engine (sync-status.ts)
8
+ * - pullRepo() — fast-forward the `.system` repo (git.ts)
9
+ * - promptAgentVersionSelection() — the "which agent types / versions?" picker
10
+ * - heal({ mode: 'full' }) — the reconcile engine `doctor --fix` uses
11
+ *
12
+ * Combined flow (one confirmation): if `.system` is behind AND resources drifted,
13
+ * a single "Sync all detected" both pulls `.system` and reconciles the chosen
14
+ * version homes. The security posture is preserved — the `.system` pull only ever
15
+ * happens on an explicit user choice here, never silently (see auto-pull-worker.ts
16
+ * for why system auto-pull is off by default).
17
+ */
18
+ import chalk from 'chalk';
19
+ import { select, confirm } from '@inquirer/prompts';
20
+ import { AGENTS } from './agents.js';
21
+ import { pullRepo } from './git.js';
22
+ import { heal } from './heal.js';
23
+ import { promptAgentVersionSelection } from './versions.js';
24
+ import { isInteractiveTerminal, isPromptCancelled } from '../commands/utils.js';
25
+ import { computeSyncStatus, } from './sync-status.js';
26
+ const agentName = (id) => AGENTS[id]?.name ?? id;
27
+ /** "claude@2.1.170 2 drifted · 1 missing" for one version. */
28
+ function versionLine(v) {
29
+ const bits = [];
30
+ if (v.counts.drifted)
31
+ bits.push(`${v.counts.drifted} drifted`);
32
+ if (v.counts.missing)
33
+ bits.push(`${v.counts.missing} missing`);
34
+ const label = `${agentName(v.agent)}@${v.version}`;
35
+ return ` ${label.padEnd(28)} ${chalk.yellow(bits.join(' · '))}`;
36
+ }
37
+ /** Render the drift summary (system freshness + each version owed a sync). */
38
+ function renderSummary(status, needing) {
39
+ console.log(chalk.bold('\nSync status'));
40
+ if (status.system.behind > 0) {
41
+ console.log(` ${'.system repo'.padEnd(28)} ${chalk.yellow(`${status.system.behind} commit${status.system.behind === 1 ? '' : 's'} behind`)} ${chalk.gray('— pull recommended')}`);
42
+ }
43
+ for (const v of needing)
44
+ console.log(versionLine(v));
45
+ if (status.totals.orphan > 0) {
46
+ console.log(chalk.gray(` (${status.totals.orphan} orphan${status.totals.orphan === 1 ? '' : 's'} — run \`agents prune cleanup\`)`));
47
+ }
48
+ }
49
+ /** Fast-forward the `.system` repo. Returns whether it actually moved. */
50
+ async function pullSystem(status) {
51
+ if (status.system.behind <= 0)
52
+ return false;
53
+ const res = await pullRepo(status.system.dir);
54
+ if (res.success) {
55
+ console.log(chalk.green(`Pulled .system (+${status.system.behind}).`));
56
+ return true;
57
+ }
58
+ console.log(chalk.red(`Could not pull .system: ${res.error ?? 'unknown error'}`));
59
+ return false;
60
+ }
61
+ /** Reconcile a set of versions grouped by agent via the shared heal engine. */
62
+ async function healVersions(versionsByAgent, cwd) {
63
+ const out = [];
64
+ for (const [agent, versions] of versionsByAgent) {
65
+ if (versions.length === 0)
66
+ continue;
67
+ const res = await heal({ mode: 'full', cwd, agent, versions });
68
+ out.push(...res.versions);
69
+ }
70
+ return out;
71
+ }
72
+ /** Report which agents received what after a heal. */
73
+ function reportHealed(healed) {
74
+ const touched = healed.filter((v) => v.healed.length > 0);
75
+ if (touched.length === 0) {
76
+ console.log(chalk.gray('Nothing to reconcile — homes already matched sources.'));
77
+ return;
78
+ }
79
+ const total = touched.reduce((n, v) => n + v.healed.length, 0);
80
+ const agents = [...new Set(touched.map((v) => agentName(v.agent)))].join(', ');
81
+ console.log(chalk.green(`Synced ${total} resource${total === 1 ? '' : 's'} to ${agents}.`));
82
+ }
83
+ function groupNeeding(needing) {
84
+ const m = new Map();
85
+ for (const v of needing) {
86
+ const list = m.get(v.agent) ?? [];
87
+ list.push(v.version);
88
+ m.set(v.agent, list);
89
+ }
90
+ return m;
91
+ }
92
+ /**
93
+ * The unified "drift detected — sync now?" flow. Returns a structured result so
94
+ * callers (menu-bar, doctor) can report without re-scanning.
95
+ */
96
+ export async function promptDriftSync(opts = {}) {
97
+ const cwd = opts.cwd ?? process.cwd();
98
+ const status = opts.status ?? (await computeSyncStatus({ cwd }));
99
+ const needing = status.agents.filter((a) => a.needsSync);
100
+ const systemBehind = status.system.behind;
101
+ const base = {
102
+ systemBehindBefore: systemBehind,
103
+ systemPulled: false,
104
+ healed: [],
105
+ cancelled: false,
106
+ nothingToDo: false,
107
+ };
108
+ if (systemBehind <= 0 && needing.length === 0) {
109
+ console.log(chalk.green('Everything is in sync.'));
110
+ return { ...base, nothingToDo: true };
111
+ }
112
+ if (!opts.quiet)
113
+ renderSummary(status, needing);
114
+ // Non-interactive OR explicit --yes: reconcile everything detected.
115
+ if (opts.yes || !isInteractiveTerminal()) {
116
+ if (!opts.yes) {
117
+ // Non-TTY without --yes: report, don't act, don't throw.
118
+ console.log(chalk.gray('\nRun `agents status --yes` to sync, or `agents status` in a terminal to choose.'));
119
+ return base;
120
+ }
121
+ const systemPulled = await pullSystem(status);
122
+ const healed = await healVersions(groupNeeding(needing), cwd);
123
+ reportHealed(healed);
124
+ return { ...base, systemPulled, healed };
125
+ }
126
+ // Interactive gate.
127
+ let choice;
128
+ try {
129
+ choice = await select({
130
+ message: 'Sync now?',
131
+ choices: [
132
+ { name: 'Sync all detected', value: 'all' },
133
+ { name: 'Choose agents & resources', value: 'choose' },
134
+ { name: 'No', value: 'no' },
135
+ ],
136
+ default: 'all',
137
+ });
138
+ }
139
+ catch (err) {
140
+ if (isPromptCancelled(err))
141
+ return { ...base, cancelled: true };
142
+ throw err;
143
+ }
144
+ if (choice === 'no')
145
+ return { ...base, cancelled: true };
146
+ if (choice === 'all') {
147
+ const systemPulled = await pullSystem(status);
148
+ const healed = await healVersions(groupNeeding(needing), cwd);
149
+ reportHealed(healed);
150
+ return { ...base, systemPulled, healed };
151
+ }
152
+ // choice === 'choose': optional .system pull, then per-agent/version selection.
153
+ let systemPulled = false;
154
+ if (systemBehind > 0) {
155
+ try {
156
+ const pull = await confirm({ message: `Pull .system (${systemBehind} behind) first?`, default: true });
157
+ if (pull)
158
+ systemPulled = await pullSystem(status);
159
+ }
160
+ catch (err) {
161
+ if (!isPromptCancelled(err))
162
+ throw err;
163
+ return { ...base, systemPulled, cancelled: true };
164
+ }
165
+ }
166
+ const needingAgents = [...new Set(needing.map((a) => a.agent))];
167
+ let selection;
168
+ try {
169
+ selection = await promptAgentVersionSelection(needingAgents);
170
+ }
171
+ catch (err) {
172
+ if (isPromptCancelled(err))
173
+ return { ...base, systemPulled, cancelled: true };
174
+ throw err;
175
+ }
176
+ const healed = await healVersions(selection.versionSelections, cwd);
177
+ reportHealed(healed);
178
+ return { ...base, systemPulled, healed };
179
+ }
@@ -189,6 +189,21 @@ export declare function nativeResume(agent: AgentId): boolean;
189
189
  export declare function buildExecCommand(options: ExecOptions): string[];
190
190
  /** Spawn an agent and return its exit code. Convenience wrapper over spawnAgent. */
191
191
  export declare function execAgent(options: ExecOptions): Promise<number>;
192
+ /**
193
+ * Resolve how to spawn a shim target for a platform. Pure — testable on any host.
194
+ *
195
+ * POSIX always execs the binary directly (no shell). On Windows a bare
196
+ * (non-absolute) name or a `.cmd` companion goes through the shell so cmd.exe
197
+ * resolves it via PATHEXT — the common, `.cmd`-present path; an absolute `.cmd`
198
+ * or extensionless path is exec'd through the shell / directly. npm always ships
199
+ * a `<cmd>.cmd` companion on Windows, so the runnable target `execShimPassthrough`
200
+ * hands us is the `.cmd` (never a bare `.ps1`).
201
+ */
202
+ export declare function resolveShimSpawn(platform: NodeJS.Platform, binary: string, extraArgs: string[]): {
203
+ command: string;
204
+ args: string[];
205
+ shell: boolean;
206
+ };
192
207
  /**
193
208
  * Transparent passthrough exec for generated shims — the node-side delegate that
194
209
  * Windows `.cmd` shims call. Resolves the active version (explicit pin, else
package/dist/lib/exec.js CHANGED
@@ -374,15 +374,6 @@ export const AGENT_COMMANDS = {
374
374
  edit: [],
375
375
  },
376
376
  },
377
- roo: {
378
- base: ['roo'],
379
- promptFlag: 'positional',
380
- modeFlags: {
381
- plan: ['--mode', 'architect'],
382
- edit: ['--mode', 'code'],
383
- },
384
- modelFlag: '--model',
385
- },
386
377
  // TODO: --output-format json is documented but currently broken upstream
387
378
  // ("flags provided but not defined: -output-format"). Track resolution at
388
379
  // https://github.com/google-antigravity/antigravity-cli/issues/7 before
@@ -665,6 +656,25 @@ export async function execAgent(options) {
665
656
  const { exitCode } = await spawnAgent(options);
666
657
  return exitCode;
667
658
  }
659
+ /**
660
+ * Resolve how to spawn a shim target for a platform. Pure — testable on any host.
661
+ *
662
+ * POSIX always execs the binary directly (no shell). On Windows a bare
663
+ * (non-absolute) name or a `.cmd` companion goes through the shell so cmd.exe
664
+ * resolves it via PATHEXT — the common, `.cmd`-present path; an absolute `.cmd`
665
+ * or extensionless path is exec'd through the shell / directly. npm always ships
666
+ * a `<cmd>.cmd` companion on Windows, so the runnable target `execShimPassthrough`
667
+ * hands us is the `.cmd` (never a bare `.ps1`).
668
+ */
669
+ export function resolveShimSpawn(platform, binary, extraArgs) {
670
+ if (platform === 'win32') {
671
+ // Use win32 path semantics regardless of the host running this (the platform
672
+ // is the parameter, not process.platform) so `C:\...` reads as absolute.
673
+ const useShell = !path.win32.isAbsolute(binary) || binary.endsWith('.cmd');
674
+ return { command: binary, args: extraArgs, shell: useShell };
675
+ }
676
+ return { command: binary, args: extraArgs, shell: false };
677
+ }
668
678
  /**
669
679
  * Transparent passthrough exec for generated shims — the node-side delegate that
670
680
  * Windows `.cmd` shims call. Resolves the active version (explicit pin, else
@@ -691,9 +701,9 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
691
701
  // mode/effort are required by ExecOptions but unused by buildExecEnv (which only
692
702
  // derives the per-version config-dir env); pass the agent's default to satisfy the type.
693
703
  const env = buildExecEnv({ agent, version, cwd, mode: defaultModeFor(agent), effort: 'auto' });
694
- const useShell = process.platform === 'win32' && (!path.isAbsolute(binary) || binary.endsWith('.cmd'));
704
+ const { command, args, shell } = resolveShimSpawn(process.platform, binary, [...launchArgs, ...rawArgs]);
695
705
  return new Promise((resolve) => {
696
- const child = spawn(binary, [...launchArgs, ...rawArgs], { cwd, stdio: 'inherit', env, shell: useShell });
706
+ const child = spawn(command, args, { cwd, stdio: 'inherit', env, shell });
697
707
  child.on('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
698
708
  child.on('error', (err) => {
699
709
  process.stderr.write(`agents: failed to launch ${agent}: ${err.message}\n`);
@@ -1,13 +1,20 @@
1
1
  /**
2
- * Dispatch a headless agent run onto a host over SSH.
2
+ * Dispatch a headless `agents …` command onto a host over SSH.
3
3
  *
4
- * The run is launched detached (`nohup … &`) writing combined output to a remote
5
- * log and its exit code to a sibling `.exit` file, so progress survives a dropped
6
- * connection (followed via offset-tail in progress.ts). This is the offload win:
7
- * the agent's process/thread/file fan-out happens on the host, not the laptop.
4
+ * The command is launched detached (`nohup … &`) writing combined output to a
5
+ * remote log and its exit code to a sibling `.exit` file, so progress survives a
6
+ * dropped connection (followed via offset-tail in progress.ts). This is the
7
+ * offload win: the process/thread/file fan-out happens on the host, not the
8
+ * laptop. `agents run` uses it; `agents teams start --watch --host` reuses the
9
+ * same core so a remote team supervisor keeps running after you disconnect.
8
10
  */
9
11
  import type { Host } from './types.js';
10
12
  import { type HostTask } from './tasks.js';
13
+ export interface DispatchResult {
14
+ task: HostTask;
15
+ /** Exit code when followed; undefined when detached (--no-follow). */
16
+ exitCode?: number;
17
+ }
11
18
  export interface DispatchOptions {
12
19
  agent: string;
13
20
  prompt: string;
@@ -18,9 +25,19 @@ export interface DispatchOptions {
18
25
  follow?: boolean;
19
26
  timeoutMs?: number;
20
27
  }
21
- export interface DispatchResult {
22
- task: HostTask;
23
- /** Exit code when followed; undefined when detached (--no-follow). */
24
- exitCode?: number;
25
- }
28
+ /** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
26
29
  export declare function dispatchToHost(host: Host, opts: DispatchOptions): Promise<DispatchResult>;
30
+ export interface CommandDispatchOptions {
31
+ /** `agents …` args (command name first), already stripped of routing flags. */
32
+ forwardedArgs: string[];
33
+ remoteCwd?: string;
34
+ follow?: boolean;
35
+ timeoutMs?: number;
36
+ }
37
+ /**
38
+ * Dispatch an arbitrary long-running `agents <command>` onto a host detached —
39
+ * used for `teams start --watch --host`, whose supervisor must outlive the SSH
40
+ * connection. Reachability is assumed (the caller has already resolved the host);
41
+ * a launch failure surfaces the remote stderr.
42
+ */
43
+ export declare function dispatchAgentsCommand(host: Host, opts: CommandDispatchOptions): Promise<DispatchResult>;
@@ -1,10 +1,12 @@
1
1
  /**
2
- * Dispatch a headless agent run onto a host over SSH.
2
+ * Dispatch a headless `agents …` command onto a host over SSH.
3
3
  *
4
- * The run is launched detached (`nohup … &`) writing combined output to a remote
5
- * log and its exit code to a sibling `.exit` file, so progress survives a dropped
6
- * connection (followed via offset-tail in progress.ts). This is the offload win:
7
- * the agent's process/thread/file fan-out happens on the host, not the laptop.
4
+ * The command is launched detached (`nohup … &`) writing combined output to a
5
+ * remote log and its exit code to a sibling `.exit` file, so progress survives a
6
+ * dropped connection (followed via offset-tail in progress.ts). This is the
7
+ * offload win: the process/thread/file fan-out happens on the host, not the
8
+ * laptop. `agents run` uses it; `agents teams start --watch --host` reuses the
9
+ * same core so a remote team supervisor keeps running after you disconnect.
8
10
  */
9
11
  import { randomUUID } from 'crypto';
10
12
  import { sshExec, shellQuote } from '../ssh-exec.js';
@@ -16,25 +18,22 @@ import { followHostTask } from './progress.js';
16
18
  // regardless of the run's cwd. Task ids are 8 hex chars, so these paths are
17
19
  // injection-safe to interpolate unquoted into remote commands.
18
20
  const REMOTE_DIR = '$HOME/.agents/.cache/hosts';
19
- export async function dispatchToHost(host, opts) {
20
- const target = sshTargetFor(host);
21
- const { warnings } = ensureHostReady(host, { agent: opts.agent });
22
- for (const w of warnings)
23
- process.stderr.write(`[hosts] warning: ${w}\n`);
21
+ /**
22
+ * The launch + task-record + optional follow core. Both `dispatchToHost` (run)
23
+ * and `dispatchAgentsCommand` (teams) build their `forwardedArgs` and call here,
24
+ * so the nohup/exit-file/offset-tail machinery lives in exactly one place.
25
+ */
26
+ async function launchDetached(host, target, opts) {
24
27
  const id = randomUUID().slice(0, 8);
25
28
  const remoteLog = `${REMOTE_DIR}/${id}.log`;
26
29
  const remoteExit = `${REMOTE_DIR}/${id}.exit`;
27
30
  // Inner command run under a login shell so PATH resolves `agents`.
28
- const runParts = ['agents', 'run', shellQuote(opts.agent), shellQuote(opts.prompt), '--quiet'];
29
- if (opts.mode)
30
- runParts.push('--mode', shellQuote(opts.mode));
31
- if (opts.model)
32
- runParts.push('--model', shellQuote(opts.model));
31
+ const invocation = ['agents', ...opts.forwardedArgs].map(shellQuote).join(' ');
33
32
  const cwd = opts.remoteCwd ? `cd ${shellQuote(opts.remoteCwd)} && ` : '';
34
- const inner = `${cwd}${runParts.join(' ')} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
33
+ const inner = `${cwd}${invocation} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
35
34
  // Outer: ensure dir, launch detached under bash -lc, print the PID.
36
35
  const launch = `mkdir -p ${REMOTE_DIR}; nohup bash -lc ${shellQuote(inner)} >/dev/null 2>&1 & echo $!`;
37
- const res = sshExec(target, launch, { timeoutMs: 30000 });
36
+ const res = sshExec(target, launch, { timeoutMs: 30000, multiplex: true });
38
37
  if (res.code !== 0) {
39
38
  throw new Error(`Failed to launch on "${host.name}": ${(res.stderr || res.stdout).trim() || 'ssh error'}`);
40
39
  }
@@ -43,8 +42,8 @@ export async function dispatchToHost(host, opts) {
43
42
  id,
44
43
  host: host.name,
45
44
  target,
46
- agent: opts.agent,
47
- prompt: opts.prompt,
45
+ agent: opts.agentLabel,
46
+ prompt: opts.promptLabel,
48
47
  pid: Number.isFinite(pid) ? pid : undefined,
49
48
  remoteLog,
50
49
  remoteExit,
@@ -69,3 +68,40 @@ export async function dispatchToHost(host, opts) {
69
68
  });
70
69
  return { task: finished ?? task, exitCode };
71
70
  }
71
+ /** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
72
+ export async function dispatchToHost(host, opts) {
73
+ const target = sshTargetFor(host);
74
+ const { warnings } = ensureHostReady(host, { agent: opts.agent });
75
+ for (const w of warnings)
76
+ process.stderr.write(`[hosts] warning: ${w}\n`);
77
+ const forwardedArgs = ['run', opts.agent, opts.prompt, '--quiet'];
78
+ if (opts.mode)
79
+ forwardedArgs.push('--mode', opts.mode);
80
+ if (opts.model)
81
+ forwardedArgs.push('--model', opts.model);
82
+ return launchDetached(host, target, {
83
+ forwardedArgs,
84
+ remoteCwd: opts.remoteCwd,
85
+ follow: opts.follow,
86
+ timeoutMs: opts.timeoutMs,
87
+ agentLabel: opts.agent,
88
+ promptLabel: opts.prompt,
89
+ });
90
+ }
91
+ /**
92
+ * Dispatch an arbitrary long-running `agents <command>` onto a host detached —
93
+ * used for `teams start --watch --host`, whose supervisor must outlive the SSH
94
+ * connection. Reachability is assumed (the caller has already resolved the host);
95
+ * a launch failure surfaces the remote stderr.
96
+ */
97
+ export async function dispatchAgentsCommand(host, opts) {
98
+ const target = sshTargetFor(host);
99
+ return launchDetached(host, target, {
100
+ forwardedArgs: opts.forwardedArgs,
101
+ remoteCwd: opts.remoteCwd,
102
+ follow: opts.follow,
103
+ timeoutMs: opts.timeoutMs,
104
+ agentLabel: opts.forwardedArgs[0] ?? 'agents',
105
+ promptLabel: opts.forwardedArgs.join(' '),
106
+ });
107
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Shared `--host` option registrar. Every command that can run on a remote host
3
+ * declares the flag through here, so its spelling, help text, and companions
4
+ * (`--remote-cwd`, `--no-tty`, `--any`) stay identical everywhere and show up in
5
+ * each command's `--help`.
6
+ *
7
+ * The flags are consumed centrally by `maybeRunOnHost` (passthrough.ts) *before*
8
+ * commander parses, so for a real remote run the local action never sees them.
9
+ * Registering them here still matters: it documents the flag and keeps the local
10
+ * fall-through (e.g. `--host <this-machine>`) from erroring on an unknown option.
11
+ */
12
+ import type { Command } from 'commander';
13
+ /** Attach the standard `--host` flag family to a command and return it (chainable). */
14
+ export declare function addHostOption(cmd: Command): Command;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Shared `--host` option registrar. Every command that can run on a remote host
3
+ * declares the flag through here, so its spelling, help text, and companions
4
+ * (`--remote-cwd`, `--no-tty`, `--any`) stay identical everywhere and show up in
5
+ * each command's `--help`.
6
+ *
7
+ * The flags are consumed centrally by `maybeRunOnHost` (passthrough.ts) *before*
8
+ * commander parses, so for a real remote run the local action never sees them.
9
+ * Registering them here still matters: it documents the flag and keeps the local
10
+ * fall-through (e.g. `--host <this-machine>`) from erroring on an unknown option.
11
+ */
12
+ /** Attach the standard `--host` flag family to a command and return it (chainable). */
13
+ export function addHostOption(cmd) {
14
+ return cmd
15
+ .option('-H, --host <name>', 'Run this command on a registered host (or user@host) over SSH instead of locally. See `agents hosts`.')
16
+ .option('--remote-cwd <dir>', 'Working directory on the host for --host runs.')
17
+ .option('--no-tty', 'Force non-interactive output for --host runs even from a terminal.')
18
+ .option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match.');
19
+ }