@phnx-labs/agents-cli 1.20.56 → 1.20.58
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 +26 -1
- package/README.md +34 -3
- package/dist/bin/agents +0 -0
- package/dist/commands/defaults.js +24 -0
- package/dist/commands/exec.js +28 -4
- package/dist/commands/secrets.d.ts +3 -2
- package/dist/commands/secrets.js +35 -25
- package/dist/commands/teams.d.ts +20 -1
- package/dist/commands/teams.js +105 -2
- package/dist/commands/versions.js +11 -3
- package/dist/commands/view.js +19 -4
- package/dist/lib/agents.d.ts +21 -0
- package/dist/lib/agents.js +28 -4
- package/dist/lib/daemon.d.ts +5 -5
- package/dist/lib/daemon.js +88 -17
- package/dist/lib/git.d.ts +9 -0
- package/dist/lib/git.js +12 -0
- package/dist/lib/hosts/dispatch.d.ts +21 -0
- package/dist/lib/hosts/dispatch.js +88 -5
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/permissions.d.ts +19 -1
- package/dist/lib/permissions.js +137 -0
- package/dist/lib/project-root.d.ts +65 -0
- package/dist/lib/project-root.js +133 -0
- package/dist/lib/resources/permissions.js +2 -0
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/secrets/agent.d.ts +48 -18
- package/dist/lib/secrets/agent.js +288 -165
- package/dist/lib/secrets/remote.js +1 -0
- package/dist/lib/session/active.d.ts +3 -0
- package/dist/lib/session/active.js +1 -0
- package/dist/lib/session/parse.js +38 -15
- package/dist/lib/session/state.d.ts +4 -1
- package/dist/lib/session/state.js +18 -1
- package/dist/lib/session/types.d.ts +8 -0
- package/dist/lib/staleness/detectors/permissions.js +42 -0
- package/dist/lib/staleness/detectors/subagents.js +30 -0
- package/dist/lib/staleness/writers/subagents.js +13 -1
- package/dist/lib/subagents.d.ts +22 -0
- package/dist/lib/subagents.js +146 -0
- package/dist/lib/teams/agents.d.ts +30 -0
- package/dist/lib/teams/agents.js +271 -42
- package/dist/lib/types.d.ts +13 -0
- package/dist/lib/versions.d.ts +39 -0
- package/dist/lib/versions.js +199 -12
- package/package.json +1 -1
- package/scripts/postinstall.js +26 -11
package/dist/commands/view.js
CHANGED
|
@@ -7,7 +7,7 @@ import * as path from 'path';
|
|
|
7
7
|
import { AGENTS, ALL_AGENT_IDS, getAllCliStates, getAccountInfo, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
|
|
8
8
|
import { deriveUsageStatusFromSnapshot, formatUsageSection, formatUsageSummary, formatUsageStatusBadge, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
|
|
9
9
|
import { readManifest } from '../lib/manifest.js';
|
|
10
|
-
import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, printTrashFooter, reconcileStaleLatestForAgent, } from '../lib/versions.js';
|
|
10
|
+
import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, printTrashFooter, reconcileStaleLatestForAgent, isGlobalBinaryAgent, getLiveVersion, } from '../lib/versions.js';
|
|
11
11
|
import { ensureVersionedAliasCurrent, removeShim, } from '../lib/shims.js';
|
|
12
12
|
import { getAgentResources } from '../lib/resources.js';
|
|
13
13
|
import { resolveVersionFilter, AgentSpecError } from '../lib/agent-spec/index.js';
|
|
@@ -358,6 +358,19 @@ async function showInstalledVersions(filterAgentId) {
|
|
|
358
358
|
profileOnly.push(agentId);
|
|
359
359
|
}
|
|
360
360
|
}
|
|
361
|
+
// For self-updating global-binary agents (droid) the on-disk version-dir name
|
|
362
|
+
// is a stale label — the real version is whatever `<cli> --version` reports.
|
|
363
|
+
// Resolve it once so every row/width pass shows the live version, while the
|
|
364
|
+
// per-version home + account lookups keep using the real dir name.
|
|
365
|
+
const liveVersionByAgent = new Map();
|
|
366
|
+
await Promise.all(versionManaged
|
|
367
|
+
.filter((agentId) => isGlobalBinaryAgent(agentId))
|
|
368
|
+
.map(async (agentId) => {
|
|
369
|
+
const live = await getLiveVersion(agentId);
|
|
370
|
+
if (live)
|
|
371
|
+
liveVersionByAgent.set(agentId, live);
|
|
372
|
+
}));
|
|
373
|
+
const displayVersion = (agentId, dirVersion) => liveVersionByAgent.get(agentId) ?? dirVersion;
|
|
361
374
|
// Show version-managed agents
|
|
362
375
|
if (versionManaged.length > 0) {
|
|
363
376
|
// Calculate column widths across all agents for alignment
|
|
@@ -370,7 +383,8 @@ async function showInstalledVersions(filterAgentId) {
|
|
|
370
383
|
const versions = listInstalledVersions(agentId);
|
|
371
384
|
const globalDefault = getGlobalDefault(agentId);
|
|
372
385
|
for (const v of versions) {
|
|
373
|
-
const
|
|
386
|
+
const shown = displayVersion(agentId, v);
|
|
387
|
+
const label = v === globalDefault ? `${shown} (default)` : shown;
|
|
374
388
|
maxVerLabel = Math.max(maxVerLabel, label.length);
|
|
375
389
|
const rawInfo = infoMap.get(`${agentId}:${v}`);
|
|
376
390
|
const info = rawInfo ? mergeCanonical(rawInfo) : undefined;
|
|
@@ -423,9 +437,10 @@ async function showInstalledVersions(filterAgentId) {
|
|
|
423
437
|
});
|
|
424
438
|
for (const version of sortedVersions) {
|
|
425
439
|
const isDefault = version === globalDefault;
|
|
426
|
-
const
|
|
440
|
+
const shown = displayVersion(agentId, version);
|
|
441
|
+
const base = isDefault ? `${shown} (default)` : shown;
|
|
427
442
|
const padded = base.padEnd(maxVerLabel);
|
|
428
|
-
const label = isDefault ? `${
|
|
443
|
+
const label = isDefault ? `${shown}${chalk.green(' (default)')}${' '.repeat(maxVerLabel - base.length)}` : padded;
|
|
429
444
|
const rawInfo = infoMap.get(`${agentId}:${version}`);
|
|
430
445
|
const vInfo = rawInfo ? mergeCanonical(rawInfo) : undefined;
|
|
431
446
|
const usageKey = getUsageLookupKey(vInfo);
|
package/dist/lib/agents.d.ts
CHANGED
|
@@ -34,6 +34,27 @@ export declare function findInPath(command: string): string | null;
|
|
|
34
34
|
export declare const AGENTS: Record<AgentId, AgentConfig>;
|
|
35
35
|
/** All registered agent IDs derived from the AGENTS registry. */
|
|
36
36
|
export declare const ALL_AGENT_IDS: AgentId[];
|
|
37
|
+
/**
|
|
38
|
+
* A self-updating agent is a single global binary installed by an official
|
|
39
|
+
* `curl … | sh` / `brew install` script that carries NO version token — the
|
|
40
|
+
* installer can only ever fetch the *current* release, and the binary then keeps
|
|
41
|
+
* itself up to date in place (droid, grok, antigravity, cursor, hermes, forge,
|
|
42
|
+
* kiro, goose). There is no semver to pin, so agents-cli must not model these as
|
|
43
|
+
* having multiple installable version-homes the way it does for npm-packaged
|
|
44
|
+
* agents (claude, codex, kimi, …).
|
|
45
|
+
*
|
|
46
|
+
* The predicate is `!npmPackage && installScript && !installScript.includes('VERSION')`:
|
|
47
|
+
* - `npmPackage` empty → not installed from npm, so `agents add x@1.2.3`
|
|
48
|
+
* can't resolve a registry version.
|
|
49
|
+
* - `installScript` present → it IS installed by a script (not unmanaged).
|
|
50
|
+
* - no `VERSION` placeholder → the script has no slot for a pinned version
|
|
51
|
+
* (contrast: an installer templated with `VERSION`
|
|
52
|
+
* could pin, and is NOT self-updating).
|
|
53
|
+
*
|
|
54
|
+
* Route every "is this a pinnable, multi-version agent?" decision through here —
|
|
55
|
+
* never a scattered `agent === 'droid'`.
|
|
56
|
+
*/
|
|
57
|
+
export declare function isSelfUpdatingAgent(agent: AgentId): boolean;
|
|
37
58
|
/** Get the chalk color function for an agent. Works for any AgentId or SessionAgentId. */
|
|
38
59
|
export declare function colorAgent(agentId: string): (s: string) => string;
|
|
39
60
|
/** Return the agent's display name, colored. */
|
package/dist/lib/agents.js
CHANGED
|
@@ -290,7 +290,7 @@ export const AGENTS = {
|
|
|
290
290
|
format: 'markdown',
|
|
291
291
|
variableSyntax: '$ARGUMENTS',
|
|
292
292
|
supportsHooks: true,
|
|
293
|
-
capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist:
|
|
293
|
+
capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: true, skills: true, commands: true, plugins: true, subagents: false, rules: { file: '.cursorrules' }, workflows: false, memory: false, modes: ['edit', 'skip'] }, // allowlist: ~/.cursor/cli-config.json
|
|
294
294
|
},
|
|
295
295
|
opencode: {
|
|
296
296
|
id: 'opencode',
|
|
@@ -310,7 +310,7 @@ export const AGENTS = {
|
|
|
310
310
|
format: 'markdown',
|
|
311
311
|
variableSyntax: '$ARGUMENTS',
|
|
312
312
|
supportsHooks: false,
|
|
313
|
-
capabilities: { hooks: false, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: { since: '1.1.1' }, skills: true, commands: true, plugins: true, subagents: true, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['plan', 'edit'] },
|
|
313
|
+
capabilities: { hooks: false, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: { since: '1.1.1' }, skills: true, commands: true, plugins: true, subagents: true, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['plan', 'edit'] },
|
|
314
314
|
},
|
|
315
315
|
openclaw: {
|
|
316
316
|
id: 'openclaw',
|
|
@@ -353,7 +353,7 @@ export const AGENTS = {
|
|
|
353
353
|
format: 'markdown',
|
|
354
354
|
variableSyntax: '$ARGUMENTS',
|
|
355
355
|
supportsHooks: true,
|
|
356
|
-
capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: false, skills: true, commands: true, plugins: true, subagents:
|
|
356
|
+
capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: false, skills: true, commands: true, plugins: true, subagents: { since: '0.0.353' }, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['plan', 'edit', 'auto', 'skip'] },
|
|
357
357
|
},
|
|
358
358
|
amp: {
|
|
359
359
|
id: 'amp',
|
|
@@ -393,7 +393,7 @@ export const AGENTS = {
|
|
|
393
393
|
format: 'markdown',
|
|
394
394
|
variableSyntax: '$ARGUMENTS',
|
|
395
395
|
supportsHooks: true,
|
|
396
|
-
capabilities: { hooks: { since: '0.10.0' }, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist:
|
|
396
|
+
capabilities: { hooks: { since: '0.10.0' }, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: { since: '2.8.0' }, skills: true, commands: true, plugins: false, subagents: { since: '1.23.0' }, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['edit'] },
|
|
397
397
|
},
|
|
398
398
|
goose: {
|
|
399
399
|
id: 'goose',
|
|
@@ -661,6 +661,30 @@ export const AGENTS = {
|
|
|
661
661
|
};
|
|
662
662
|
/** All registered agent IDs derived from the AGENTS registry. */
|
|
663
663
|
export const ALL_AGENT_IDS = Object.keys(AGENTS);
|
|
664
|
+
/**
|
|
665
|
+
* A self-updating agent is a single global binary installed by an official
|
|
666
|
+
* `curl … | sh` / `brew install` script that carries NO version token — the
|
|
667
|
+
* installer can only ever fetch the *current* release, and the binary then keeps
|
|
668
|
+
* itself up to date in place (droid, grok, antigravity, cursor, hermes, forge,
|
|
669
|
+
* kiro, goose). There is no semver to pin, so agents-cli must not model these as
|
|
670
|
+
* having multiple installable version-homes the way it does for npm-packaged
|
|
671
|
+
* agents (claude, codex, kimi, …).
|
|
672
|
+
*
|
|
673
|
+
* The predicate is `!npmPackage && installScript && !installScript.includes('VERSION')`:
|
|
674
|
+
* - `npmPackage` empty → not installed from npm, so `agents add x@1.2.3`
|
|
675
|
+
* can't resolve a registry version.
|
|
676
|
+
* - `installScript` present → it IS installed by a script (not unmanaged).
|
|
677
|
+
* - no `VERSION` placeholder → the script has no slot for a pinned version
|
|
678
|
+
* (contrast: an installer templated with `VERSION`
|
|
679
|
+
* could pin, and is NOT self-updating).
|
|
680
|
+
*
|
|
681
|
+
* Route every "is this a pinnable, multi-version agent?" decision through here —
|
|
682
|
+
* never a scattered `agent === 'droid'`.
|
|
683
|
+
*/
|
|
684
|
+
export function isSelfUpdatingAgent(agent) {
|
|
685
|
+
const cfg = AGENTS[agent];
|
|
686
|
+
return !cfg.npmPackage && !!cfg.installScript && !cfg.installScript.includes('VERSION');
|
|
687
|
+
}
|
|
664
688
|
// Capability-filtered agent lists used to live here as `*_CAPABLE_AGENTS`
|
|
665
689
|
// constants. They were a frequent source of silent-skip bugs (e.g. grok
|
|
666
690
|
// rules sync gated on `COMMANDS_CAPABLE_AGENTS`). Use `capableAgents(cap)`
|
package/dist/lib/daemon.d.ts
CHANGED
|
@@ -82,12 +82,12 @@ export declare function readDaemonClaudeOAuthToken(opts?: {
|
|
|
82
82
|
*/
|
|
83
83
|
export declare function writeOwnerOnlyServiceManifest(filePath: string, content: string): void;
|
|
84
84
|
/** Generate a macOS launchd plist for auto-starting the daemon. */
|
|
85
|
-
export declare function generateLaunchdPlist(oauthToken?: string | null): string;
|
|
85
|
+
export declare function generateLaunchdPlist(oauthToken?: string | null, agentsBin?: string): string;
|
|
86
86
|
/** Generate a Linux systemd user unit for auto-starting the daemon. */
|
|
87
|
-
export declare function generateSystemdUnit(oauthToken?: string | null): string;
|
|
87
|
+
export declare function generateSystemdUnit(oauthToken?: string | null, agentsBin?: string): string;
|
|
88
88
|
export declare function getAgentsBinPath(argv1?: string | undefined, execPath?: string): string;
|
|
89
89
|
/** Start the daemon via launchd, systemd, or as a detached process. */
|
|
90
|
-
export declare function startDaemon(): {
|
|
90
|
+
export declare function startDaemon(agentsBin?: string): {
|
|
91
91
|
pid: number | null;
|
|
92
92
|
method: string;
|
|
93
93
|
};
|
|
@@ -129,8 +129,8 @@ export declare function buildDetachedDaemonEnv(baseEnv?: NodeJS.ProcessEnv, oaut
|
|
|
129
129
|
* Going through `process.execPath` means a real PE/binary is spawned with
|
|
130
130
|
* `detached: true` and no console, so nothing signals the daemon after launch.
|
|
131
131
|
*
|
|
132
|
-
* When the entry isn't a
|
|
133
|
-
*
|
|
132
|
+
* When the entry isn't a Node script (e.g. a native compiled launcher), run it
|
|
133
|
+
* directly — it owns its own runtime resolution.
|
|
134
134
|
*/
|
|
135
135
|
export declare function getDaemonLaunch(agentsBin?: string): {
|
|
136
136
|
command: string;
|
package/dist/lib/daemon.js
CHANGED
|
@@ -306,6 +306,28 @@ export async function runDaemon() {
|
|
|
306
306
|
catch (err) {
|
|
307
307
|
log('ERROR', `Stray daemon reaper failed: ${err.message}`);
|
|
308
308
|
}
|
|
309
|
+
// #416: host the secrets broker socket-first — before the scheduler and the
|
|
310
|
+
// heavy browser/session-sync services — so `agents secrets` resolves within
|
|
311
|
+
// ms of daemon start. Only host when no broker is already reachable, so we
|
|
312
|
+
// never orphan a live standalone broker's clients (that broker stays the
|
|
313
|
+
// server until it idle-exits or the daemon restarts). Best-effort: a failure
|
|
314
|
+
// here must not stop the daemon. Retiring the standalone launchd service is
|
|
315
|
+
// the follow-on (#416 step 2 / #417).
|
|
316
|
+
let hostedBroker = null;
|
|
317
|
+
try {
|
|
318
|
+
const { agentPing, startHostedBroker } = await import('./secrets/agent.js');
|
|
319
|
+
if ((await agentPing()).reachable) {
|
|
320
|
+
log('INFO', 'Secrets broker already running (standalone); daemon not hosting it');
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
hostedBroker = await startHostedBroker();
|
|
324
|
+
if (hostedBroker)
|
|
325
|
+
log('INFO', 'Secrets broker hosted in daemon (socket-first)');
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
log('WARN', `Secrets broker host skipped: ${err.message}`);
|
|
330
|
+
}
|
|
309
331
|
const scheduler = new JobScheduler(async (config) => {
|
|
310
332
|
log('INFO', `Triggering job '${config.name}' (agent: ${config.agent})`);
|
|
311
333
|
try {
|
|
@@ -594,6 +616,7 @@ export async function runDaemon() {
|
|
|
594
616
|
clearTimeout(tmuxReconcileKickoff);
|
|
595
617
|
clearInterval(launchHealthInterval);
|
|
596
618
|
clearTimeout(launchHealthKickoff);
|
|
619
|
+
hostedBroker?.close();
|
|
597
620
|
removeDaemonPid();
|
|
598
621
|
removeHeartbeat();
|
|
599
622
|
process.exit(0);
|
|
@@ -651,8 +674,7 @@ export function writeOwnerOnlyServiceManifest(filePath, content) {
|
|
|
651
674
|
fs.writeFileSync(filePath, content, { encoding: 'utf-8', mode: 0o600 });
|
|
652
675
|
}
|
|
653
676
|
/** Generate a macOS launchd plist for auto-starting the daemon. */
|
|
654
|
-
export function generateLaunchdPlist(oauthToken = readDaemonClaudeOAuthToken()) {
|
|
655
|
-
const agentsBin = getAgentsBinPath();
|
|
677
|
+
export function generateLaunchdPlist(oauthToken = readDaemonClaudeOAuthToken(), agentsBin = getAgentsBinPath()) {
|
|
656
678
|
const launch = getDaemonLaunch(agentsBin);
|
|
657
679
|
const logPath = getLogPath();
|
|
658
680
|
const oauthEntry = oauthToken
|
|
@@ -681,7 +703,7 @@ ${[launch.command, ...launch.args].map((arg) => ` <string>${xmlEscape(arg)}</
|
|
|
681
703
|
<key>EnvironmentVariables</key>
|
|
682
704
|
<dict>
|
|
683
705
|
<key>PATH</key>
|
|
684
|
-
<string
|
|
706
|
+
<string>${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.bun/bin</string>${oauthEntry}
|
|
685
707
|
</dict>
|
|
686
708
|
</dict>
|
|
687
709
|
</plist>`;
|
|
@@ -691,8 +713,7 @@ function systemdExecArg(value) {
|
|
|
691
713
|
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
692
714
|
}
|
|
693
715
|
/** Generate a Linux systemd user unit for auto-starting the daemon. */
|
|
694
|
-
export function generateSystemdUnit(oauthToken = readDaemonClaudeOAuthToken()) {
|
|
695
|
-
const agentsBin = getAgentsBinPath();
|
|
716
|
+
export function generateSystemdUnit(oauthToken = readDaemonClaudeOAuthToken(), agentsBin = getAgentsBinPath()) {
|
|
696
717
|
const launch = getDaemonLaunch(agentsBin);
|
|
697
718
|
const execStart = [launch.command, ...launch.args].map(systemdExecArg).join(' ');
|
|
698
719
|
const oauthLine = oauthToken
|
|
@@ -707,7 +728,7 @@ Type=simple
|
|
|
707
728
|
ExecStart=${execStart}
|
|
708
729
|
Restart=always
|
|
709
730
|
RestartSec=10
|
|
710
|
-
Environment=PATH
|
|
731
|
+
Environment=PATH=${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin${oauthLine}
|
|
711
732
|
|
|
712
733
|
[Install]
|
|
713
734
|
WantedBy=default.target`;
|
|
@@ -779,7 +800,7 @@ function readServiceManagerPid(platform = os.platform()) {
|
|
|
779
800
|
return null;
|
|
780
801
|
}
|
|
781
802
|
/** Start the daemon via launchd, systemd, or as a detached process. */
|
|
782
|
-
export function startDaemon() {
|
|
803
|
+
export function startDaemon(agentsBin) {
|
|
783
804
|
if (isDaemonRunning()) {
|
|
784
805
|
const pid = readDaemonPid();
|
|
785
806
|
return { pid, method: 'already-running' };
|
|
@@ -791,7 +812,7 @@ export function startDaemon() {
|
|
|
791
812
|
return { pid, method: 'already-starting' };
|
|
792
813
|
}
|
|
793
814
|
try {
|
|
794
|
-
return startDaemonLocked();
|
|
815
|
+
return startDaemonLocked(agentsBin ?? getAgentsBinPath());
|
|
795
816
|
}
|
|
796
817
|
finally {
|
|
797
818
|
releaseLock();
|
|
@@ -815,7 +836,7 @@ export function ensureDaemonStarted() {
|
|
|
815
836
|
return null;
|
|
816
837
|
}
|
|
817
838
|
}
|
|
818
|
-
function startDaemonLocked() {
|
|
839
|
+
function startDaemonLocked(agentsBin) {
|
|
819
840
|
const platform = os.platform();
|
|
820
841
|
if (platform === 'darwin') {
|
|
821
842
|
try {
|
|
@@ -826,7 +847,7 @@ function startDaemonLocked() {
|
|
|
826
847
|
}
|
|
827
848
|
// The plist may embed a long-lived OAuth token in EnvironmentVariables;
|
|
828
849
|
// create owner-only atomically (no world-readable window before chmod).
|
|
829
|
-
writeOwnerOnlyServiceManifest(plistPath, generateLaunchdPlist());
|
|
850
|
+
writeOwnerOnlyServiceManifest(plistPath, generateLaunchdPlist(undefined, agentsBin));
|
|
830
851
|
try {
|
|
831
852
|
execFileSync('launchctl', ['unload', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
832
853
|
}
|
|
@@ -844,7 +865,7 @@ function startDaemonLocked() {
|
|
|
844
865
|
catch {
|
|
845
866
|
// load threw — fall through to detached spawn
|
|
846
867
|
}
|
|
847
|
-
return startDetached();
|
|
868
|
+
return startDetached({ agentsBin });
|
|
848
869
|
}
|
|
849
870
|
if (platform === 'linux') {
|
|
850
871
|
try {
|
|
@@ -854,7 +875,7 @@ function startDaemonLocked() {
|
|
|
854
875
|
fs.mkdirSync(unitDir, { recursive: true });
|
|
855
876
|
}
|
|
856
877
|
// May embed a long-lived OAuth token in an Environment= line; owner-only.
|
|
857
|
-
writeOwnerOnlyServiceManifest(unitPath, generateSystemdUnit());
|
|
878
|
+
writeOwnerOnlyServiceManifest(unitPath, generateSystemdUnit(undefined, agentsBin));
|
|
858
879
|
execFileSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf-8' });
|
|
859
880
|
execFileSync('systemctl', ['--user', 'enable', SYSTEMD_UNIT], { encoding: 'utf-8' });
|
|
860
881
|
execFileSync('systemctl', ['--user', 'start', SYSTEMD_UNIT], { encoding: 'utf-8' });
|
|
@@ -867,9 +888,9 @@ function startDaemonLocked() {
|
|
|
867
888
|
catch {
|
|
868
889
|
// start threw — fall through to detached spawn
|
|
869
890
|
}
|
|
870
|
-
return startDetached();
|
|
891
|
+
return startDetached({ agentsBin });
|
|
871
892
|
}
|
|
872
|
-
return startDetached();
|
|
893
|
+
return startDetached({ agentsBin });
|
|
873
894
|
}
|
|
874
895
|
/**
|
|
875
896
|
* Environment for the detached daemon fallback. The launchd/systemd paths
|
|
@@ -902,18 +923,68 @@ export function buildDetachedDaemonEnv(baseEnv = process.env, oauthToken = readD
|
|
|
902
923
|
* Going through `process.execPath` means a real PE/binary is spawned with
|
|
903
924
|
* `detached: true` and no console, so nothing signals the daemon after launch.
|
|
904
925
|
*
|
|
905
|
-
* When the entry isn't a
|
|
906
|
-
*
|
|
926
|
+
* When the entry isn't a Node script (e.g. a native compiled launcher), run it
|
|
927
|
+
* directly — it owns its own runtime resolution.
|
|
907
928
|
*/
|
|
908
929
|
export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
|
|
909
930
|
const { warnings } = validateDaemonBinary(agentsBin);
|
|
910
931
|
for (const w of warnings)
|
|
911
932
|
process.stderr.write(`[agents] ${w}\n`);
|
|
912
|
-
if (
|
|
933
|
+
if (isNodeScriptEntry(agentsBin)) {
|
|
913
934
|
return { command: process.execPath, args: [agentsBin, 'daemon', '_run'] };
|
|
914
935
|
}
|
|
915
936
|
return { command: agentsBin, args: ['daemon', '_run'] };
|
|
916
937
|
}
|
|
938
|
+
/**
|
|
939
|
+
* A daemon entry must be launched through the Node runtime when it is a Node
|
|
940
|
+
* script — a `.js`/`.cjs`/`.mjs` file, OR a symlink/extension-less shim whose
|
|
941
|
+
* shebang names `node`. Package installs link `bin/agents` to a `dist/index.js`
|
|
942
|
+
* (a symlink) or drop an extension-less `#!/usr/bin/env node` shim, so an
|
|
943
|
+
* extension check alone misses them and they get run directly. Executing such an
|
|
944
|
+
* entry then relies on the shebang resolving `node` off the daemon's PATH — and
|
|
945
|
+
* when that PATH points at a pruned nvm version (or an ancient system node), the
|
|
946
|
+
* daemon crash-loops at import (`node:util` has no `styleText` on Node 18). A
|
|
947
|
+
* real compiled binary (Mach-O/ELF/PE) has no `#!node` shebang, so it takes the
|
|
948
|
+
* direct branch and owns its own runtime resolution.
|
|
949
|
+
*/
|
|
950
|
+
function isNodeScriptEntry(agentsBin) {
|
|
951
|
+
let resolved = agentsBin;
|
|
952
|
+
try {
|
|
953
|
+
resolved = fs.realpathSync(agentsBin);
|
|
954
|
+
}
|
|
955
|
+
catch {
|
|
956
|
+
// Unresolvable (e.g. a template path that does not exist on this box): fall
|
|
957
|
+
// back to the extension check on the path as given.
|
|
958
|
+
}
|
|
959
|
+
if (/\.(c|m)?js$/.test(resolved))
|
|
960
|
+
return true;
|
|
961
|
+
try {
|
|
962
|
+
const fd = fs.openSync(resolved, 'r');
|
|
963
|
+
try {
|
|
964
|
+
const buf = Buffer.alloc(128);
|
|
965
|
+
const n = fs.readSync(fd, buf, 0, 128, 0);
|
|
966
|
+
const firstLine = buf.toString('utf-8', 0, n).split('\n', 1)[0];
|
|
967
|
+
return firstLine.startsWith('#!') && /\bnode\b/.test(firstLine);
|
|
968
|
+
}
|
|
969
|
+
finally {
|
|
970
|
+
fs.closeSync(fd);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
catch {
|
|
974
|
+
return false;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* The directory of the Node runtime that generated this service manifest, kept
|
|
979
|
+
* first on the daemon's PATH. Both the shim's shebang and any child routine
|
|
980
|
+
* process then resolve the exact Node that installed the service — never an
|
|
981
|
+
* ancient system node or a pruned nvm version. Replaces the old hardcoded
|
|
982
|
+
* `~/.nvm/versions/node/v24.0.0/bin`, which went stale the moment that patch
|
|
983
|
+
* release was upgraded away and bricked the daemon fleet-wide.
|
|
984
|
+
*/
|
|
985
|
+
function daemonNodeBinDir() {
|
|
986
|
+
return path.dirname(process.execPath);
|
|
987
|
+
}
|
|
917
988
|
/**
|
|
918
989
|
* Build the argv to relaunch the `agents` CLI with the given subcommand args.
|
|
919
990
|
*
|
package/dist/lib/git.d.ts
CHANGED
|
@@ -107,6 +107,15 @@ export declare function isGitRepo(dir: string): boolean;
|
|
|
107
107
|
* {@link isGitRepo} above). Throws if `dir` is not inside a git repository.
|
|
108
108
|
*/
|
|
109
109
|
export declare function getGitRoot(dir: string): Promise<string>;
|
|
110
|
+
/**
|
|
111
|
+
* Return the absolute path to the **main** working-tree root for `dir`.
|
|
112
|
+
*
|
|
113
|
+
* Unlike {@link getGitRoot}, this stays correct when `dir` is inside a *linked*
|
|
114
|
+
* worktree: `--show-toplevel` there returns the worktree's own path, but the
|
|
115
|
+
* common git dir (`--git-common-dir`) always points at the primary repo's
|
|
116
|
+
* `.git`, whose parent is the main checkout. Throws if `dir` is not in a repo.
|
|
117
|
+
*/
|
|
118
|
+
export declare function getMainRepoRoot(dir: string): Promise<string>;
|
|
110
119
|
/**
|
|
111
120
|
* Initialize a git repo in an existing directory.
|
|
112
121
|
*/
|
package/dist/lib/git.js
CHANGED
|
@@ -427,6 +427,18 @@ export async function getGitRoot(dir) {
|
|
|
427
427
|
const root = await simpleGit(dir).revparse(['--show-toplevel']);
|
|
428
428
|
return root.trim();
|
|
429
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* Return the absolute path to the **main** working-tree root for `dir`.
|
|
432
|
+
*
|
|
433
|
+
* Unlike {@link getGitRoot}, this stays correct when `dir` is inside a *linked*
|
|
434
|
+
* worktree: `--show-toplevel` there returns the worktree's own path, but the
|
|
435
|
+
* common git dir (`--git-common-dir`) always points at the primary repo's
|
|
436
|
+
* `.git`, whose parent is the main checkout. Throws if `dir` is not in a repo.
|
|
437
|
+
*/
|
|
438
|
+
export async function getMainRepoRoot(dir) {
|
|
439
|
+
const common = await simpleGit(dir).raw(['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
440
|
+
return path.dirname(common.trim());
|
|
441
|
+
}
|
|
430
442
|
/**
|
|
431
443
|
* Initialize a git repo in an existing directory.
|
|
432
444
|
*/
|
|
@@ -10,11 +10,32 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { Host } from './types.js';
|
|
12
12
|
import { type HostTask } from './tasks.js';
|
|
13
|
+
/**
|
|
14
|
+
* Build a `cd <dir> && ` prefix that resolves on the REMOTE host.
|
|
15
|
+
*
|
|
16
|
+
* A `~`/`$HOME`-anchored path must resolve against the REMOTE user's home, not
|
|
17
|
+
* the local one (`/home/<me>` vs `/Users/<me>`). We emit an unquoted `"$HOME"`
|
|
18
|
+
* for that segment — the remote login shell expands it — and shell-quote the
|
|
19
|
+
* remainder. Any other path (absolute or relative) is quoted verbatim.
|
|
20
|
+
*/
|
|
21
|
+
export declare function remoteCdPrefix(remoteCwd?: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Launch a detached login-shell command in its own Unix session/process group.
|
|
24
|
+
*
|
|
25
|
+
* Node is already a hard requirement for a host that can run `agents`. Its
|
|
26
|
+
* `detached: true` contract calls setsid(2) on Unix, unlike `nohup ... &` under
|
|
27
|
+
* a non-interactive shell where the background wrapper can remain in the SSH
|
|
28
|
+
* shell's process group. Returning the group leader PID makes `kill(-pid)` a
|
|
29
|
+
* reliable whole-tree operation for both normal stops and rollback cleanup.
|
|
30
|
+
*/
|
|
31
|
+
export declare function buildDetachedLaunchCommand(inner: string): string;
|
|
13
32
|
export interface DispatchResult {
|
|
14
33
|
task: HostTask;
|
|
15
34
|
/** Exit code when followed; undefined when detached (--no-follow). */
|
|
16
35
|
exitCode?: number;
|
|
17
36
|
}
|
|
37
|
+
/** Terminate a detached dispatch that its caller could not persist locally. */
|
|
38
|
+
export declare function terminateDispatchedTask(task: HostTask): void;
|
|
18
39
|
export interface DispatchOptions {
|
|
19
40
|
agent: string;
|
|
20
41
|
prompt: string;
|
|
@@ -20,6 +20,77 @@ import { followHostTask } from './progress.js';
|
|
|
20
20
|
// regardless of the run's cwd. Task ids are 8 hex chars, so these paths are
|
|
21
21
|
// injection-safe to interpolate unquoted into remote commands.
|
|
22
22
|
const REMOTE_DIR = '$HOME/.agents/.cache/hosts';
|
|
23
|
+
/**
|
|
24
|
+
* If `p` is anchored at the home dir — a leading `~` or `$HOME` — return the
|
|
25
|
+
* remainder (no leading slash), else null. Callers that want a local-home
|
|
26
|
+
* absolute (`/Users/<me>/x`, from a shell-expanded `--cwd ~/x`) re-rooted at the
|
|
27
|
+
* remote home normalize it to `~/x` first (`toRemotePortable`); explicit
|
|
28
|
+
* `--remote-cwd` is left literal and so is never re-rooted here.
|
|
29
|
+
*/
|
|
30
|
+
function homeRemainder(p) {
|
|
31
|
+
if (p === '~' || p === '$HOME')
|
|
32
|
+
return '';
|
|
33
|
+
if (p.startsWith('~/'))
|
|
34
|
+
return p.slice(2);
|
|
35
|
+
if (p.startsWith('$HOME/'))
|
|
36
|
+
return p.slice(6);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Build a `cd <dir> && ` prefix that resolves on the REMOTE host.
|
|
41
|
+
*
|
|
42
|
+
* A `~`/`$HOME`-anchored path must resolve against the REMOTE user's home, not
|
|
43
|
+
* the local one (`/home/<me>` vs `/Users/<me>`). We emit an unquoted `"$HOME"`
|
|
44
|
+
* for that segment — the remote login shell expands it — and shell-quote the
|
|
45
|
+
* remainder. Any other path (absolute or relative) is quoted verbatim.
|
|
46
|
+
*/
|
|
47
|
+
export function remoteCdPrefix(remoteCwd) {
|
|
48
|
+
if (!remoteCwd)
|
|
49
|
+
return '';
|
|
50
|
+
const rest = homeRemainder(remoteCwd);
|
|
51
|
+
if (rest === '')
|
|
52
|
+
return 'cd "$HOME" && ';
|
|
53
|
+
if (rest !== null)
|
|
54
|
+
return `cd "$HOME"/${shellQuote(rest)} && `;
|
|
55
|
+
return `cd ${shellQuote(remoteCwd)} && `;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Launch a detached login-shell command in its own Unix session/process group.
|
|
59
|
+
*
|
|
60
|
+
* Node is already a hard requirement for a host that can run `agents`. Its
|
|
61
|
+
* `detached: true` contract calls setsid(2) on Unix, unlike `nohup ... &` under
|
|
62
|
+
* a non-interactive shell where the background wrapper can remain in the SSH
|
|
63
|
+
* shell's process group. Returning the group leader PID makes `kill(-pid)` a
|
|
64
|
+
* reliable whole-tree operation for both normal stops and rollback cleanup.
|
|
65
|
+
*/
|
|
66
|
+
export function buildDetachedLaunchCommand(inner) {
|
|
67
|
+
const nodeScript = [
|
|
68
|
+
"const { spawn } = require('node:child_process');",
|
|
69
|
+
`const child = spawn('/bin/bash', ['-lc', ${JSON.stringify(inner)}], { detached: true, stdio: 'ignore' });`,
|
|
70
|
+
"child.once('error', error => { console.error(error.message); process.exitCode = 1; });",
|
|
71
|
+
"child.once('spawn', () => { console.log(child.pid); child.unref(); });",
|
|
72
|
+
].join(' ');
|
|
73
|
+
return `bash -lc ${shellQuote(`node -e ${shellQuote(nodeScript)}`)}`;
|
|
74
|
+
}
|
|
75
|
+
function terminateRemoteLaunch(task) {
|
|
76
|
+
if (!task.pid)
|
|
77
|
+
throw new Error(`Cannot terminate remote task ${task.id}: launch returned no PID.`);
|
|
78
|
+
const pid = task.pid;
|
|
79
|
+
const command = `if kill -TERM -- -${pid} 2>/dev/null; then ` +
|
|
80
|
+
`sleep 1; kill -KILL -- -${pid} 2>/dev/null || true; ` +
|
|
81
|
+
`elif kill -0 -- -${pid} 2>/dev/null; then exit 1; fi; ` +
|
|
82
|
+
`rm -f ${task.remoteLog} ${task.remoteExit}`;
|
|
83
|
+
const result = sshExec(task.target, command, { timeoutMs: 10000, multiplex: true });
|
|
84
|
+
if (result.code !== 0) {
|
|
85
|
+
throw new Error(`Failed to terminate remote task ${task.id} on ${task.host}: ` +
|
|
86
|
+
`${(result.stderr || result.stdout).trim() || 'ssh error'}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Terminate a detached dispatch that its caller could not persist locally. */
|
|
90
|
+
export function terminateDispatchedTask(task) {
|
|
91
|
+
terminateRemoteLaunch(task);
|
|
92
|
+
updateTask(task.id, terminalPatch(143));
|
|
93
|
+
}
|
|
23
94
|
/**
|
|
24
95
|
* The launch + task-record + optional follow core. Both `dispatchToHost` (run)
|
|
25
96
|
* and `dispatchAgentsCommand` (teams) build their `forwardedArgs` and call here,
|
|
@@ -45,10 +116,11 @@ async function launchDetached(host, target, opts) {
|
|
|
45
116
|
const remoteExit = `${REMOTE_DIR}/${id}.exit`;
|
|
46
117
|
// Inner command run under a login shell so PATH resolves `agents`.
|
|
47
118
|
const invocation = ['agents', ...opts.forwardedArgs].map(shellQuote).join(' ');
|
|
48
|
-
const cwd =
|
|
119
|
+
const cwd = remoteCdPrefix(opts.remoteCwd);
|
|
49
120
|
const inner = `${cwd}${invocation} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
|
|
50
|
-
// Outer: ensure dir, launch
|
|
51
|
-
|
|
121
|
+
// Outer: ensure dir, launch the login-shell wrapper as a new process-group
|
|
122
|
+
// leader, and print that leader PID.
|
|
123
|
+
const launch = `mkdir -p ${REMOTE_DIR}; ${buildDetachedLaunchCommand(inner)}`;
|
|
52
124
|
const res = sshExec(target, launch, { timeoutMs: 30000, multiplex: true });
|
|
53
125
|
if (res.code !== 0) {
|
|
54
126
|
throw new Error(`Failed to launch on "${host.name}": ${(res.stderr || res.stdout).trim() || 'ssh error'}`);
|
|
@@ -68,7 +140,18 @@ async function launchDetached(host, target, opts) {
|
|
|
68
140
|
status: 'running',
|
|
69
141
|
createdAt: new Date().toISOString(),
|
|
70
142
|
};
|
|
71
|
-
|
|
143
|
+
try {
|
|
144
|
+
saveTask(task);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
try {
|
|
148
|
+
terminateRemoteLaunch(task);
|
|
149
|
+
}
|
|
150
|
+
catch (cleanupErr) {
|
|
151
|
+
throw new Error(`Failed to persist remote task ${task.id}; cleanup also failed: ${cleanupErr.message}`, { cause: err });
|
|
152
|
+
}
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
72
155
|
if (opts.follow === false) {
|
|
73
156
|
return { task };
|
|
74
157
|
}
|
|
@@ -146,7 +229,7 @@ export async function runInteractiveOnHost(host, opts) {
|
|
|
146
229
|
for (const w of warnings)
|
|
147
230
|
process.stderr.write(`[hosts] warning: ${w}\n`);
|
|
148
231
|
const invocation = ['agents', ...buildInteractiveRunForwardedArgs(opts)].map(shellQuote).join(' ');
|
|
149
|
-
const cwd =
|
|
232
|
+
const cwd = remoteCdPrefix(opts.remoteCwd);
|
|
150
233
|
const remoteCmd = `${cwd}${invocation}`;
|
|
151
234
|
return sshStream(target, remoteCmd, { tty: process.stdin.isTTY, multiplex: true });
|
|
152
235
|
}
|
|
Binary file
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentId, PermissionSet, InstalledPermission, ClaudePermissions, OpenCodePermissions, CodexPermissions } from './types.js';
|
|
1
|
+
import type { AgentId, PermissionSet, InstalledPermission, ClaudePermissions, CursorPermissions, OpenCodePermissions, CodexPermissions } from './types.js';
|
|
2
2
|
/** Filename used for Codex Starlark deny-rules generated from permission groups. */
|
|
3
3
|
export declare const CODEX_RULES_FILENAME = "agents-deny.rules";
|
|
4
4
|
export type ParsedRules = PermissionSet;
|
|
@@ -99,6 +99,11 @@ export declare function removePermissionSet(name: string): {
|
|
|
99
99
|
* Claude uses: { permissions: { allow: ["Bash(*)", "Read(**)"], deny: [] } }
|
|
100
100
|
*/
|
|
101
101
|
export declare function convertToClaudeFormat(set: PermissionSet): ClaudePermissions;
|
|
102
|
+
/**
|
|
103
|
+
* Convert canonical permission set to Cursor CLI format
|
|
104
|
+
* (`~/.cursor/cli-config.json` permissions.allow/deny).
|
|
105
|
+
*/
|
|
106
|
+
export declare function convertToCursorFormat(set: PermissionSet): CursorPermissions;
|
|
102
107
|
/**
|
|
103
108
|
* Convert canonical permission set to Gemini format.
|
|
104
109
|
* Gemini reads tool allow-lists from settings.json under `tools.allowed`.
|
|
@@ -152,6 +157,19 @@ export type KimiRule = {
|
|
|
152
157
|
decision: 'allow' | 'deny';
|
|
153
158
|
pattern: string;
|
|
154
159
|
};
|
|
160
|
+
export type KiroRule = {
|
|
161
|
+
capability: string;
|
|
162
|
+
effect: 'allow' | 'deny';
|
|
163
|
+
match?: string[];
|
|
164
|
+
exclude?: string[];
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* Convert canonical permissions to Kiro CLI v3 capability rules.
|
|
168
|
+
* Kiro stores these rules in ~/.kiro/settings/permissions.yaml.
|
|
169
|
+
*/
|
|
170
|
+
export declare function convertToKiroFormat(set: PermissionSet): {
|
|
171
|
+
rules: KiroRule[];
|
|
172
|
+
};
|
|
155
173
|
/**
|
|
156
174
|
* Convert a canonical permission set to Kimi Code's `[permission].rules` format.
|
|
157
175
|
* Kimi (`~/.kimi-code/config.toml`) reads rules of the form
|