@phnx-labs/agents-cli 1.20.12 → 1.20.14

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 (67) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +3 -0
  3. package/dist/commands/computer-actions.d.ts +3 -0
  4. package/dist/commands/computer-actions.js +16 -0
  5. package/dist/commands/doctor.js +51 -7
  6. package/dist/commands/exec.js +25 -4
  7. package/dist/commands/import.js +17 -6
  8. package/dist/commands/inspect.d.ts +28 -1
  9. package/dist/commands/inspect.js +330 -47
  10. package/dist/commands/mcp.js +3 -3
  11. package/dist/commands/plugins.d.ts +2 -0
  12. package/dist/commands/plugins.js +69 -26
  13. package/dist/commands/prune.js +8 -5
  14. package/dist/commands/sync.js +1 -1
  15. package/dist/commands/teams.js +1 -0
  16. package/dist/commands/trash.d.ts +11 -0
  17. package/dist/commands/trash.js +57 -41
  18. package/dist/commands/versions.js +68 -20
  19. package/dist/commands/view.d.ts +1 -0
  20. package/dist/commands/view.js +56 -12
  21. package/dist/commands/wallet.d.ts +14 -0
  22. package/dist/commands/wallet.js +199 -0
  23. package/dist/index.js +4 -1
  24. package/dist/lib/agents.js +70 -22
  25. package/dist/lib/browser/ipc.d.ts +7 -0
  26. package/dist/lib/browser/ipc.js +43 -27
  27. package/dist/lib/capabilities.js +7 -1
  28. package/dist/lib/command-skills.d.ts +1 -0
  29. package/dist/lib/command-skills.js +23 -7
  30. package/dist/lib/exec.d.ts +32 -1
  31. package/dist/lib/exec.js +79 -7
  32. package/dist/lib/hooks.d.ts +21 -1
  33. package/dist/lib/hooks.js +69 -7
  34. package/dist/lib/mcp.js +33 -0
  35. package/dist/lib/models.js +5 -0
  36. package/dist/lib/picker.d.ts +2 -0
  37. package/dist/lib/picker.js +96 -6
  38. package/dist/lib/platform/index.d.ts +1 -0
  39. package/dist/lib/platform/index.js +1 -0
  40. package/dist/lib/platform/winpath.d.ts +35 -0
  41. package/dist/lib/platform/winpath.js +86 -0
  42. package/dist/lib/plugins.d.ts +24 -0
  43. package/dist/lib/plugins.js +37 -2
  44. package/dist/lib/project-launch.js +110 -5
  45. package/dist/lib/registry.js +15 -2
  46. package/dist/lib/rotate.d.ts +7 -0
  47. package/dist/lib/rotate.js +17 -7
  48. package/dist/lib/runner.js +14 -0
  49. package/dist/lib/sandbox.js +5 -2
  50. package/dist/lib/settings-manifest.d.ts +39 -0
  51. package/dist/lib/settings-manifest.js +163 -0
  52. package/dist/lib/shims.d.ts +1 -1
  53. package/dist/lib/shims.js +16 -31
  54. package/dist/lib/staleness/detectors/subagents.js +16 -0
  55. package/dist/lib/staleness/writers/subagents.js +11 -3
  56. package/dist/lib/subagents.d.ts +9 -0
  57. package/dist/lib/subagents.js +33 -0
  58. package/dist/lib/teams/agents.js +1 -1
  59. package/dist/lib/teams/parsers.d.ts +1 -1
  60. package/dist/lib/teams/parsers.js +6 -0
  61. package/dist/lib/types.d.ts +1 -1
  62. package/dist/lib/versions.d.ts +15 -3
  63. package/dist/lib/versions.js +88 -19
  64. package/dist/lib/wallet/index.d.ts +78 -0
  65. package/dist/lib/wallet/index.js +253 -0
  66. package/package.json +3 -3
  67. package/scripts/postinstall.js +35 -7
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `agents wallet` — a device-local credit-card vault backed by macOS Keychain.
3
+ *
4
+ * UX intent matches Apple Wallet: list cards freely (no biometric), reveal
5
+ * a card with Touch ID. Card numbers never leave the device. This is NOT
6
+ * Apple Pay — we store the real PAN, not a network DPAN, and do not generate
7
+ * per-transaction cryptograms. The help text reflects this.
8
+ *
9
+ * Bundle layout:
10
+ * ~/.agents/wallet/cards.json metadata (id, last4, brand, exp)
11
+ * agents-cli.secrets.wallet.<id> JSON {pan, cvc, cardholder}
12
+ */
13
+ import type { Command } from 'commander';
14
+ export declare function registerWalletCommands(program: Command): void;
@@ -0,0 +1,199 @@
1
+ /**
2
+ * `agents wallet` — a device-local credit-card vault backed by macOS Keychain.
3
+ *
4
+ * UX intent matches Apple Wallet: list cards freely (no biometric), reveal
5
+ * a card with Touch ID. Card numbers never leave the device. This is NOT
6
+ * Apple Pay — we store the real PAN, not a network DPAN, and do not generate
7
+ * per-transaction cryptograms. The help text reflects this.
8
+ *
9
+ * Bundle layout:
10
+ * ~/.agents/wallet/cards.json metadata (id, last4, brand, exp)
11
+ * agents-cli.secrets.wallet.<id> JSON {pan, cvc, cardholder}
12
+ */
13
+ import chalk from 'chalk';
14
+ import { addCard, detectBrand, isValidLuhn, listCards, removeCard, renameCard, showCard, } from '../lib/wallet/index.js';
15
+ import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
16
+ import { setHelpSections } from '../lib/help.js';
17
+ function brandLabel(b) {
18
+ switch (b) {
19
+ case 'visa': return 'Visa';
20
+ case 'mastercard': return 'Mastercard';
21
+ case 'amex': return 'American Express';
22
+ case 'discover': return 'Discover';
23
+ case 'diners': return 'Diners Club';
24
+ case 'jcb': return 'JCB';
25
+ case 'unionpay': return 'UnionPay';
26
+ default: return 'Card';
27
+ }
28
+ }
29
+ function formatExp(month, year) {
30
+ return `${month}/${year.slice(-2)}`;
31
+ }
32
+ function renderRow(c) {
33
+ const nick = c.nickname.padEnd(24);
34
+ const brand = brandLabel(c.brand).padEnd(18);
35
+ const last4 = `•••• ${c.last4}`.padEnd(10);
36
+ const exp = formatExp(c.exp_month, c.exp_year);
37
+ return ` ${chalk.cyan(nick)} ${brand} ${last4} ${chalk.gray('exp ' + exp)} ${chalk.gray(c.id)}`;
38
+ }
39
+ async function promptString(message, validate) {
40
+ const { input } = await import('@inquirer/prompts');
41
+ return await input({ message, validate });
42
+ }
43
+ async function promptSecret(message) {
44
+ const { password } = await import('@inquirer/prompts');
45
+ return await password({ message, mask: true });
46
+ }
47
+ function requireTTY(action) {
48
+ if (!isInteractiveTerminal()) {
49
+ throw new Error(`'agents wallet ${action}' requires an interactive terminal.`);
50
+ }
51
+ }
52
+ export function registerWalletCommands(program) {
53
+ const cmd = program
54
+ .command('wallet')
55
+ .description('Device-local credit-card vault backed by macOS Keychain (Touch ID required to reveal). ' +
56
+ 'Encrypted at rest, never leaves your device. Not Apple Pay — stores real PANs, no tokenization.');
57
+ setHelpSections(cmd, {
58
+ examples: `
59
+ $ agents wallet add # interactive: PAN, CVC, expiry, nickname
60
+ $ agents wallet list # last 4 only, no Touch ID prompt
61
+ $ agents wallet show personal-amex # Touch ID required, reveals full card
62
+ $ agents wallet rename personal-amex "Travel"
63
+ $ agents wallet remove personal-amex
64
+ `,
65
+ });
66
+ cmd
67
+ .command('add')
68
+ .description('Add a card to the vault. Interactive prompt for PAN, CVC, expiry, cardholder, nickname.')
69
+ .option('--nickname <name>', 'Set the nickname non-interactively (still prompts for PAN/CVC)')
70
+ .option('--stdin-json', 'Read all fields as a JSON object on stdin (for IPC callers). Emits the new card metadata as JSON to stdout.')
71
+ .action(async (opts) => {
72
+ try {
73
+ if (opts.stdinJson) {
74
+ const raw = await new Promise((resolve, reject) => {
75
+ const chunks = [];
76
+ process.stdin.on('data', (c) => {
77
+ chunks.push(typeof c === 'string' ? Buffer.from(c) : c);
78
+ });
79
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
80
+ process.stdin.on('error', reject);
81
+ });
82
+ const input = JSON.parse(raw);
83
+ const meta = addCard(input);
84
+ process.stdout.write(JSON.stringify({ card: meta }) + '\n');
85
+ return;
86
+ }
87
+ requireTTY('add');
88
+ const pan = (await promptSecret('Card number')).replace(/\s+/g, '');
89
+ if (!/^\d+$/.test(pan))
90
+ throw new Error('PAN must contain only digits.');
91
+ if (!isValidLuhn(pan))
92
+ throw new Error('PAN failed Luhn checksum — typo?');
93
+ const brand = detectBrand(pan);
94
+ console.log(chalk.gray(`Detected: ${brandLabel(brand)} •••• ${pan.slice(-4)}`));
95
+ const exp_month = await promptString('Expiration month (MM)', (v) => {
96
+ const n = Number(v);
97
+ return Number.isInteger(n) && n >= 1 && n <= 12 ? true : 'Month must be 1-12';
98
+ });
99
+ const exp_year = await promptString('Expiration year (YY or YYYY)', (v) => {
100
+ const d = v.replace(/\D/g, '');
101
+ return d.length === 2 || d.length === 4 ? true : 'Year must be 2 or 4 digits';
102
+ });
103
+ const cvc = (await promptSecret('CVC')).replace(/\s+/g, '');
104
+ if (!/^\d{3,4}$/.test(cvc))
105
+ throw new Error('CVC must be 3 or 4 digits.');
106
+ const cardholder = await promptString('Cardholder name (as printed)');
107
+ const nickname = opts.nickname?.trim() || await promptString('Nickname (e.g. Personal Amex)', (v) => v.trim() ? true : 'Required');
108
+ const meta = addCard({ nickname, pan, cvc, cardholder, exp_month, exp_year });
109
+ console.log(chalk.green(`Added ${brandLabel(meta.brand)} •••• ${meta.last4} as '${meta.nickname}' (id: ${meta.id})`));
110
+ }
111
+ catch (err) {
112
+ if (isPromptCancelled(err))
113
+ return;
114
+ console.error(chalk.red(err.message));
115
+ process.exit(1);
116
+ }
117
+ });
118
+ cmd
119
+ .command('list')
120
+ .alias('ls')
121
+ .description('List stored cards (metadata only — last 4, brand, expiry). No biometric prompt.')
122
+ .option('--json', 'Emit JSON to stdout')
123
+ .action((opts) => {
124
+ try {
125
+ const cards = listCards();
126
+ if (opts.json) {
127
+ process.stdout.write(JSON.stringify({ cards }, null, 2) + '\n');
128
+ return;
129
+ }
130
+ if (cards.length === 0) {
131
+ console.log(chalk.gray('No cards in wallet. Try: agents wallet add'));
132
+ return;
133
+ }
134
+ console.log(chalk.bold(` ${'NICKNAME'.padEnd(24)} ${'BRAND'.padEnd(18)} ${'LAST4'.padEnd(10)} EXP ID`));
135
+ for (const c of cards)
136
+ console.log(renderRow(c));
137
+ }
138
+ catch (err) {
139
+ console.error(chalk.red(err.message));
140
+ process.exit(1);
141
+ }
142
+ });
143
+ cmd
144
+ .command('show <id>')
145
+ .description('Reveal a card. Touch ID required. Argument is a card id or nickname.')
146
+ .option('--json', 'Emit JSON to stdout (still triggers Touch ID)')
147
+ .action((id, opts) => {
148
+ try {
149
+ const full = showCard(id);
150
+ if (opts.json) {
151
+ process.stdout.write(JSON.stringify(full, null, 2) + '\n');
152
+ return;
153
+ }
154
+ console.log();
155
+ console.log(chalk.bold(` ${full.nickname}`));
156
+ console.log(` ${chalk.gray('Brand ')} ${brandLabel(full.brand)}`);
157
+ console.log(` ${chalk.gray('Number ')} ${full.pan.match(/.{1,4}/g)?.join(' ') ?? full.pan}`);
158
+ console.log(` ${chalk.gray('CVC ')} ${full.cvc}`);
159
+ console.log(` ${chalk.gray('Expires ')} ${formatExp(full.exp_month, full.exp_year)}`);
160
+ console.log(` ${chalk.gray('Holder ')} ${full.cardholder}`);
161
+ console.log();
162
+ }
163
+ catch (err) {
164
+ console.error(chalk.red(err.message));
165
+ process.exit(1);
166
+ }
167
+ });
168
+ cmd
169
+ .command('remove <id>')
170
+ .alias('rm')
171
+ .description('Remove a card from the vault. Argument is a card id or nickname.')
172
+ .action((id) => {
173
+ try {
174
+ const meta = removeCard(id);
175
+ if (!meta) {
176
+ console.error(chalk.red(`No card found matching '${id}'.`));
177
+ process.exit(1);
178
+ }
179
+ console.log(chalk.green(`Removed '${meta.nickname}' (${brandLabel(meta.brand)} •••• ${meta.last4}).`));
180
+ }
181
+ catch (err) {
182
+ console.error(chalk.red(err.message));
183
+ process.exit(1);
184
+ }
185
+ });
186
+ cmd
187
+ .command('rename <id> <new-nickname>')
188
+ .description('Rename a card. Argument is the current id or nickname.')
189
+ .action((id, newNickname) => {
190
+ try {
191
+ const meta = renameCard(id, newNickname);
192
+ console.log(chalk.green(`Renamed to '${meta.nickname}'.`));
193
+ }
194
+ catch (err) {
195
+ console.error(chalk.red(err.message));
196
+ process.exit(1);
197
+ }
198
+ });
199
+ }
package/dist/index.js CHANGED
@@ -78,7 +78,7 @@ import { registerRunCommand } from './commands/exec.js';
78
78
  import { registerModelsCommand } from './commands/models.js';
