@phnx-labs/agents-cli 1.20.11 → 1.20.13

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 (61) hide show
  1. package/CHANGELOG.md +17 -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/exec.js +25 -4
  6. package/dist/commands/import.js +17 -6
  7. package/dist/commands/inspect.d.ts +11 -1
  8. package/dist/commands/inspect.js +53 -19
  9. package/dist/commands/mcp.js +3 -3
  10. package/dist/commands/plugins.d.ts +2 -0
  11. package/dist/commands/plugins.js +69 -26
  12. package/dist/commands/sync.js +1 -1
  13. package/dist/commands/teams.js +1 -0
  14. package/dist/commands/trash.d.ts +11 -0
  15. package/dist/commands/trash.js +57 -41
  16. package/dist/commands/versions.js +68 -20
  17. package/dist/commands/view.js +1 -12
  18. package/dist/commands/wallet.d.ts +14 -0
  19. package/dist/commands/wallet.js +199 -0
  20. package/dist/index.js +22 -1
  21. package/dist/lib/agents.js +70 -22
  22. package/dist/lib/browser/ipc.d.ts +7 -0
  23. package/dist/lib/browser/ipc.js +43 -27
  24. package/dist/lib/capabilities.js +7 -1
  25. package/dist/lib/command-skills.d.ts +1 -0
  26. package/dist/lib/command-skills.js +23 -7
  27. package/dist/lib/exec.d.ts +32 -1
  28. package/dist/lib/exec.js +79 -7
  29. package/dist/lib/hooks.js +37 -5
  30. package/dist/lib/mcp.js +33 -0
  31. package/dist/lib/models.js +5 -0
  32. package/dist/lib/picker.d.ts +2 -0
  33. package/dist/lib/picker.js +96 -6
  34. package/dist/lib/platform/index.d.ts +1 -0
  35. package/dist/lib/platform/index.js +1 -0
  36. package/dist/lib/platform/winpath.d.ts +35 -0
  37. package/dist/lib/platform/winpath.js +86 -0
  38. package/dist/lib/plugins.d.ts +14 -0
  39. package/dist/lib/plugins.js +23 -0
  40. package/dist/lib/project-launch.js +110 -5
  41. package/dist/lib/registry.js +15 -2
  42. package/dist/lib/runner.js +14 -0
  43. package/dist/lib/sandbox.js +5 -2
  44. package/dist/lib/settings-manifest.d.ts +39 -0
  45. package/dist/lib/settings-manifest.js +163 -0
  46. package/dist/lib/shims.d.ts +1 -1
  47. package/dist/lib/shims.js +16 -31
  48. package/dist/lib/staleness/detectors/subagents.js +16 -0
  49. package/dist/lib/staleness/writers/subagents.js +11 -3
  50. package/dist/lib/subagents.d.ts +9 -0
  51. package/dist/lib/subagents.js +33 -0
  52. package/dist/lib/teams/agents.js +1 -1
  53. package/dist/lib/teams/parsers.d.ts +1 -1
  54. package/dist/lib/teams/parsers.js +6 -0
  55. package/dist/lib/types.d.ts +1 -1
  56. package/dist/lib/versions.d.ts +15 -3
  57. package/dist/lib/versions.js +88 -19
  58. package/dist/lib/wallet/index.d.ts +78 -0
  59. package/dist/lib/wallet/index.js +253 -0
  60. package/package.json +3 -3
  61. package/scripts/postinstall.js +35 -7
