@phnx-labs/agents-cli 1.20.57 → 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 +20 -0
- 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.js +28 -19
- 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 +65 -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/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 +26 -23
- package/dist/lib/secrets/agent.js +196 -216
- 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 +14 -0
- package/dist/lib/teams/agents.js +158 -22
- 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/lib/daemon.js
CHANGED
|
@@ -674,8 +674,7 @@ export function writeOwnerOnlyServiceManifest(filePath, content) {
|
|
|
674
674
|
fs.writeFileSync(filePath, content, { encoding: 'utf-8', mode: 0o600 });
|
|
675
675
|
}
|
|
676
676
|
/** Generate a macOS launchd plist for auto-starting the daemon. */
|
|
677
|
-
export function generateLaunchdPlist(oauthToken = readDaemonClaudeOAuthToken()) {
|
|
678
|
-
const agentsBin = getAgentsBinPath();
|
|
677
|
+
export function generateLaunchdPlist(oauthToken = readDaemonClaudeOAuthToken(), agentsBin = getAgentsBinPath()) {
|
|
679
678
|
const launch = getDaemonLaunch(agentsBin);
|
|
680
679
|
const logPath = getLogPath();
|
|
681
680
|
const oauthEntry = oauthToken
|
|
@@ -704,7 +703,7 @@ ${[launch.command, ...launch.args].map((arg) => ` <string>${xmlEscape(arg)}</
|
|
|
704
703
|
<key>EnvironmentVariables</key>
|
|
705
704
|
<dict>
|
|
706
705
|
<key>PATH</key>
|
|
707
|
-
<string
|
|
706
|
+
<string>${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.bun/bin</string>${oauthEntry}
|
|
708
707
|
</dict>
|
|
709
708
|
</dict>
|
|
710
709
|
</plist>`;
|
|
@@ -714,8 +713,7 @@ function systemdExecArg(value) {
|
|
|
714
713
|
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
715
714
|
}
|
|
716
715
|
/** Generate a Linux systemd user unit for auto-starting the daemon. */
|
|
717
|
-
export function generateSystemdUnit(oauthToken = readDaemonClaudeOAuthToken()) {
|
|
718
|
-
const agentsBin = getAgentsBinPath();
|
|
716
|
+
export function generateSystemdUnit(oauthToken = readDaemonClaudeOAuthToken(), agentsBin = getAgentsBinPath()) {
|
|
719
717
|
const launch = getDaemonLaunch(agentsBin);
|
|
720
718
|
const execStart = [launch.command, ...launch.args].map(systemdExecArg).join(' ');
|
|
721
719
|
const oauthLine = oauthToken
|
|
@@ -730,7 +728,7 @@ Type=simple
|
|
|
730
728
|
ExecStart=${execStart}
|
|
731
729
|
Restart=always
|
|
732
730
|
RestartSec=10
|
|
733
|
-
Environment=PATH
|
|
731
|
+
Environment=PATH=${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin${oauthLine}
|
|
734
732
|
|
|
735
733
|
[Install]
|
|
736
734
|
WantedBy=default.target`;
|
|
@@ -802,7 +800,7 @@ function readServiceManagerPid(platform = os.platform()) {
|
|
|
802
800
|
return null;
|
|
803
801
|
}
|
|
804
802
|
/** Start the daemon via launchd, systemd, or as a detached process. */
|
|
805
|
-
export function startDaemon() {
|
|
803
|
+
export function startDaemon(agentsBin) {
|
|
806
804
|
if (isDaemonRunning()) {
|
|
807
805
|
const pid = readDaemonPid();
|
|
808
806
|
return { pid, method: 'already-running' };
|
|
@@ -814,7 +812,7 @@ export function startDaemon() {
|
|
|
814
812
|
return { pid, method: 'already-starting' };
|
|
815
813
|
}
|
|
816
814
|
try {
|
|
817
|
-
return startDaemonLocked();
|
|
815
|
+
return startDaemonLocked(agentsBin ?? getAgentsBinPath());
|
|
818
816
|
}
|
|
819
817
|
finally {
|
|
820
818
|
releaseLock();
|
|
@@ -838,7 +836,7 @@ export function ensureDaemonStarted() {
|
|
|
838
836
|
return null;
|
|
839
837
|
}
|
|
840
838
|
}
|
|
841
|
-
function startDaemonLocked() {
|
|
839
|
+
function startDaemonLocked(agentsBin) {
|
|
842
840
|
const platform = os.platform();
|
|
843
841
|
if (platform === 'darwin') {
|
|
844
842
|
try {
|
|
@@ -849,7 +847,7 @@ function startDaemonLocked() {
|
|
|
849
847
|
}
|
|
850
848
|
// The plist may embed a long-lived OAuth token in EnvironmentVariables;
|
|
851
849
|
// create owner-only atomically (no world-readable window before chmod).
|
|
852
|
-
writeOwnerOnlyServiceManifest(plistPath, generateLaunchdPlist());
|
|
850
|
+
writeOwnerOnlyServiceManifest(plistPath, generateLaunchdPlist(undefined, agentsBin));
|
|
853
851
|
try {
|
|
854
852
|
execFileSync('launchctl', ['unload', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
855
853
|
}
|
|
@@ -867,7 +865,7 @@ function startDaemonLocked() {
|
|
|
867
865
|
catch {
|
|
868
866
|
// load threw — fall through to detached spawn
|
|
869
867
|
}
|
|
870
|
-
return startDetached();
|
|
868
|
+
return startDetached({ agentsBin });
|
|
871
869
|
}
|
|
872
870
|
if (platform === 'linux') {
|
|
873
871
|
try {
|
|
@@ -877,7 +875,7 @@ function startDaemonLocked() {
|
|
|
877
875
|
fs.mkdirSync(unitDir, { recursive: true });
|
|
878
876
|
}
|
|
879
877
|
// May embed a long-lived OAuth token in an Environment= line; owner-only.
|
|
880
|
-
writeOwnerOnlyServiceManifest(unitPath, generateSystemdUnit());
|
|
878
|
+
writeOwnerOnlyServiceManifest(unitPath, generateSystemdUnit(undefined, agentsBin));
|
|
881
879
|
execFileSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf-8' });
|
|
882
880
|
execFileSync('systemctl', ['--user', 'enable', SYSTEMD_UNIT], { encoding: 'utf-8' });
|
|
883
881
|
execFileSync('systemctl', ['--user', 'start', SYSTEMD_UNIT], { encoding: 'utf-8' });
|
|
@@ -890,9 +888,9 @@ function startDaemonLocked() {
|
|
|
890
888
|
catch {
|
|
891
889
|
// start threw — fall through to detached spawn
|
|
892
890
|
}
|
|
893
|
-
return startDetached();
|
|
891
|
+
return startDetached({ agentsBin });
|
|
894
892
|
}
|
|
895
|
-
return startDetached();
|
|
893
|
+
return startDetached({ agentsBin });
|
|
896
894
|
}
|
|
897
895
|
/**
|
|
898
896
|
* Environment for the detached daemon fallback. The launchd/systemd paths
|
|
@@ -925,18 +923,68 @@ export function buildDetachedDaemonEnv(baseEnv = process.env, oauthToken = readD
|
|
|
925
923
|
* Going through `process.execPath` means a real PE/binary is spawned with
|
|
926
924
|
* `detached: true` and no console, so nothing signals the daemon after launch.
|
|
927
925
|
*
|
|
928
|
-
* When the entry isn't a
|
|
929
|
-
*
|
|
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.
|
|
930
928
|
*/
|
|
931
929
|
export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
|
|
932
930
|
const { warnings } = validateDaemonBinary(agentsBin);
|
|
933
931
|
for (const w of warnings)
|
|
934
932
|
process.stderr.write(`[agents] ${w}\n`);
|
|
935
|
-
if (
|
|
933
|
+
if (isNodeScriptEntry(agentsBin)) {
|
|
936
934
|
return { command: process.execPath, args: [agentsBin, 'daemon', '_run'] };
|
|
937
935
|
}
|
|
938
936
|
return { command: agentsBin, args: ['daemon', '_run'] };
|
|
939
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
|
+
}
|
|
940
988
|
/**
|
|
941
989
|
* Build the argv to relaunch the `agents` CLI with the given subcommand args.
|
|
942
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
|
}
|
|
@@ -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
|
package/dist/lib/permissions.js
CHANGED
|
@@ -440,6 +440,37 @@ export function convertToClaudeFormat(set) {
|
|
|
440
440
|
}
|
|
441
441
|
return { permissions };
|
|
442
442
|
}
|
|
443
|
+
/**
|
|
444
|
+
* Map a canonical rule to Cursor CLI syntax.
|
|
445
|
+
* Cursor uses Shell(...) instead of Bash(...); other tools keep TitleCase names.
|
|
446
|
+
* https://cursor.com/docs/cli/reference/permissions
|
|
447
|
+
*/
|
|
448
|
+
function canonicalToCursorRule(perm) {
|
|
449
|
+
if (perm === 'Bash' || perm.startsWith('Bash(')) {
|
|
450
|
+
return perm.replace(/^Bash/, 'Shell');
|
|
451
|
+
}
|
|
452
|
+
// Canonical WebSearch maps to WebFetch family for network allow.
|
|
453
|
+
if (perm.startsWith('WebSearch(')) {
|
|
454
|
+
return perm.replace(/^WebSearch/, 'WebFetch');
|
|
455
|
+
}
|
|
456
|
+
// Cursor has no Edit prefix — file writes use Write(...).
|
|
457
|
+
if (perm === 'Edit' || perm.startsWith('Edit(')) {
|
|
458
|
+
return perm.replace(/^Edit/, 'Write');
|
|
459
|
+
}
|
|
460
|
+
return perm;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Convert canonical permission set to Cursor CLI format
|
|
464
|
+
* (`~/.cursor/cli-config.json` permissions.allow/deny).
|
|
465
|
+
*/
|
|
466
|
+
export function convertToCursorFormat(set) {
|
|
467
|
+
return {
|
|
468
|
+
permissions: {
|
|
469
|
+
allow: set.allow.map(canonicalToCursorRule),
|
|
470
|
+
deny: (set.deny ?? []).map(canonicalToCursorRule),
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
}
|
|
443
474
|
/**
|
|
444
475
|
* Parse canonical permission pattern to extract tool and pattern.
|
|
445
476
|
* "Bash(git *)" -> { tool: "bash", pattern: "git *" }
|
|
@@ -592,6 +623,57 @@ function canonicalToGrokRule(perm, action) {
|
|
|
592
623
|
}
|
|
593
624
|
return { action, tool, pattern };
|
|
594
625
|
}
|
|
626
|
+
/**
|
|
627
|
+
* Convert canonical permissions to Kiro CLI v3 capability rules.
|
|
628
|
+
* Kiro stores these rules in ~/.kiro/settings/permissions.yaml.
|
|
629
|
+
*/
|
|
630
|
+
export function convertToKiroFormat(set) {
|
|
631
|
+
const rules = [];
|
|
632
|
+
for (const perm of set.allow) {
|
|
633
|
+
const rule = canonicalToKiroRule(perm, 'allow');
|
|
634
|
+
if (rule)
|
|
635
|
+
rules.push(rule);
|
|
636
|
+
}
|
|
637
|
+
for (const perm of set.deny ?? []) {
|
|
638
|
+
const rule = canonicalToKiroRule(perm, 'deny');
|
|
639
|
+
if (rule)
|
|
640
|
+
rules.push(rule);
|
|
641
|
+
}
|
|
642
|
+
return { rules };
|
|
643
|
+
}
|
|
644
|
+
function canonicalToKiroRule(perm, effect) {
|
|
645
|
+
if (BLANKET_BASH_FORMS.has(perm)) {
|
|
646
|
+
return { capability: 'shell', effect };
|
|
647
|
+
}
|
|
648
|
+
const parsed = parseCanonicalPreserveCase(perm);
|
|
649
|
+
const capability = KIRO_CAPABILITY_BY_TOOL[parsed.tool.toLowerCase()];
|
|
650
|
+
if (!capability)
|
|
651
|
+
return null;
|
|
652
|
+
if (parsed.pattern === null || parsed.pattern === '*' || parsed.pattern === '**') {
|
|
653
|
+
return { capability, effect };
|
|
654
|
+
}
|
|
655
|
+
const lowerTool = parsed.tool.toLowerCase();
|
|
656
|
+
const pattern = lowerTool === 'bash'
|
|
657
|
+
? normalizeBashPattern(parsed.pattern)
|
|
658
|
+
: (lowerTool === 'webfetch' || lowerTool === 'websearch') && parsed.pattern.startsWith('domain:')
|
|
659
|
+
? parsed.pattern.slice('domain:'.length)
|
|
660
|
+
: parsed.pattern;
|
|
661
|
+
return { capability, effect, match: [pattern] };
|
|
662
|
+
}
|
|
663
|
+
const KIRO_CAPABILITY_BY_TOOL = {
|
|
664
|
+
bash: 'shell',
|
|
665
|
+
read: 'fs_read',
|
|
666
|
+
grep: 'fs_read',
|
|
667
|
+
glob: 'fs_read',
|
|
668
|
+
write: 'fs_write',
|
|
669
|
+
edit: 'fs_write',
|
|
670
|
+
notebookedit: 'fs_write',
|
|
671
|
+
webfetch: 'web_fetch',
|
|
672
|
+
websearch: 'web_search',
|
|
673
|
+
mcp: 'mcp',
|
|
674
|
+
subagent: 'subagent',
|
|
675
|
+
skill: 'skill',
|
|
676
|
+
};
|
|
595
677
|
/**
|
|
596
678
|
* Parse a canonical permission string preserving the tool's original casing.
|
|
597
679
|
* `parseCanonicalPattern` lowercases the tool name, which is fine for Grok
|
|
@@ -1258,6 +1340,61 @@ export function applyPermissionsToVersion(agentId, set, versionHome, merge = tru
|
|
|
1258
1340
|
fs.writeFileSync(configPath, TOML.stringify(config), 'utf-8');
|
|
1259
1341
|
return { success: true };
|
|
1260
1342
|
}
|
|
1343
|
+
if (agentId === 'cursor') {
|
|
1344
|
+
// Cursor CLI permissions live in ~/.cursor/cli-config.json
|
|
1345
|
+
const configPath = path.join(configDir, 'cli-config.json');
|
|
1346
|
+
let config = {};
|
|
1347
|
+
if (fs.existsSync(configPath)) {
|
|
1348
|
+
try {
|
|
1349
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
1350
|
+
}
|
|
1351
|
+
catch { /* start fresh */ }
|
|
1352
|
+
}
|
|
1353
|
+
const converted = convertToCursorFormat(set);
|
|
1354
|
+
if (merge && config.permissions && typeof config.permissions === 'object') {
|
|
1355
|
+
const existing = config.permissions;
|
|
1356
|
+
const allow = new Set([...(existing.allow || []), ...converted.permissions.allow]);
|
|
1357
|
+
const deny = new Set([...(existing.deny || []), ...converted.permissions.deny]);
|
|
1358
|
+
config.permissions = { allow: [...allow], deny: [...deny] };
|
|
1359
|
+
}
|
|
1360
|
+
else {
|
|
1361
|
+
config.permissions = converted.permissions;
|
|
1362
|
+
}
|
|
1363
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
1364
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
1365
|
+
return { success: true };
|
|
1366
|
+
}
|
|
1367
|
+
if (agentId === 'kiro') {
|
|
1368
|
+
const permissionsPath = path.join(versionHome, '.kiro', 'settings', 'permissions.yaml');
|
|
1369
|
+
let config = {};
|
|
1370
|
+
if (fs.existsSync(permissionsPath)) {
|
|
1371
|
+
const parsed = yaml.parse(fs.readFileSync(permissionsPath, 'utf-8'));
|
|
1372
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
1373
|
+
config = parsed;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
const newRules = convertToKiroFormat(set).rules;
|
|
1377
|
+
if (merge) {
|
|
1378
|
+
const existingRules = Array.isArray(config.rules) ? config.rules : [];
|
|
1379
|
+
const seen = new Set();
|
|
1380
|
+
config.rules = [...existingRules, ...newRules].filter((rule) => {
|
|
1381
|
+
if (!rule || typeof rule !== 'object' || Array.isArray(rule))
|
|
1382
|
+
return false;
|
|
1383
|
+
const record = rule;
|
|
1384
|
+
const key = `${String(record.effect)}|${String(record.capability)}|${JSON.stringify(record.match ?? [])}|${JSON.stringify(record.exclude ?? [])}`;
|
|
1385
|
+
if (seen.has(key))
|
|
1386
|
+
return false;
|
|
1387
|
+
seen.add(key);
|
|
1388
|
+
return true;
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
else {
|
|
1392
|
+
config.rules = newRules;
|
|
1393
|
+
}
|
|
1394
|
+
fs.mkdirSync(path.dirname(permissionsPath), { recursive: true });
|
|
1395
|
+
fs.writeFileSync(permissionsPath, yaml.stringify(config), 'utf-8');
|
|
1396
|
+
return { success: true };
|
|
1397
|
+
}
|
|
1261
1398
|
return { success: false, error: `Agent '${agentId}' does not support permissions` };
|
|
1262
1399
|
}
|
|
1263
1400
|
catch (err) {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projects-root resolution for the `agents run --project <slug>` shorthand.
|
|
3
|
+
*
|
|
4
|
+
* Projects follow a predictable layout — `<root>/<repo>` (e.g.
|
|
5
|
+
* `~/src/github.com/<user>/<repo>`), with git worktrees under
|
|
6
|
+
* `<repo>/.agents/worktrees/<slug>`. The root is auto-inferred from the repo you
|
|
7
|
+
* launch inside (the directory ABOVE the git root) and cached in `agents.yaml`
|
|
8
|
+
* so later runs resolve a bare slug from anywhere. It is stored home-relative
|
|
9
|
+
* (`~/…`) when it sits under `$HOME`, so the SAME value resolves on a remote
|
|
10
|
+
* host whose home differs (`/home/<user>` vs `/Users/<user>`): a `--host` run
|
|
11
|
+
* keeps the `~` and lets the remote login shell expand it (see `remoteCdPrefix`
|
|
12
|
+
* in `hosts/dispatch.ts`), while a local run expands `~` against the local home.
|
|
13
|
+
*/
|
|
14
|
+
/** Rewrite an absolute path under the local home to a `~/`-relative string; pass others through. */
|
|
15
|
+
export declare function toHomeRelative(abs: string): string;
|
|
16
|
+
/** Expand a leading `~`/`$HOME` against the LOCAL home. Other paths pass through unchanged. */
|
|
17
|
+
export declare function expandLocalHome(p: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* Make a `--cwd`/`--project` value portable to a remote host: an absolute path
|
|
20
|
+
* under the LOCAL home (which the local shell already expanded from `~`) becomes
|
|
21
|
+
* `~/…` so the *remote* shell re-roots it at its own home. Paths already anchored
|
|
22
|
+
* at `~`/`$HOME` pass through; other absolute or relative paths are left as-is
|
|
23
|
+
* (used verbatim on the host). Explicit `--remote-cwd` is NOT run through this —
|
|
24
|
+
* it is a literal remote path by contract.
|
|
25
|
+
*/
|
|
26
|
+
export declare function toRemotePortable(p: string): string;
|
|
27
|
+
/** The configured projects root (home-relative or absolute), or undefined when unset. */
|
|
28
|
+
export declare function getProjectRoot(): string | undefined;
|
|
29
|
+
/** Set (override) the cached projects root. Stored home-relative when under `$HOME`. */
|
|
30
|
+
export declare function setProjectRoot(rootPath: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Infer the projects root from `cwd`: the directory ABOVE the git repo root
|
|
33
|
+
* (cwd inside `~/src/github.com/user/repo` → `~/src/github.com/user`). Returns a
|
|
34
|
+
* home-relative string when under `$HOME`; undefined when `cwd` is not in a repo.
|
|
35
|
+
*/
|
|
36
|
+
export declare function inferProjectRoot(cwd: string): Promise<string | undefined>;
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the projects root, auto-inferring and caching on first use. Throws an
|
|
39
|
+
* actionable error when it is neither configured nor inferrable from `cwd`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function ensureProjectRoot(cwd: string): Promise<string>;
|
|
42
|
+
export interface ProjectRef {
|
|
43
|
+
slug: string;
|
|
44
|
+
worktree?: string;
|
|
45
|
+
}
|
|
46
|
+
/** Parse a `--project` value of the form `<slug>[@<worktree>]`. */
|
|
47
|
+
export declare function parseProjectRef(ref: string): ProjectRef;
|
|
48
|
+
/**
|
|
49
|
+
* Join a root + `--project` ref into a working directory. Pure (no I/O) so the
|
|
50
|
+
* slug/worktree layout is unit-testable. `forRemote` keeps the path
|
|
51
|
+
* home-relative (`~/…`) for the remote shell to expand; otherwise it is expanded
|
|
52
|
+
* against the local home into an absolute path.
|
|
53
|
+
*/
|
|
54
|
+
export declare function buildProjectPath(root: string, ref: string, forRemote: boolean): string;
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a `--project` ref to a working directory, inferring/caching the root.
|
|
57
|
+
*
|
|
58
|
+
* `forRemote: true` returns a home-relative path (`~/…`) so the REMOTE login
|
|
59
|
+
* shell expands `~`/`$HOME` to its own home. `forRemote: false` returns an
|
|
60
|
+
* absolute local path and verifies it exists (so a mistyped slug fails loudly).
|
|
61
|
+
*/
|
|
62
|
+
export declare function resolveProjectRef(ref: string, opts: {
|
|
63
|
+
forRemote: boolean;
|
|
64
|
+
cwd?: string;
|
|
65
|
+
}): Promise<string>;
|