79
79
  import { registerDefaultsCommands } from './commands/defaults.js';
80
80
  import { registerPruneCommand } from './commands/prune.js';
81
- import { registerTrashCommands } from './commands/trash.js';
81
+ import { registerTrashCommands, registerRestoreCommand } from './commands/trash.js';
82
82
  import { registerDoctorCommand } from './commands/doctor.js';
83
83
  import { registerSubagentsCommands } from './commands/subagents.js';
84
84
  import { registerPluginsCommands } from './commands/plugins.js';
@@ -93,6 +93,7 @@ import { registerBrowserCommand } from './commands/browser.js';
93
93
  import { registerComputerCommand } from './commands/computer.js';
94
94
  import { registerProfilesCommands } from './commands/profiles.js';
95
95
  import { registerSecretsCommands } from './commands/secrets.js';
96
+ import { registerWalletCommands } from './commands/wallet.js';
96
97
  import { registerHelperCommand } from './commands/helper.js';
97
98
  import { registerFactoryCommands } from './commands/factory.js';
98
99
  import { registerUsageCommand } from './commands/usage.js';
@@ -655,6 +656,7 @@ registerDefaultsCommands(program);
655
656
  registerModelsCommand(program);
656
657
  registerPruneCommand(program);
657
658
  registerTrashCommands(program);