@@ -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
  /**
package/dist/lib/exec.js CHANGED
@@ -34,6 +34,36 @@ export function normalizeMode(input) {
34
34
  return v;
35
35
  throw new Error(`Invalid mode '${input}'. Use one of: ${ALL_MODES.join(', ')} (or 'full' as a deprecated alias for 'skip').`);
36
36
  }
37
+ /**
38
+ * Detect the headless-plan stall footgun.
39
+ *
40
+ * A slash command (e.g. `/code:commit`) run headless under the IMPLICIT default
41
+ * `plan` mode hangs forever: plan is read-only, so the agent calls ExitPlanMode
42
+ * to start working, and in a headless run there is no TTY to approve it. The
43
+ * process just sits there. Callers use this to fail fast with a fix instead.
44
+ *
45
+ * Returns the offending command token (e.g. `/code:commit`) when the run should
46
+ * be blocked, else null. Guards are deliberately narrow:
47
+ * - interactive runs / no prompt -> not headless, never blocks
48
+ * - explicit --mode (modeIsDefault false) -> respected; `--mode plan` is a
49
+ * legitimate read-only command run and must not be blocked
50
+ * - resolved mode is not `plan` -> only plan stalls at ExitPlanMode
51
+ * - prompt is not a slash command -> natural-language read-only prompts
52
+ * ("summarize commits") are a valid default-plan use and must not be blocked
53
+ */
54
+ export function headlessPlanStallCommand(args) {
55
+ const { prompt, interactive, mode, modeIsDefault } = args;
56
+ if (interactive === true || prompt === undefined)
57
+ return null;
58
+ if (!modeIsDefault)
59
+ return null;
60
+ if (normalizeMode(mode) !== 'plan')
61
+ return null;
62
+ const trimmed = prompt.trimStart();
63
+ if (!trimmed.startsWith('/'))
64
+ return null;
65
+ return trimmed.split(/\s+/)[0];
66
+ }
37
67
  /**
38
68
  * Resolve a requested mode against an agent's capability table.
39
69
  *
@@ -71,6 +101,19 @@ export function resolveMode(agent, requested) {
71
101
  export function defaultModeFor(agent) {
72
102
  return AGENTS[agent].capabilities.modes[0];
73
103
  }
104
+ /**
105
+ * Resolve interactive vs headless. Explicit flags are definitive and win over
106
+ * inference: `--interactive` forces interactive, `--headless` forces headless.
107
+ * With neither flag, prompt presence decides (prompt -> headless, none -> interactive).
108
+ * `--interactive` takes precedence over `--headless`; the CLI layer rejects passing both.
109
+ */
110
+ export function resolveInteractive(options) {
111
+ if (options.interactive === true)
112
+ return true;
113
+ if (options.headless === true)
114
+ return false;
115
+ return options.prompt === undefined;
116
+ }
74
117
  /** Pattern for valid environment variable names (C identifier rules). */
75
118
  const EXEC_ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
76
119
  /** Parse a single KEY=VALUE string into a tuple, validating the key name. */
@@ -352,16 +395,40 @@ export const AGENT_COMMANDS = {
352
395
  jsonFlags: ['--output-format', 'stream-json'],
353
396
  modelFlag: '--model',
354
397
  },
398
+ // Factory AI Droid (`droid exec` for headless, `droid` for TUI). Flags from
399
+ // docs.factory.ai CLI reference: prompt is positional; --auto low|medium|high
400
+ // escalates autonomy (default is read-only); --skip-permissions-unsafe drops
401
+ // all guardrails; -o stream-json streams JSONL events; -m selects the model.
402
+ // The `exec` subcommand is dropped for interactive runs (see buildExecCommand).
403
+ droid: {
404
+ base: ['droid', 'exec'],
405
+ promptFlag: 'positional',
406
+ modeFlags: {
407
+ plan: [], // droid's default exec mode is read-only
408
+ edit: ['--auto', 'low'], // create/edit files, non-destructive
409
+ auto: ['--auto', 'high'], // full autonomy
410
+ skip: ['--skip-permissions-unsafe'],
411
+ },
412
+ jsonFlags: ['-o', 'stream-json'],
413
+ modelFlag: '-m',
414
+ },
355
415
  };
356
416
  /** Assemble the full CLI argument array for an agent invocation. */
