@phnx-labs/agents-cli 1.20.12 → 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.
- package/CHANGELOG.md +13 -0
- package/README.md +3 -0
- package/dist/commands/computer-actions.d.ts +3 -0
- package/dist/commands/computer-actions.js +16 -0
- package/dist/commands/exec.js +25 -4
- package/dist/commands/import.js +17 -6
- package/dist/commands/inspect.d.ts +11 -1
- package/dist/commands/inspect.js +53 -19
- package/dist/commands/mcp.js +3 -3
- package/dist/commands/plugins.d.ts +2 -0
- package/dist/commands/plugins.js +69 -26
- package/dist/commands/sync.js +1 -1
- package/dist/commands/teams.js +1 -0
- package/dist/commands/trash.d.ts +11 -0
- package/dist/commands/trash.js +57 -41
- package/dist/commands/versions.js +68 -20
- package/dist/commands/view.js +1 -12
- package/dist/commands/wallet.d.ts +14 -0
- package/dist/commands/wallet.js +199 -0
- package/dist/index.js +4 -1
- package/dist/lib/agents.js +70 -22
- package/dist/lib/browser/ipc.d.ts +7 -0
- package/dist/lib/browser/ipc.js +43 -27
- package/dist/lib/capabilities.js +7 -1
- package/dist/lib/command-skills.d.ts +1 -0
- package/dist/lib/command-skills.js +23 -7
- package/dist/lib/exec.d.ts +32 -1
- package/dist/lib/exec.js +79 -7
- package/dist/lib/hooks.js +37 -5
- package/dist/lib/mcp.js +33 -0
- package/dist/lib/models.js +5 -0
- package/dist/lib/picker.d.ts +2 -0
- package/dist/lib/picker.js +96 -6
- package/dist/lib/platform/index.d.ts +1 -0
- package/dist/lib/platform/index.js +1 -0
- package/dist/lib/platform/winpath.d.ts +35 -0
- package/dist/lib/platform/winpath.js +86 -0
- package/dist/lib/plugins.d.ts +14 -0
- package/dist/lib/plugins.js +23 -0
- package/dist/lib/project-launch.js +110 -5
- package/dist/lib/registry.js +15 -2
- package/dist/lib/runner.js +14 -0
- package/dist/lib/sandbox.js +5 -2
- package/dist/lib/settings-manifest.d.ts +39 -0
- package/dist/lib/settings-manifest.js +163 -0
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +16 -31
- package/dist/lib/staleness/detectors/subagents.js +16 -0
- package/dist/lib/staleness/writers/subagents.js +11 -3
- package/dist/lib/subagents.d.ts +9 -0
- package/dist/lib/subagents.js +33 -0
- package/dist/lib/teams/agents.js +1 -1
- package/dist/lib/teams/parsers.d.ts +1 -1
- package/dist/lib/teams/parsers.js +6 -0
- package/dist/lib/types.d.ts +1 -1
- package/dist/lib/versions.d.ts +15 -3
- package/dist/lib/versions.js +88 -19
- package/dist/lib/wallet/index.d.ts +78 -0
- package/dist/lib/wallet/index.js +253 -0
- package/package.json +3 -3
- package/scripts/postinstall.js +35 -7
package/dist/lib/agents.js
CHANGED
|
@@ -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
|
-
*
|
|
95
|
-
*
|
|
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
|
|
105
|
-
if (
|
|
106
|
-
return
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
116
|
-
if (!
|
|
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(
|
|
122
|
+
const stat = fs.statSync(binaryPath);
|
|
120
123
|
if (stat.mtimeMs > latestMtime) {
|
|
121
124
|
latestMtime = stat.mtimeMs;
|
|
122
|
-
latest =
|
|
125
|
+
latest = binaryPath;
|
|
123
126
|
}
|
|
124
127
|
}
|
|
125
128
|
catch { }
|
|
126
129
|
}
|
|
127
|
-
return latest
|
|
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>;
|
package/dist/lib/browser/ipc.js
CHANGED
|
@@ -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
|
|
456
|
+
let versionReconciledThisProcess = false;
|
|
457
457
|
/**
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
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
|
-
|
|
465
|
-
if (
|
|
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
|
-
|
|
478
|
+
versionReconciledThisProcess = true;
|
|
479
|
+
let daemon;
|
|
468
480
|
try {
|
|
469
|
-
const resp = await sendRawIPCRequest({ action: 'version' });
|
|
470
|
-
|
|
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
|
-
//
|
|
479
|
-
|
|
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
|
-
|
|
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 = '';
|
package/dist/lib/capabilities.js
CHANGED
|
@@ -24,7 +24,13 @@ function compareVersions(a, b) {
|
|
|
24
24
|
return 0;
|
|
25
25
|
}
|
|
26
26
|
function getCapability(agent, cap) {
|
|
27
|
-
|
|
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
|
-
|
|
78
|
-
|
|
77
|
+
function findSkillSourceDir(skillName, skillSourceDirs) {
|
|
78
|
+
for (const dir of skillSourceDirs) {
|
|
79
79
|
if (!dir)
|
|
80
|
-
|
|
80
|
+
continue;
|
|
81
81
|
const candidate = path.join(dir, skillName);
|
|
82
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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);
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -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
|
|
361
|
-
// For Codex, 'exec' is the headless subcommand
|
|
362
|
-
// so we
|
|
363
|
-
|
|
364
|
-
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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;
|
package/dist/lib/models.js
CHANGED
|
@@ -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
|
}
|
package/dist/lib/picker.d.ts
CHANGED
|
@@ -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>;
|