659
+ registerRestoreCommand(program);
658
660
  registerDoctorCommand(program);
659
661
  // Deprecated 'exec' alias for 'run'
660
662
  program
@@ -669,6 +671,7 @@ program
669
671
  });
670
672
  registerProfilesCommands(program);
671
673
  registerSecretsCommands(program);
674
+ registerWalletCommands(program);
672
675
  registerHelperCommand(program);
673
676
  registerBetaCommands(program);
674
677
  registerSyncCommand(program);
@@ -90,41 +90,44 @@ function findInPath(command) {
90
90
  return null;
91
91
  }
92
92
  /** Grok-specific binary resolution.
93
- * Grok does not live in node_modules/.bin. Its versioned binaries live in
94
- * ~/.grok/downloads/ with names like `grok-0.1.218-macos-aarch64`.
95
- * We still use the agents-cli version dir for *config isolation* via GROK_HOME.
93
+ * Grok does not live in node_modules/.bin. Its versioned binaries live in each
94
+ * managed version home under `.grok/downloads/`, so detection must not follow
95
+ * the host ~/.grok config symlink.
96
96
  */
97
97
  function resolveGrokBinary(version) {
98
- const grokDownloads = path.join(HOME, '.grok', 'downloads');
99
- if (!fs.existsSync(grokDownloads))
100
- return null;
101
- const entries = fs.readdirSync(grokDownloads);
102
- // Prefer exact version match in filename
103
98
  if (version && version !== 'latest') {
104
- const match = entries.find((e) => e.includes(version) && e.startsWith('grok-'));
105
- if (match)
106
- return path.join(grokDownloads, match);
107
- }
108
- // Fallback: the "current" symlink or the plain `grok-*` without version in name
109
- const current = entries.find((e) => e === 'grok' || e.startsWith('grok-') && !e.match(/grok-\d/));
110
- if (current)
111
- return path.join(grokDownloads, current);
112
- // Last resort: newest file by mtime
99
+ const binaryPath = getBinaryPath('grok', version);
100
+ if (fs.existsSync(binaryPath))
101
+ return binaryPath;
102
+ return null;
103
+ }
104
+ const resolvedVersion = resolveVersion('grok', process.cwd());
105
+ if (resolvedVersion) {
106
+ const binaryPath = getBinaryPath('grok', resolvedVersion);
107
+ if (fs.existsSync(binaryPath))
108
+ return binaryPath;
109
+ }
110
+ const grokVersionsDir = path.join(getVersionsDir(), 'grok');
111
+ if (!fs.existsSync(grokVersionsDir))
112
+ return null;
113
113
  let latest = null;