357
417
  export function buildExecCommand(options) {
358
418
  const template = AGENT_COMMANDS[options.agent];
359
419
  const cmd = [...template.base];
360
- const interactive = options.interactive === true || options.prompt === undefined;
361
- // For Codex, 'exec' is the headless subcommand -- drop it for interactive mode
362
- // so we run 'codex' (TUI) instead of 'codex exec' (one-shot)
363
- if (options.agent === 'codex' && interactive && cmd[1] === 'exec') {
364
- cmd.splice(1, 1);
420
+ const interactive = resolveInteractive(options);
421
+ // For Codex and Droid, 'exec' is the headless subcommand; for OpenCode, 'run'
422
+ // is. Drop it for interactive mode so we launch the TUI (`codex` / `droid` /
423
+ // `opencode`, each agent's default command) instead of the one-shot headless
424
+ // subcommand ('codex exec' / 'droid exec' / 'opencode run').
425
+ if (interactive) {
426
+ if ((options.agent === 'codex' || options.agent === 'droid') && cmd[1] === 'exec') {
427
+ cmd.splice(1, 1);
428
+ }
429
+ else if (options.agent === 'opencode' && cmd[1] === 'run') {
430
+ cmd.splice(1, 1);
431
+ }
365
432
  }
366
433
  // Use versioned alias if a specific version was requested (e.g., claude@2.1.98).
367
434
  // Resolve to the absolute path of the shim so spawn doesn't depend on PATH —
@@ -455,7 +522,12 @@ export function buildExecCommand(options) {
455
522
  // so the CLI launches its TUI. When --interactive is passed alongside a prompt
456
523
  // we still forward the prompt so the agent receives it as the first message.
457
524
  if (options.prompt !== undefined) {
458
- if (template.promptFlag === 'positional') {
525
+ if (interactive && options.agent === 'opencode') {
526
+ // The OpenCode TUI takes an initial prompt via --prompt; a bare positional
527
+ // on the default command is parsed as a project path, not a message.
528
+ cmd.push('--prompt', options.prompt);
529
+ }
530
+ else if (template.promptFlag === 'positional') {
459
531
  cmd.push(options.prompt);
460
532
  }
461
533
  else {
@@ -526,7 +598,7 @@ async function spawnAgent(options) {
526
598
  const [executable, ...args] = cmd;
527
599
  const timeoutMs = options.timeout ? parseTimeout(options.timeout) : undefined;
528
600
  const piped = !process.stdout.isTTY;
529
- const interactive = options.interactive === true || options.prompt === undefined;
601
+ const interactive = resolveInteractive(options);
530
602
  maybeRotate();
531
603
  const timer = createTimer('agent.run', {
532
604
  agent: options.agent,
package/dist/lib/hooks.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * and syncing them across version switches.
9
9
  */
10
10
  import * as fs from 'fs';
11
+ import * as os from 'os';
11
12
  import * as path from 'path';
12
13
  import * as yaml from 'yaml';
13
14
  import * as TOML from 'smol-toml';
@@ -53,9 +54,41 @@ function getManagedHookPrefixes() {
53
54
  path.join(getSystemAgentsDir(), 'hooks') + path.sep,
54
55
  ];
55
56
  }
57
+ /**
58
+ * Convert an absolute path under HOME to a portable ~/... form with forward
59
+ * slashes. Hook commands stored this way work on both macOS and Windows:
60
+ * absolute Windows paths break in bash because backslashes are stripped as
61
+ * escape characters, whereas ~/... paths expand correctly via the ~/.claude
62
+ * symlink/junction on both platforms.
63
+ */
64
+ function toPortableCommand(absPath) {
65
+ const home = os.homedir();
66
+ const normalized = absPath.split(path.sep).join('/');
67
+ const homeNorm = home.split(path.sep).join('/');
68
+ if (normalized.startsWith(homeNorm + '/')) {
69
+ return '~/' + normalized.slice(homeNorm.length + 1);
70
+ }
71
+ return normalized;
72
+ }
56
73
  function isManagedHookCommand(command, prefixes) {
74
+ // Expand ~/... so tilde-form portable commands can be matched against
75
+ // absolute managed prefixes.
76
+ let expanded = command;
77
+ if (command.startsWith('~/')) {
78
+ expanded = path.join(os.homedir(), command.slice(2));
79
+ }
80
+ // Resolve the directory through symlinks/junctions (e.g. ~/.claude on
81
+ // Windows is a junction to the versioned home dir where prefixes live).
82
+ // Resolve the dir, not the full path — the file may not exist after removal.
83
+ const dir = path.dirname(expanded);
84
+ let resolvedDir = dir;
85
+ try {
86
+ resolvedDir = fs.realpathSync(dir);
87
+ }
88
+ catch { /* absent or broken link */ }
89
+ const resolved = path.join(resolvedDir, path.basename(expanded));
57
90
  for (const prefix of prefixes) {
58
- if (command.startsWith(prefix))
91
+ if (resolved.startsWith(prefix))
59
92
  return true;
60
93
  }
61
94
  return false;
@@ -80,9 +113,9 @@ function resolveHookCommand(name, hookDef, resolveScript) {
80
113
  // No caching opted in — make sure a previously generated shim from an
81
114
  // earlier `cache:` config is gone so the JSONL doesn't keep claiming hits.
82
115
  removeHookShim(name);
83
- return scriptPath;
116
+ return toPortableCommand(scriptPath);
84
117
  }
85
- return generateHookShim({ name, scriptPath, cache });
118
+ return toPortableCommand(generateHookShim({ name, scriptPath, cache }));
86
119
  }
87
120
  /**
88
121
  * Extensions that are NEVER hooks — docs, configuration, plain data. A file
@@ -1253,8 +1286,7 @@ function registerHooksForKimi(versionHome, manifest, resolveScript, managedPrefi
1253
1286
  const cmd = typeof h.command === 'string' ? h.command : '';
1254
1287
  if (!cmd)
1255
1288
  return true;
1256
- const isManaged = managedPrefixes.some((p) => cmd.startsWith(p));
1257
- if (!isManaged)
1289
+ if (!isManagedHookCommand(cmd, managedPrefixes))
1258
1290
  return true;
1259
1291
  return currentManifestPaths.has(cmd);
1260
1292
  });
package/dist/lib/mcp.js CHANGED
@@ -338,6 +338,35 @@ function installMcpToKimiConfig(server, versionHome) {
338
338
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
339
339
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
340
340
  }
341
+ /**
342
+ * Install MCP server to Factory AI Droid config (`~/.factory/mcp.json`).
343
+ * Droid uses the standard `mcpServers` JSON shape, same as Kimi/Claude.
344
+ */
345
+ function installMcpToFactoryConfig(server, versionHome) {
346
+ const configPath = path.join(versionHome, '.factory', 'mcp.json');
347
+ let config = {};
348
+ if (fs.existsSync(configPath)) {
349
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
350
+ }
351
+ if (!config.mcpServers || typeof config.mcpServers !== 'object') {
352
+ config.mcpServers = {};
353
+ }
354
+ const mcpServers = config.mcpServers;
355
+ if (server.config.transport === 'stdio') {
356
+ mcpServers[server.name] = {
357
+ command: server.config.command,
358
+ args: server.config.args || [],
359
+ env: server.config.env || {},
360
+ };
361
+ }
362
+ else {
363
+ mcpServers[server.name] = {
364
+ url: server.config.url,
365
+ };
366
+ }
367
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
368
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
369
+ }
341
370
  function installMcpToOpenCodeConfig(server, versionHome) {
342
371
  const configPath = path.join(versionHome, '.opencode', 'opencode.jsonc');
343
372
  let config = {};
@@ -424,6 +453,10 @@ export function installMcpServers(agentId, version, versionHome, mcpNames, optio
424
453
  installMcpToKimiConfig(server, versionHome);
425
454
  applied.push(server.name);
426
455
  }
456
+ else if (agentId === 'droid') {
457
+ installMcpToFactoryConfig(server, versionHome);
458
+ applied.push(server.name);
459
+ }
427
460
  }
428
461
  catch (err) {
429
462
  const message = err.message;
@@ -772,5 +772,10 @@ export function buildReasoningFlags(agent, level) {
772
772
  const codexLevel = (normalized === 'xhigh' || normalized === 'max') ? 'high' : normalized;
773
773
  return ['-c', `model_reasoning_effort=${codexLevel}`];
774
774
  }
775
+ if (agent === 'droid') {
776
+ // Droid: `-r off|none|low|medium|high`. xhigh/max clamp to high.
777
+ const droidLevel = (normalized === 'xhigh' || normalized === 'max') ? 'high' : normalized;
778
+ return ['-r', droidLevel];
779
+ }
775
780
  return [];
776
781
  }
@@ -22,5 +22,7 @@ export interface PickerConfig<T> {
22
22
  export interface PickedItem<T> {
23
23
  item: T;
24
24
  }
25
+ /** Clip a picker preview so the full prompt can fit in the terminal viewport. */
26
+ export declare function limitPreviewHeight(preview: string, maxRows: number, width: number): string;
25
27
  /** Show an interactive fuzzy-filter picker and return the selected item, or null on cancel. */
26
28
  export declare function itemPicker<T>(config: PickerConfig<T>): Promise<PickedItem<T> | null>;