114
114
  let latestMtime = 0;
115
- for (const e of entries) {
116
- if (!e.startsWith('grok-'))
115
+ for (const entry of fs.readdirSync(grokVersionsDir, { withFileTypes: true })) {
116
+ if (!entry.isDirectory())
117
+ continue;
118
+ const binaryPath = getBinaryPath('grok', entry.name);
119
+ if (!fs.existsSync(binaryPath))
117
120
  continue;
118
121
  try {
119
- const stat = fs.statSync(path.join(grokDownloads, e));
122
+ const stat = fs.statSync(binaryPath);
120
123
  if (stat.mtimeMs > latestMtime) {
121
124
  latestMtime = stat.mtimeMs;
122
- latest = e;
125
+ latest = binaryPath;
123
126
  }
124
127
  }
125
128
  catch { }
126
129
  }
127
- return latest ? path.join(grokDownloads, latest) : null;
130
+ return latest;
128
131
  }
129
132
  function splitCommandLine(command) {
130
133
  const args = [];
@@ -482,6 +485,44 @@ export const AGENTS = {
482
485
  rulesImports: false,
483
486
  },
484
487
  },
488
+ // Factory AI Droid CLI (`droid`) — agentic coding CLI from factory.ai.
489
+ // Install: `curl -fsSL https://app.factory.ai/cli | sh` (no npm package).
490
+ // Binary is NOT in node_modules/.bin — resolved via resolveDroidBinary().
491
+ // Config: `~/.factory/` (settings.json, mcp.json, droids/, commands/).
492
+ // Memory: native AGENTS.md. Subagents = custom droids (top-level .md files
493
+ // in ~/.factory/droids/). Config isolation rides the ~/.factory symlink
494
+ // switch (no FACTORY_HOME env var exists). Headless: `droid exec "<prompt>"`
495
+ // with --auto low|medium|high, -o stream-json, -m <model>, -r <effort>.
496
+ droid: {
497
+ id: 'droid',
498
+ name: 'Droid',
499
+ color: 'yellowBright',
500
+ cliCommand: 'droid',
501
+ npmPackage: '',
502
+ installScript: 'curl -fsSL https://app.factory.ai/cli | sh',
503
+ configDir: path.join(HOME, '.factory'),
504
+ commandsDir: path.join(HOME, '.factory', 'commands'),
505
+ commandsSubdir: 'commands',
506
+ skillsDir: '', // no skills concept
507
+ hooksDir: 'hooks',
508
+ instructionsFile: 'AGENTS.md',
509
+ format: 'markdown',
510
+ variableSyntax: '$ARGUMENTS',
511
+ supportsHooks: false,
512
+ capabilities: {
513
+ hooks: false,
514
+ mcp: true,
515
+ allowlist: false,
516
+ skills: false,
517
+ commands: true,
518
+ plugins: false,
519
+ subagents: true,
520
+ rules: { file: 'AGENTS.md' },
521
+ workflows: false,
522
+ modes: ['plan', 'edit', 'auto', 'skip'],
523
+ rulesImports: false,
524
+ },
525
+ },
485
526
  };
486
527
  /** All registered agent IDs derived from the AGENTS registry. */
487
528
  export const ALL_AGENT_IDS = Object.keys(AGENTS);
@@ -1353,6 +1394,9 @@ export function getUserMcpConfigPath(agentId) {
1353
1394
  case 'grok':
1354
1395
  // grok mcp.json — exact field schema verified at first install
1355
1396
  return path.join(agent.configDir, 'mcp.json');
1397
+ case 'droid':
1398
+ // Factory AI Droid stores MCPs in ~/.factory/mcp.json
1399
+ return path.join(agent.configDir, 'mcp.json');
1356
1400
  default:
1357
1401
  // Gemini and others use settings.json
1358
1402
  return path.join(agent.configDir, 'settings.json');
@@ -1387,6 +1431,8 @@ export function getMcpConfigPathForHome(agentId, home) {
1387
1431
  return path.join(home, '.gemini', 'antigravity-cli', 'mcp_config.json');
1388
1432
  case 'grok':
1389
1433
  return path.join(home, '.grok', 'config.toml');
1434
+ case 'droid':
1435
+ return path.join(home, '.factory', 'mcp.json');
1390
1436
  default:
1391
1437
  return path.join(home, agentConfigDirName(agentId), 'settings.json');
1392
1438
  }
@@ -1423,6 +1469,8 @@ function getProjectMcpConfigPath(agentId, cwd = process.cwd()) {
1423
1469
  return path.join(cwd, '.gemini', 'antigravity-cli', 'mcp_config.json');
1424
1470
  case 'grok':
1425
1471
  return path.join(cwd, '.grok', 'config.toml');
1472
+ case 'droid':
1473
+ return path.join(cwd, '.factory', 'mcp.json');
1426
1474
  default:
1427
1475
  return path.join(cwd, `.${agentId}`, 'settings.json');
1428
1476
  }
@@ -16,4 +16,11 @@ export declare class BrowserIPCServer {
16
16
  stop(): Promise<void>;
17
17
  private handleRequest;
18
18
  }
19
+ /**
20
+ * Decide whether a running daemon is stale and must be restarted. A daemon
21
+ * is stale when it reports a concrete version that differs from this CLI's.
22
+ * `undefined`/`'unknown'` means the daemon is too old to answer the `version`
23
+ * action reliably — don't churn it on that ambiguous signal.
24
+ */
25
+ export declare function shouldRestartStaleDaemon(daemonVersion: string | undefined, clientVersion: string): boolean;
19
26
  export declare function sendIPCRequest(request: IPCRequest, opts?: IPCRequestOptions): Promise<IPCResponse>;
@@ -3,7 +3,7 @@ import * as fs from 'fs';
3
3
  import * as path from 'path';
4
4
  import { IS_WINDOWS, ipcEndpoint } from '../platform/index.js';
5
5
  import { getHelpersDir } from '../state.js';
6
- import { startDaemon } from '../daemon.js';
6
+ import { startDaemon, stopDaemon } from '../daemon.js';
7
7
  import { getCliVersion } from '../version.js';
8
8
  const SOCKET_NAME = 'browser.sock';
9
9
  export class BrowserDaemonNotRunningError extends Error {
@@ -453,41 +453,51 @@ export class BrowserIPCServer {
453
453
  }
454
454
  }
455
455
  }
456
- let versionCheckedThisProcess = false;
456
+ let versionReconciledThisProcess = false;
457
457
  /**
458
- * Check the daemon's version against ours and warn loudly when they
459
- * differ. Fires at most once per CLI process successive calls in the
460
- * same `agents browser ...` invocation are cheap. The whole reason this
461
- * code exists: a launchd-managed registry daemon kept serving stale code
462
- * to a dev-build CLI for an entire session and nothing surfaced it.
458
+ * Decide whether a running daemon is stale and must be restarted. A daemon
459
+ * is stale when it reports a concrete version that differs from this CLI's.
460
+ * `undefined`/`'unknown'` means the daemon is too old to answer the `version`
461
+ * action reliably don't churn it on that ambiguous signal.
463
462
  */
464
- async function maybeWarnVersionMismatch() {
465
- if (versionCheckedThisProcess)
463
+ export function shouldRestartStaleDaemon(daemonVersion, clientVersion) {
464
+ if (!daemonVersion || daemonVersion === 'unknown')
465
+ return false;
466
+ return daemonVersion !== clientVersion;
467
+ }
468
+ /**
469
+ * Reconcile the running daemon's version with ours. If the daemon is serving
470
+ * stale code, stop and restart it so this request — and the rest of the
471
+ * session — runs the current build. Runs at most once per CLI process. The
472
+ * whole reason this exists: a launchd-managed daemon kept serving stale code
473
+ * to a dev-build CLI for an entire session and nothing surfaced it (#291).
474
+ */
475
+ async function reconcileDaemonVersion(socketPath) {
476
+ if (versionReconciledThisProcess)
466
477
  return;
467
- versionCheckedThisProcess = true;
478
+ versionReconciledThisProcess = true;
479
+ let daemon;
468
480
  try {
469
- const resp = await sendRawIPCRequest({ action: 'version' });
470
- const daemon = resp.version;
471
- const client = getCliVersion();
472
- if (!daemon || daemon === 'unknown' || daemon === client)
473
- return;
474
- process.stderr.write(`\nwarning: browser daemon is on ${daemon} but this CLI is on ${client}.\n` +
475
- ` Run \`agents daemon restart\` to load the current code.\n\n`);
481
+ const resp = await sendRawIPCRequest({ action: 'version' }, { autoStartDaemon: false });
482
+ daemon = resp.version;
476
483
  }
477
484
  catch {
478
- // daemon might be an older build that doesn't speak 'version' — that's
479
- // itself a hint, but a noisy one. Stay silent on this path.
485
+ // Daemon unreachable or too old to speak 'version' — leave it alone.
486
+ return;
480
487
  }
488
+ const client = getCliVersion();
489
+ if (!shouldRestartStaleDaemon(daemon, client))
490
+ return;
491
+ process.stderr.write(`\nbrowser daemon was on ${daemon}, this CLI is on ${client} — restarting it to load current code.\n\n`);
492
+ stopDaemon();
493
+ startDaemon();
494
+ if (!(await isDaemonReachable())) {
495
+ await waitForSocket(socketPath, 6000);
496
+ }
497
+ await new Promise((r) => setTimeout(r, 300));
481
498
  }
482
499
  export async function sendIPCRequest(request, opts = {}) {
483
- const result = await sendRawIPCRequest(request, opts);
484
- // Run the version check after the user's request returns — keeps the
485
- // critical path zero-overhead and ensures `start` doesn't get blocked
486
- // on a daemon-restart warning that the user hasn't read yet.
487
- if (request.action !== 'version') {
488
- maybeWarnVersionMismatch().catch(() => { });
489
- }
490
- return result;
500
+ return sendRawIPCRequest(request, opts);
491
501
  }
492
502
  async function sendRawIPCRequest(request, opts = {}) {
493
503
  const socketPath = getSocketPath();
@@ -510,6 +520,12 @@ async function sendRawIPCRequest(request, opts = {}) {
510
520
  }
511
521
  await new Promise((r) => setTimeout(r, 300));
512
522
  }
523
+ // Before serving a real request, make sure the daemon isn't running stale
524
+ // code. Skips the internal `version` probe (avoids recursion) and callers
525
+ // that opt out of auto-start. No-ops once reconciled or when versions match.
526
+ if (request.action !== 'version' && autoStartDaemon) {
527
+ await reconcileDaemonVersion(socketPath);
528
+ }
513
529
  return new Promise((resolve, reject) => {
514
530
  const socket = net.createConnection(endpoint);
515
531
  let buffer = '';
@@ -24,7 +24,13 @@ function compareVersions(a, b) {
24
24
  return 0;
25
25
  }
26
26
  function getCapability(agent, cap) {
27
- return AGENTS[agent].capabilities[cap];
27
+ // Guard against unknown agent ids (e.g. a caller passing "claude@2.1.168"
28
+ // instead of "claude"). Without this, AGENTS[agent] is undefined and the
29
+ // property access throws an opaque TypeError instead of reporting false.
30
+ const def = AGENTS[agent];
31
+ if (!def)
32
+ return false;
33
+ return def.capabilities[cap];
28
34
  }
29
35
  /**
30
36
  * True when the agent supports the capability on at least some version.
@@ -7,6 +7,7 @@ export declare function shouldInstallCommandAsSkill(agent: AgentId, version: str
7
7
  export declare function commandSkillName(commandName: string): string;
8
8
  export declare function buildCommandSkillContent(commandName: string, sourcePath: string): string;
9
9
  export declare function skillSourceExists(skillName: string, skillSourceDirs: Array<string | null | undefined>): boolean;
10
+ export declare function readSkillSourceCommandMarker(skillName: string, skillSourceDirs: Array<string | null | undefined>): string | null;
10
11
  export declare function installCommandSkillToVersion(agentDir: string, commandName: string, sourcePath: string, skillSourceDirs?: Array<string | null | undefined>): {
11
12
  success: boolean;
12
13
  skipped?: boolean;
@@ -74,18 +74,34 @@ export function buildCommandSkillContent(commandName, sourcePath) {
74
74
  '',
75
75
  ].join('\n');
76
76
  }
77
- export function skillSourceExists(skillName, skillSourceDirs) {
78
- return skillSourceDirs.some((dir) => {
77
+ function findSkillSourceDir(skillName, skillSourceDirs) {
78
+ for (const dir of skillSourceDirs) {
79
79
  if (!dir)
80
- return false;
80
+ continue;
81
81
  const candidate = path.join(dir, skillName);
82
- return fs.existsSync(candidate) && fs.lstatSync(candidate).isDirectory();
83
- });
82
+ if (fs.existsSync(candidate) && fs.lstatSync(candidate).isDirectory()) {
83
+ return candidate;
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ export function skillSourceExists(skillName, skillSourceDirs) {
89
+ return findSkillSourceDir(skillName, skillSourceDirs) !== null;
90
+ }
91
+ export function readSkillSourceCommandMarker(skillName, skillSourceDirs) {
92
+ const sourceDir = findSkillSourceDir(skillName, skillSourceDirs);
93
+ if (!sourceDir)
94
+ return null;
95
+ return readSkillCommandMarker(path.join(sourceDir, 'SKILL.md'));
84
96
  }
85
97
  export function installCommandSkillToVersion(agentDir, commandName, sourcePath, skillSourceDirs = []) {
86
98
  const skillName = commandSkillName(commandName);
87
- if (skillSourceExists(skillName, skillSourceDirs)) {
88
- return { success: false, skipped: true, error: `Skill '${skillName}' already exists` };
99
+ const existingSkillSource = findSkillSourceDir(skillName, skillSourceDirs);
100
+ if (existingSkillSource) {
101
+ const sourceMarker = readSkillCommandMarker(path.join(existingSkillSource, 'SKILL.md'));
102
+ if (sourceMarker !== commandName) {
103
+ return { success: true, skipped: true, error: `Skill '${skillName}' already exists` };
104
+ }
89
105
  }
90
106
  const skillsDir = safeJoin(agentDir, 'skills');
91
107
  const skillDir = safeJoin(skillsDir, skillName);
@@ -12,6 +12,29 @@ export type ExecMode = Mode;
12
12
  * boundary rather than silently picking a wrong code path.
13
13
  */
14
14
  export declare function normalizeMode(input: string | null | undefined): Mode;
15
+ /**
16
+ * Detect the headless-plan stall footgun.
17
+ *
18
+ * A slash command (e.g. `/code:commit`) run headless under the IMPLICIT default
19
+ * `plan` mode hangs forever: plan is read-only, so the agent calls ExitPlanMode
20
+ * to start working, and in a headless run there is no TTY to approve it. The
21
+ * process just sits there. Callers use this to fail fast with a fix instead.
22
+ *
23
+ * Returns the offending command token (e.g. `/code:commit`) when the run should
24
+ * be blocked, else null. Guards are deliberately narrow:
25
+ * - interactive runs / no prompt -> not headless, never blocks
26
+ * - explicit --mode (modeIsDefault false) -> respected; `--mode plan` is a
27
+ * legitimate read-only command run and must not be blocked
28
+ * - resolved mode is not `plan` -> only plan stalls at ExitPlanMode
29
+ * - prompt is not a slash command -> natural-language read-only prompts
30
+ * ("summarize commits") are a valid default-plan use and must not be blocked
31
+ */
32
+ export declare function headlessPlanStallCommand(args: {
33
+ prompt: string | undefined;
34
+ interactive: boolean | undefined;
35
+ mode: string;
36
+ modeIsDefault: boolean;
37
+ }): string | null;
15
38
  /**
16
39
  * Resolve a requested mode against an agent's capability table.
17
40
  *
@@ -45,11 +68,12 @@ export interface ExecOptions {
45
68
  version?: string;
46
69
  /** Omit to launch the CLI interactively -- no prompt, no --print, stdio fully inherited. */
47
70
  prompt?: string;
48
- /** Force interactive mode even when a prompt is provided. */
71
+ /** Force interactive mode even when a prompt is provided. Wins over `headless`. */
49
72
  interactive?: boolean;
50
73
  mode: ExecMode;
51
74
  effort: ExecEffort;
52
75
  cwd?: string;
76
+ /** Force headless mode even when no prompt is provided (e.g. piping via stdin). */
53
77
  headless?: boolean;
54
78
  json?: boolean;
55
79
  model?: string;
@@ -59,6 +83,13 @@ export interface ExecOptions {
59
83
  verbose?: boolean;
60
84
  env?: Record<string, string>;
61
85
  }
86
+ /**
87
+ * Resolve interactive vs headless. Explicit flags are definitive and win over
88
+ * inference: `--interactive` forces interactive, `--headless` forces headless.
89
+ * With neither flag, prompt presence decides (prompt -> headless, none -> interactive).
90
+ * `--interactive` takes precedence over `--headless`; the CLI layer rejects passing both.
91
+ */
92
+ export declare function resolveInteractive(options: Pick<ExecOptions, 'interactive' | 'headless' | 'prompt'>): boolean;
62
93
  /** Parse an array of KEY=VALUE strings into an env record. Returns undefined for empty input. */
63
94
  export declare function parseExecEnv(entries: string[]): Record<string, string> | undefined;
64
95
  /**