@phnx-labs/agents-cli 1.20.61 → 1.20.63
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 +57 -0
- package/README.md +10 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +13 -3
- package/dist/commands/exec.js +51 -1
- package/dist/commands/funnel.d.ts +5 -0
- package/dist/commands/funnel.js +62 -0
- package/dist/commands/hosts.js +42 -0
- package/dist/commands/repo.d.ts +4 -4
- package/dist/commands/repo.js +30 -19
- package/dist/commands/routines.js +73 -16
- package/dist/commands/sessions-sync.d.ts +1 -0
- package/dist/commands/sessions-sync.js +16 -2
- package/dist/commands/sessions.js +8 -1
- package/dist/commands/setup.js +9 -0
- package/dist/commands/ssh.js +72 -2
- package/dist/commands/sync-provision.d.ts +23 -0
- package/dist/commands/sync-provision.js +107 -0
- package/dist/commands/webhook.d.ts +9 -0
- package/dist/commands/webhook.js +93 -0
- package/dist/index.js +6 -2
- package/dist/lib/agents.d.ts +26 -0
- package/dist/lib/agents.js +100 -28
- package/dist/lib/browser/ipc.js +5 -4
- package/dist/lib/browser/profiles.d.ts +13 -0
- package/dist/lib/browser/profiles.js +17 -0
- package/dist/lib/browser/service.d.ts +12 -1
- package/dist/lib/browser/service.js +48 -13
- package/dist/lib/browser/sessions-list.d.ts +40 -0
- package/dist/lib/browser/sessions-list.js +190 -0
- package/dist/lib/commands.js +29 -0
- package/dist/lib/convert.d.ts +11 -0
- package/dist/lib/convert.js +22 -0
- package/dist/lib/daemon.js +2 -0
- package/dist/lib/devices/fleet.d.ts +62 -0
- package/dist/lib/devices/fleet.js +128 -0
- package/dist/lib/doctor-diff.js +17 -1
- package/dist/lib/funnel.d.ts +5 -0
- package/dist/lib/funnel.js +23 -0
- package/dist/lib/git.d.ts +21 -5
- package/dist/lib/git.js +64 -14
- package/dist/lib/goose-commands.d.ts +41 -0
- package/dist/lib/goose-commands.js +176 -0
- package/dist/lib/hooks.js +134 -0
- package/dist/lib/hosts/credentials.d.ts +28 -0
- package/dist/lib/hosts/credentials.js +48 -0
- package/dist/lib/hosts/dispatch.d.ts +25 -0
- package/dist/lib/hosts/dispatch.js +68 -2
- package/dist/lib/hosts/passthrough.d.ts +13 -10
- package/dist/lib/hosts/passthrough.js +119 -29
- package/dist/lib/mailbox-gc.js +4 -16
- package/dist/lib/migrate.d.ts +12 -0
- package/dist/lib/migrate.js +55 -1
- package/dist/lib/permissions.d.ts +15 -0
- package/dist/lib/permissions.js +99 -0
- package/dist/lib/plugins.d.ts +20 -0
- package/dist/lib/plugins.js +118 -0
- package/dist/lib/resources/permissions.js +2 -0
- package/dist/lib/routines.d.ts +29 -10
- package/dist/lib/routines.js +47 -15
- package/dist/lib/session/active.d.ts +8 -1
- package/dist/lib/session/active.js +1 -0
- package/dist/lib/session/parse.js +12 -0
- package/dist/lib/session/state.d.ts +33 -0
- package/dist/lib/session/state.js +40 -0
- package/dist/lib/session/sync/agents.d.ts +2 -0
- package/dist/lib/session/sync/agents.js +39 -1
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +6 -1
- package/dist/lib/session/sync/provision.d.ts +49 -0
- package/dist/lib/session/sync/provision.js +91 -0
- package/dist/lib/session/sync/sync.d.ts +3 -0
- package/dist/lib/session/sync/sync.js +26 -6
- package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
- package/dist/lib/session/sync/transcript-crypto.js +147 -0
- package/dist/lib/staleness/detectors/permissions.js +23 -0
- package/dist/lib/staleness/detectors/subagents.js +36 -0
- package/dist/lib/staleness/writers/commands.js +7 -0
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/staleness/writers/subagents.js +22 -3
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +11 -0
- package/dist/lib/state.d.ts +10 -2
- package/dist/lib/state.js +14 -2
- package/dist/lib/subagents.d.ts +32 -0
- package/dist/lib/subagents.js +238 -0
- package/dist/lib/triggers/webhook.d.ts +70 -27
- package/dist/lib/triggers/webhook.js +264 -43
- package/package.json +1 -1
|
@@ -16,6 +16,7 @@ import { remoteShellFor } from './remote-cmd.js';
|
|
|
16
16
|
import { resolveRemoteOsSync } from './remote-os.js';
|
|
17
17
|
import { saveTask, updateTask, terminalPatch } from './tasks.js';
|
|
18
18
|
import { followHostTask } from './progress.js';
|
|
19
|
+
import { wrapHostCommandWithCredentials } from './credentials.js';
|
|
19
20
|
// Use $HOME (not ~) so the path is correct whether or not it's quoted and
|
|
20
21
|
// regardless of the run's cwd. Task ids are 8 hex chars, so these paths are
|
|
21
22
|
// injection-safe to interpolate unquoted into remote commands.
|
|
@@ -91,6 +92,64 @@ export function terminateDispatchedTask(task) {
|
|
|
91
92
|
terminateRemoteLaunch(task);
|
|
92
93
|
updateTask(task.id, terminalPatch(143));
|
|
93
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Build the remote shell used by {@link stopDispatchedTask}. Exported for
|
|
97
|
+
* unit tests — the keep-log / no-clobber contract lives in this script.
|
|
98
|
+
*
|
|
99
|
+
* Protocol (printed to stdout for the local caller):
|
|
100
|
+
* - `SIGNALED` — process group was live; SIGTERM/KILL applied; wrote 143
|
|
101
|
+
* - `ALREADY` + code — group gone; adopted existing `.exit` (never overwrite)
|
|
102
|
+
* - `GONE` — group gone and no `.exit`; write 143 as the local stop outcome
|
|
103
|
+
* Exit 1 if the group is still alive after TERM/KILL (can't stop it).
|
|
104
|
+
*/
|
|
105
|
+
export function buildStopRemoteCommand(pid, remoteExit) {
|
|
106
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
107
|
+
throw new Error(`Invalid remote task pid: ${pid}`);
|
|
108
|
+
}
|
|
109
|
+
// Only force-write 143 when we actually signaled a live group (or nothing
|
|
110
|
+
// left a code). Never `echo 143` over a real completed-run exit code.
|
|
111
|
+
return (`if kill -TERM -- -${pid} 2>/dev/null; then ` +
|
|
112
|
+
`sleep 1; kill -KILL -- -${pid} 2>/dev/null || true; ` +
|
|
113
|
+
`echo 143 > ${remoteExit}; echo SIGNALED; ` +
|
|
114
|
+
`elif kill -0 -- -${pid} 2>/dev/null; then ` +
|
|
115
|
+
`exit 1; ` +
|
|
116
|
+
`else ` +
|
|
117
|
+
`code=$(cat ${remoteExit} 2>/dev/null | tr -d '[:space:]'); ` +
|
|
118
|
+
`if [ -n "$code" ]; then echo "ALREADY $code"; ` +
|
|
119
|
+
`else echo 143 > ${remoteExit}; echo GONE; fi; ` +
|
|
120
|
+
`fi`);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Stop a running host task from the origin machine (`agents hosts stop <id>`).
|
|
124
|
+
*
|
|
125
|
+
* Unlike {@link terminateDispatchedTask} (rollback cleanup after a failed
|
|
126
|
+
* persist), this keeps the remote log so `agents hosts logs <id>` still works,
|
|
127
|
+
* writes a terminal `.exit` marker only when we actually stopped a live group
|
|
128
|
+
* (or no code existed), and never clobbers a real completed-run exit code.
|
|
129
|
+
*/
|
|
130
|
+
export function stopDispatchedTask(task) {
|
|
131
|
+
if (task.status !== 'running') {
|
|
132
|
+
throw new Error(`Task ${task.id} is already ${task.status}`);
|
|
133
|
+
}
|
|
134
|
+
if (!task.pid) {
|
|
135
|
+
throw new Error(`Cannot stop remote task ${task.id}: launch returned no PID.`);
|
|
136
|
+
}
|
|
137
|
+
const command = buildStopRemoteCommand(task.pid, task.remoteExit);
|
|
138
|
+
const result = sshExec(task.target, command, { timeoutMs: 10000, multiplex: true });
|
|
139
|
+
if (result.code !== 0) {
|
|
140
|
+
throw new Error(`Failed to stop remote task ${task.id} on ${task.host}: ` +
|
|
141
|
+
`${(result.stderr || result.stdout).trim() || 'ssh error'}`);
|
|
142
|
+
}
|
|
143
|
+
const line = result.stdout.trim().split('\n').pop() ?? '';
|
|
144
|
+
let code = 143;
|
|
145
|
+
if (line.startsWith('ALREADY ')) {
|
|
146
|
+
const parsed = parseInt(line.slice('ALREADY '.length), 10);
|
|
147
|
+
if (Number.isFinite(parsed))
|
|
148
|
+
code = parsed;
|
|
149
|
+
}
|
|
150
|
+
// SIGNALED / GONE / ALREADY all end with a terminal local record.
|
|
151
|
+
return updateTask(task.id, terminalPatch(code)) ?? { ...task, ...terminalPatch(code) };
|
|
152
|
+
}
|
|
94
153
|
/**
|
|
95
154
|
* The launch + task-record + optional follow core. Both `dispatchToHost` (run)
|
|
96
155
|
* and `dispatchAgentsCommand` (teams) build their `forwardedArgs` and call here,
|
|
@@ -117,7 +176,10 @@ async function launchDetached(host, target, opts) {
|
|
|
117
176
|
// Inner command run under a login shell so PATH resolves `agents`.
|
|
118
177
|
const invocation = ['agents', ...opts.forwardedArgs].map(shellQuote).join(' ');
|
|
119
178
|
const cwd = remoteCdPrefix(opts.remoteCwd);
|
|
120
|
-
|
|
179
|
+
let inner = `${cwd}${invocation} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
|
|
180
|
+
if (opts.copyCreds) {
|
|
181
|
+
inner = wrapHostCommandWithCredentials(inner, opts.copyCreds);
|
|
182
|
+
}
|
|
121
183
|
// Outer: ensure dir, launch the login-shell wrapper as a new process-group
|
|
122
184
|
// leader, and print that leader PID.
|
|
123
185
|
const launch = `mkdir -p ${REMOTE_DIR}; ${buildDetachedLaunchCommand(inner)}`;
|
|
@@ -268,7 +330,10 @@ export async function runInteractiveOnHost(host, opts) {
|
|
|
268
330
|
process.stderr.write(`[hosts] warning: ${w}\n`);
|
|
269
331
|
const invocation = ['agents', ...buildInteractiveRunForwardedArgs(opts)].map(shellQuote).join(' ');
|
|
270
332
|
const cwd = remoteCdPrefix(opts.remoteCwd);
|
|
271
|
-
|
|
333
|
+
let remoteCmd = `${cwd}${invocation}`;
|
|
334
|
+
if (opts.copyCreds) {
|
|
335
|
+
remoteCmd = wrapHostCommandWithCredentials(remoteCmd, opts.copyCreds);
|
|
336
|
+
}
|
|
272
337
|
return sshStream(target, remoteCmd, { tty: process.stdin.isTTY, multiplex: true });
|
|
273
338
|
}
|
|
274
339
|
/** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
|
|
@@ -288,6 +353,7 @@ export async function dispatchToHost(host, opts) {
|
|
|
288
353
|
// On resume the remote session keeps its existing id; record that id so the
|
|
289
354
|
// task stays mapped to the same session.
|
|
290
355
|
sessionId: opts.resume ?? opts.sessionId,
|
|
356
|
+
copyCreds: opts.copyCreds,
|
|
291
357
|
});
|
|
292
358
|
}
|
|
293
359
|
/**
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Generic `--host` passthrough — the single choke point that runs an allowlisted
|
|
3
|
-
* `agents <command>` on a remote host instead of locally
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* code. Called once from `index.ts` before commander parses; returns `true` when
|
|
7
|
-
* it handled the invocation (the local command must then NOT run).
|
|
3
|
+
* `agents <command>` on a remote host instead of locally. Called once from
|
|
4
|
+
* `index.ts` before commander parses; returns `true` when it handled the
|
|
5
|
+
* invocation (the local command must then NOT run).
|
|
8
6
|
*
|
|
9
7
|
* Transport is SSH (via `ssh-exec.ts`), never a daemon: SSH is the one hardened
|
|
10
8
|
* choke point already used everywhere, and it gives auth + encryption + host-key
|
|
@@ -12,17 +10,22 @@
|
|
|
12
10
|
* long-running case — `teams start --watch` — dispatches detached so the remote
|
|
13
11
|
* supervisor outlives a dropped connection.
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* Commands with their own richer `--host` handling (`run`/`sessions`/`feed`/
|
|
14
|
+
* `computer`/`secrets`/`logs`/…) are listed in {@link OWN_HOST_COMMANDS} and
|
|
15
|
+
* fall through to their local actions. Everything else either routes via this
|
|
16
|
+
* table or, when `--host`/`--device` is present, exits with a clear
|
|
17
|
+
* "not supported" message — never commander's raw `unknown option`.
|
|
18
18
|
*/
|
|
19
19
|
/** Pull the value of `--host`/`-H`/`--remote-cwd` (any form) out of an argv. */
|
|
20
20
|
export declare function flagValue(args: string[], long: string, short?: string): string | undefined;
|
|
21
21
|
/**
|
|
22
22
|
* Route `agents <command> … --host <name>` to a remote if the command is
|
|
23
23
|
* host-routable and a `--host` (or its `--device` alias) was given. Returns
|
|
24
|
-
* `false` (run locally) when neither flag is present, the command
|
|
25
|
-
*
|
|
24
|
+
* `false` (run locally) when neither flag is present, the command owns its own
|
|
25
|
+
* host handling, the target is this very machine, or placement flags need the
|
|
26
|
+
* local action. Returns `true` after printing a clear error when the flag is
|
|
27
|
+
* present on a command that is neither routable nor self-handling — so the user
|
|
28
|
+
* never sees commander's raw `unknown option '--host'`.
|
|
26
29
|
*
|
|
27
30
|
* @param command the resolved subcommand name (`process.argv`'s first non-flag).
|
|
28
31
|
* @param allArgs `process.argv.slice(2)` — the command name followed by its args.
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Generic `--host` passthrough — the single choke point that runs an allowlisted
|
|
3
|
-
* `agents <command>` on a remote host instead of locally
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* code. Called once from `index.ts` before commander parses; returns `true` when
|
|
7
|
-
* it handled the invocation (the local command must then NOT run).
|
|
3
|
+
* `agents <command>` on a remote host instead of locally. Called once from
|
|
4
|
+
* `index.ts` before commander parses; returns `true` when it handled the
|
|
5
|
+
* invocation (the local command must then NOT run).
|
|
8
6
|
*
|
|
9
7
|
* Transport is SSH (via `ssh-exec.ts`), never a daemon: SSH is the one hardened
|
|
10
8
|
* choke point already used everywhere, and it gives auth + encryption + host-key
|
|
@@ -12,9 +10,11 @@
|
|
|
12
10
|
* long-running case — `teams start --watch` — dispatches detached so the remote
|
|
13
11
|
* supervisor outlives a dropped connection.
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* Commands with their own richer `--host` handling (`run`/`sessions`/`feed`/
|
|
14
|
+
* `computer`/`secrets`/`logs`/…) are listed in {@link OWN_HOST_COMMANDS} and
|
|
15
|
+
* fall through to their local actions. Everything else either routes via this
|
|
16
|
+
* table or, when `--host`/`--device` is present, exits with a clear
|
|
17
|
+
* "not supported" message — never commander's raw `unknown option`.
|
|
18
18
|
*/
|
|
19
19
|
import chalk from 'chalk';
|
|
20
20
|
import { assertValidSshTarget, sshStream } from '../ssh-exec.js';
|
|
@@ -24,19 +24,90 @@ import { dispatchAgentsCommand } from './dispatch.js';
|
|
|
24
24
|
import { stripRoutingFlags, buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, } from './remote-cmd.js';
|
|
25
25
|
import { resolveRemoteOsSync } from './remote-os.js';
|
|
26
26
|
import { machineId } from '../session/sync/config.js';
|
|
27
|
+
/**
|
|
28
|
+
* First-class groups that run transparently on a remote via SSH when
|
|
29
|
+
* `--host`/`--device` is present. Keep both canonical names and aliases
|
|
30
|
+
* (`repo`/`repos`, `exec`/`run`) so either argv form routes the same way.
|
|
31
|
+
*
|
|
32
|
+
* Prefer adding here over per-command SSH code — this is the single choke point.
|
|
33
|
+
*/
|
|
27
34
|
const REMOTE_PASSTHROUGH = {
|
|
35
|
+
// status / inspect
|
|
28
36
|
view: {},
|
|
37
|
+
inspect: {},
|
|
38
|
+
doctor: {},
|
|
39
|
+
status: {},
|
|
40
|
+
check: {},
|
|
41
|
+
list: {},
|
|
29
42
|
usage: {},
|
|
30
43
|
cost: {},
|
|
31
44
|
output: {},
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
list: {},
|
|
45
|
+
budget: {},
|
|
46
|
+
// config / resources
|
|
35
47
|
sync: { nonInteractive: ['--yes'] },
|
|
48
|
+
pull: {},
|
|
49
|
+
push: {},
|
|
50
|
+
repo: {},
|
|
51
|
+
repos: {},
|
|
52
|
+
plugins: {},
|
|
53
|
+
skills: {},
|
|
54
|
+
hooks: {},
|
|
55
|
+
commands: {},
|
|
56
|
+
rules: {},
|
|
57
|
+
memory: {},
|
|
58
|
+
permissions: {},
|
|
59
|
+
perms: {},
|
|
60
|
+
mcp: {},
|
|
61
|
+
cli: {},
|
|
62
|
+
subagents: {},
|
|
63
|
+
workflows: {},
|
|
64
|
+
packages: {},
|
|
65
|
+
models: {},
|
|
66
|
+
profiles: {},
|
|
67
|
+
defaults: {},
|
|
68
|
+
alias: {},
|
|
69
|
+
// lifecycle
|
|
36
70
|
teams: {},
|
|
37
71
|
message: {},
|
|
38
72
|
routines: {},
|
|
73
|
+
jobs: {},
|
|
74
|
+
cron: {},
|
|
75
|
+
// misc remote-sensible
|
|
76
|
+
prune: {},
|
|
77
|
+
trash: {},
|
|
78
|
+
restore: {},
|
|
79
|
+
worktree: {},
|
|
80
|
+
events: {},
|
|
81
|
+
audit: {},
|
|
82
|
+
lock: {},
|
|
83
|
+
feedback: {},
|
|
84
|
+
wallet: {},
|
|
85
|
+
daemon: {},
|
|
86
|
+
pty: {},
|
|
87
|
+
tmux: {},
|
|
88
|
+
watchdog: {},
|
|
89
|
+
factory: {},
|
|
90
|
+
browser: {},
|
|
91
|
+
versions: {},
|
|
39
92
|
};
|
|
93
|
+
/**
|
|
94
|
+
* Commands that register and interpret `--host`/`--device` themselves — must
|
|
95
|
+
* fall through to local commander even when the flag is present. Do not add
|
|
96
|
+
* these to {@link REMOTE_PASSTHROUGH}.
|
|
97
|
+
*/
|
|
98
|
+
const OWN_HOST_COMMANDS = new Set([
|
|
99
|
+
'run',
|
|
100
|
+
'exec', // deprecated alias of run
|
|
101
|
+
'sessions',
|
|
102
|
+
'feed',
|
|
103
|
+
'computer',
|
|
104
|
+
'secrets',
|
|
105
|
+
'logs',
|
|
106
|
+
'hosts',
|
|
107
|
+
'ssh',
|
|
108
|
+
'devices',
|
|
109
|
+
'fleet', // alias of devices
|
|
110
|
+
]);
|
|
40
111
|
/** `--no-tty` is stripped like the routing flags but carries no value. */
|
|
41
112
|
const STRIP_SPECS = [...HOST_ROUTING_SPECS, { long: 'no-tty', takesValue: false }];
|
|
42
113
|
/** Pull the value of `--host`/`-H`/`--remote-cwd` (any form) out of an argv. */
|
|
@@ -83,15 +154,26 @@ async function resolveTargetHost(name, any) {
|
|
|
83
154
|
/**
|
|
84
155
|
* Route `agents <command> … --host <name>` to a remote if the command is
|
|
85
156
|
* host-routable and a `--host` (or its `--device` alias) was given. Returns
|
|
86
|
-
* `false` (run locally) when neither flag is present, the command
|
|
87
|
-
*
|
|
157
|
+
* `false` (run locally) when neither flag is present, the command owns its own
|
|
158
|
+
* host handling, the target is this very machine, or placement flags need the
|
|
159
|
+
* local action. Returns `true` after printing a clear error when the flag is
|
|
160
|
+
* present on a command that is neither routable nor self-handling — so the user
|
|
161
|
+
* never sees commander's raw `unknown option '--host'`.
|
|
88
162
|
*
|
|
89
163
|
* @param command the resolved subcommand name (`process.argv`'s first non-flag).
|
|
90
164
|
* @param allArgs `process.argv.slice(2)` — the command name followed by its args.
|
|
91
165
|
*/
|
|
92
166
|
export async function maybeRunOnHost(command, allArgs) {
|
|
93
|
-
const
|
|
94
|
-
|
|
167
|
+
const hostFlag = flagValue(allArgs, 'host', 'H');
|
|
168
|
+
const deviceFlag = flagValue(allArgs, 'device');
|
|
169
|
+
const hostName = hostFlag ?? deviceFlag;
|
|
170
|
+
if (!hostName)
|
|
171
|
+
return false;
|
|
172
|
+
// Commands with their own richer --host semantics must reach local commander
|
|
173
|
+
// BEFORE any single-target conflict gate. sessions/feed merge --host and
|
|
174
|
+
// --device into a multi-host list; rejecting "conflicting" pairs would break
|
|
175
|
+
// `agents sessions --host a --device b` / `agents feed --host a --device b`.
|
|
176
|
+
if (OWN_HOST_COMMANDS.has(command))
|
|
95
177
|
return false;
|
|
96
178
|
// Placement, not routing: `teams add`/`teams create` read `--device`/`--devices`
|
|
97
179
|
// (and `--host`/`--hosts`) as WHERE to place a teammate / the team pool — the
|
|
@@ -107,16 +189,6 @@ export async function maybeRunOnHost(command, allArgs) {
|
|
|
107
189
|
return false;
|
|
108
190
|
}
|
|
109
191
|
}
|
|
110
|
-
// `--device` is a first-class alias of `--host` (mirrors `agents run`); the
|
|
111
|
-
// device registry is the source of truth for machine identity. Reject a
|
|
112
|
-
// conflicting pair rather than silently preferring one — same rule as run.
|
|
113
|
-
const hostFlag = flagValue(allArgs, 'host', 'H');
|
|
114
|
-
const deviceFlag = flagValue(allArgs, 'device');
|
|
115
|
-
if (hostFlag && deviceFlag && hostFlag !== deviceFlag) {
|
|
116
|
-
console.error(chalk.red('Conflicting --host/--device values — pass just one.'));
|
|
117
|
-
process.exitCode = 1;
|
|
118
|
-
return true;
|
|
119
|
-
}
|
|
120
192
|
// `--hosts` is always a generic fleet flag — bail for every command so the
|
|
121
193
|
// local aggregator handles it. `--devices` is fan-out on most commands but
|
|
122
194
|
// a placement flag on `routines` (which devices may run the routine), so
|
|
@@ -125,14 +197,32 @@ export async function maybeRunOnHost(command, allArgs) {
|
|
|
125
197
|
return false;
|
|
126
198
|
if (allArgs.includes('--devices') && command !== 'routines')
|
|
127
199
|
return false;
|
|
128
|
-
const
|
|
129
|
-
if (!
|
|
130
|
-
|
|
200
|
+
const spec = REMOTE_PASSTHROUGH[command];
|
|
201
|
+
if (!spec) {
|
|
202
|
+
// Flag was accepted (no raw commander "unknown option") but this group has
|
|
203
|
+
// no remote semantics — say so clearly instead of falling through.
|
|
204
|
+
console.error(chalk.red(`\`agents ${command}\` does not support --host/--device (no remote interpretation).`) +
|
|
205
|
+
chalk.gray(' Run without the flag, or use a host-routable group (repos, view, sync, teams, doctor, …).'));
|
|
206
|
+
process.exitCode = 1;
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
// Single-target remote path only: reject a conflicting --host/--device pair
|
|
210
|
+
// rather than silently preferring one (same rule as `agents run`).
|
|
211
|
+
if (hostFlag && deviceFlag && hostFlag !== deviceFlag) {
|
|
212
|
+
console.error(chalk.red('Conflicting --host/--device values — pass just one.'));
|
|
213
|
+
process.exitCode = 1;
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
131
216
|
// Running against your own machine is just a local run — skip the SSH round-trip.
|
|
132
217
|
// `machineId()` is the same self-identifier the device registry and session
|
|
133
218
|
// sync use (lowercased short hostname); compare case-insensitively.
|
|
134
|
-
|
|
219
|
+
// Strip the routing flags from process.argv so the local command never sees
|
|
220
|
+
// an unregistered `--host`/`--device` and dies with "unknown option".
|
|
221
|
+
if (hostName.toLowerCase() === machineId()) {
|
|
222
|
+
const stripped = stripRoutingFlags(allArgs, STRIP_SPECS);
|
|
223
|
+
process.argv = [process.argv[0], process.argv[1], ...stripped];
|
|
135
224
|
return false;
|
|
225
|
+
}
|
|
136
226
|
const remoteCwd = flagValue(allArgs, 'remote-cwd');
|
|
137
227
|
const any = allArgs.includes('--any');
|
|
138
228
|
let host;
|
package/dist/lib/mailbox-gc.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import * as path from 'path';
|
|
11
11
|
import { getMailboxRootDir } from './state.js';
|
|
12
|
-
import { mailboxDir, isValidMailboxId, readMessage, } from './mailbox.js';
|
|
12
|
+
import { mailboxDir, isValidMailboxId, readMessage, sweepExpired, } from './mailbox.js';
|
|
13
13
|
import { listBlocks, removeBlock } from './feed.js';
|
|
14
14
|
const DEFAULT_MAX_CONSUMED_AGE_MINUTES = 24 * 60;
|
|
15
15
|
function consumedAgeMinutes(file, now) {
|
|
@@ -134,21 +134,9 @@ export function gcMailbox(activeBoxIds, options = {}) {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
else {
|
|
137
|
-
// Live box:
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
for (const sub of ['inbox', 'processing']) {
|
|
141
|
-
const dir = path.join(boxDir, sub);
|
|
142
|
-
for (const file of jsonFiles(dir)) {
|
|
143
|
-
const msg = readMessage(path.join(dir, file));
|
|
144
|
-
if (msg && msg.expiresAt) {
|
|
145
|
-
const ts = Date.parse(msg.expiresAt);
|
|
146
|
-
if (!Number.isNaN(ts) && ts <= now.getTime()) {
|
|
147
|
-
result.messagesDroppedExpired++;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
137
|
+
// Live box: archive expired messages (same path as drain/peek) so GC does
|
|
138
|
+
// not leave expired files in inbox/processing while only bumping metrics.
|
|
139
|
+
result.messagesDroppedExpired += sweepExpired(boxDir, name, now);
|
|
152
140
|
result.consumedPruned += pruneConsumed(boxDir, maxConsumedAgeMinutes, now);
|
|
153
141
|
}
|
|
154
142
|
}
|
package/dist/lib/migrate.d.ts
CHANGED
|
@@ -44,6 +44,18 @@ export declare function foldLegacySystemRepo(): void;
|
|
|
44
44
|
* drive a fixture tree without touching the user's ~/.agents.
|
|
45
45
|
*/
|
|
46
46
|
export declare function migrateAgentsYaml(systemDir?: string, userDir?: string): void;
|
|
47
|
+
/**
|
|
48
|
+
* Fold the legacy GLOBAL browser captures root
|
|
49
|
+
* (`~/.agents/.cache/browser/sessions/<task>/`) into the new PER-PROFILE layout
|
|
50
|
+
* (`~/.agents/.cache/browser/<profile>/sessions/<task>/`), so each profile is one
|
|
51
|
+
* self-contained tree and the new `browser sessions` listing finds old captures.
|
|
52
|
+
*
|
|
53
|
+
* Attribution: a legacy `sessions/<task>` dir is owned by whichever profile's
|
|
54
|
+
* `tasks.json` still lists that task name. Tasks no profile claims (the profile
|
|
55
|
+
* was deleted, or tasks.json was cleared) move under a `_legacy` pseudo-profile so
|
|
56
|
+
* nothing is lost. Idempotent — once the global `sessions/` root is gone it no-ops.
|
|
57
|
+
*/
|
|
58
|
+
export declare function foldBrowserSessionsIntoProfiles(browserDir?: string): void;
|
|
47
59
|
/**
|
|
48
60
|
* Repair self-referential agent binary symlinks.
|
|
49
61
|
*
|
package/dist/lib/migrate.js
CHANGED
|
@@ -359,6 +359,56 @@ function foldUserHooksYamlIntoAgentsYaml() {
|
|
|
359
359
|
}
|
|
360
360
|
catch { /* best-effort */ }
|
|
361
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* Fold the legacy GLOBAL browser captures root
|
|
364
|
+
* (`~/.agents/.cache/browser/sessions/<task>/`) into the new PER-PROFILE layout
|
|
365
|
+
* (`~/.agents/.cache/browser/<profile>/sessions/<task>/`), so each profile is one
|
|
366
|
+
* self-contained tree and the new `browser sessions` listing finds old captures.
|
|
367
|
+
*
|
|
368
|
+
* Attribution: a legacy `sessions/<task>` dir is owned by whichever profile's
|
|
369
|
+
* `tasks.json` still lists that task name. Tasks no profile claims (the profile
|
|
370
|
+
* was deleted, or tasks.json was cleared) move under a `_legacy` pseudo-profile so
|
|
371
|
+
* nothing is lost. Idempotent — once the global `sessions/` root is gone it no-ops.
|
|
372
|
+
*/
|
|
373
|
+
export function foldBrowserSessionsIntoProfiles(browserDir = path.join(CACHE_DIR, 'browser')) {
|
|
374
|
+
const legacySessionsDir = path.join(browserDir, 'sessions');
|
|
375
|
+
let taskDirs;
|
|
376
|
+
try {
|
|
377
|
+
taskDirs = fs.readdirSync(legacySessionsDir, { withFileTypes: true });
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return; // no legacy global sessions/ root — already folded or never existed
|
|
381
|
+
}
|
|
382
|
+
// Map task name -> owning profile from every profile's tasks.json (first wins).
|
|
383
|
+
const taskOwner = new Map();
|
|
384
|
+
let profileDirs = [];
|
|
385
|
+
try {
|
|
386
|
+
profileDirs = fs.readdirSync(browserDir, { withFileTypes: true });
|
|
387
|
+
}
|
|
388
|
+
catch { /* browserDir vanished mid-run */ }
|
|
389
|
+
for (const p of profileDirs) {
|
|
390
|
+
if (!p.isDirectory() || p.name === 'sessions' || p.name === '_legacy')
|
|
391
|
+
continue;
|
|
392
|
+
try {
|
|
393
|
+
const state = JSON.parse(fs.readFileSync(path.join(browserDir, p.name, 'tasks.json'), 'utf-8'));
|
|
394
|
+
for (const taskName of Object.keys(state)) {
|
|
395
|
+
if (!taskOwner.has(taskName))
|
|
396
|
+
taskOwner.set(taskName, p.name);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
catch { /* missing or invalid tasks.json */ }
|
|
400
|
+
}
|
|
401
|
+
for (const taskEntry of taskDirs) {
|
|
402
|
+
if (!taskEntry.isDirectory())
|
|
403
|
+
continue;
|
|
404
|
+
const owner = taskOwner.get(taskEntry.name) ?? '_legacy';
|
|
405
|
+
// A capture dir can hold a nested recordings/ subdir and may collide with a
|
|
406
|
+
// dest that already has newer captures — moveDirOnce merges (skip-existing),
|
|
407
|
+
// moveFileOnce would EISDIR on unlink and silently strand the captures.
|
|
408
|
+
moveDirOnce(path.join(legacySessionsDir, taskEntry.name), path.join(browserDir, owner, 'sessions', taskEntry.name));
|
|
409
|
+
}
|
|
410
|
+
rmEmptyDirTree(legacySessionsDir);
|
|
411
|
+
}
|
|
362
412
|
/**
|
|
363
413
|
* Fold ~/.agents/browser/profiles/*.yaml into ~/.agents/agents.yaml under a
|
|
364
414
|
* `browser:` key, then delete the profiles directory. Single user file to sync.
|
|
@@ -1582,7 +1632,7 @@ function containsOnlyDsStore(dir) {
|
|
|
1582
1632
|
function warnSystemOrphans() {
|
|
1583
1633
|
const SHIPPED_ALLOWLIST = new Set([
|
|
1584
1634
|
// resource directories shipped by the npm package
|
|
1585
|
-
'commands', 'hooks', 'skills', 'rules', 'mcp', 'cli', 'permissions', 'subagents', 'profiles', 'agents',
|
|
1635
|
+
'commands', 'hooks', 'skills', 'rules', 'mcp', 'cli', 'permissions', 'subagents', 'profiles', 'agents', 'routines',
|
|
1586
1636
|
// top-level metadata files
|
|
1587
1637
|
'agents.yaml', 'hooks.yaml', 'README.md', 'CHANGELOG.md',
|
|
1588
1638
|
// git + repo metadata
|
|
@@ -1966,6 +2016,10 @@ export async function runMigration() {
|
|
|
1966
2016
|
// migrateRuntimeToCache so any legacy plugins/ still at the user-root from
|
|
1967
2017
|
// very-old layouts have already been handled.
|
|
1968
2018
|
migratePluginsBackToUserRoot();
|
|
2019
|
+
// Browser captures: fold the legacy global browser/sessions/<task> root into the
|
|
2020
|
+
// per-profile browser/<profile>/sessions/<task> layout. After the cache moves so
|
|
2021
|
+
// the browser dir is at its canonical .cache location.
|
|
2022
|
+
foldBrowserSessionsIntoProfiles();
|
|
1969
2023
|
// System-repo sweep: move every remaining operational dir into its canonical
|
|
1970
2024
|
// user-bucket location, then drop known-dead artifacts and warn about
|
|
1971
2025
|
// anything we don't recognize. Order: durable (sessions/teams/trash/repos/
|
|
@@ -122,6 +122,21 @@ export declare function convertToDroidFormat(set: PermissionSet): {
|
|
|
122
122
|
commandAllowlist: string[];
|
|
123
123
|
commandDenylist: string[];
|
|
124
124
|
};
|
|
125
|
+
/**
|
|
126
|
+
* Convert canonical permission set to OpenClaw's `tools.alsoAllow`/`tools.deny`.
|
|
127
|
+
*
|
|
128
|
+
* OpenClaw's allowlist is tool-level only, so ONLY blanket (whole-tool) rules
|
|
129
|
+
* map — a rule is blanket iff it's a bare tool with no parens (`Bash`), it's in
|
|
130
|
+
* BLANKET_BASH_FORMS, or its pattern is `*`/`**` (`Read(**)`, `Write(*)`).
|
|
131
|
+
* Sub-command/path/domain rules (`Bash(git:*)`, `Write(secrets/**)`,
|
|
132
|
+
* `WebFetch(domain:x)`) are SKIPPED — coarse-mapping a specific deny to a whole
|
|
133
|
+
* tool would wrongly gate every use of that tool. Output arrays are deduped and
|
|
134
|
+
* sorted for deterministic writes/tests.
|
|
135
|
+
*/
|
|
136
|
+
export declare function convertToOpenClawFormat(set: PermissionSet): {
|
|
137
|
+
alsoAllow: string[];
|
|
138
|
+
deny: string[];
|
|
139
|
+
};
|
|
125
140
|
/**
|
|
126
141
|
* Convert canonical permission set to Antigravity format.
|
|
127
142
|
* Antigravity reads ~/.gemini/antigravity-cli/settings.json with
|
package/dist/lib/permissions.js
CHANGED
|
@@ -560,6 +560,60 @@ export function convertToDroidFormat(set) {
|
|
|
560
560
|
commandDenylist: commands(set.deny ?? []),
|
|
561
561
|
};
|
|
562
562
|
}
|
|
563
|
+
/**
|
|
564
|
+
* Canonical tool -> OpenClaw tool id. OpenClaw gates at TOOL granularity only
|
|
565
|
+
* (no sub-command/path/domain patterns), so only these whole-tool ids exist.
|
|
566
|
+
* Any canonical tool not in this map is unsupported and skipped.
|
|
567
|
+
*/
|
|
568
|
+
const CANONICAL_TO_OPENCLAW_TOOL = {
|
|
569
|
+
bash: 'exec',
|
|
570
|
+
read: 'read',
|
|
571
|
+
write: 'write',
|
|
572
|
+
edit: 'write',
|
|
573
|
+
webfetch: 'web_fetch',
|
|
574
|
+
websearch: 'web_search',
|
|
575
|
+
};
|
|
576
|
+
/**
|
|
577
|
+
* Convert canonical permission set to OpenClaw's `tools.alsoAllow`/`tools.deny`.
|
|
578
|
+
*
|
|
579
|
+
* OpenClaw's allowlist is tool-level only, so ONLY blanket (whole-tool) rules
|
|
580
|
+
* map — a rule is blanket iff it's a bare tool with no parens (`Bash`), it's in
|
|
581
|
+
* BLANKET_BASH_FORMS, or its pattern is `*`/`**` (`Read(**)`, `Write(*)`).
|
|
582
|
+
* Sub-command/path/domain rules (`Bash(git:*)`, `Write(secrets/**)`,
|
|
583
|
+
* `WebFetch(domain:x)`) are SKIPPED — coarse-mapping a specific deny to a whole
|
|
584
|
+
* tool would wrongly gate every use of that tool. Output arrays are deduped and
|
|
585
|
+
* sorted for deterministic writes/tests.
|
|
586
|
+
*/
|
|
587
|
+
export function convertToOpenClawFormat(set) {
|
|
588
|
+
const map = (permissions) => {
|
|
589
|
+
const tools = new Set();
|
|
590
|
+
for (const perm of permissions) {
|
|
591
|
+
// Bare tool name with no parens (e.g. "Bash", "Read") is a blanket grant;
|
|
592
|
+
// parseCanonicalPattern requires parens, so handle it first.
|
|
593
|
+
const bare = perm.match(/^(\w+)$/);
|
|
594
|
+
if (bare) {
|
|
595
|
+
const id = CANONICAL_TO_OPENCLAW_TOOL[bare[1].toLowerCase()];
|
|
596
|
+
if (id)
|
|
597
|
+
tools.add(id);
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
const parsed = parseCanonicalPattern(perm);
|
|
601
|
+
if (!parsed)
|
|
602
|
+
continue;
|
|
603
|
+
const isBlanket = BLANKET_BASH_FORMS.has(perm) || parsed.pattern === '*' || parsed.pattern === '**';
|
|
604
|
+
if (!isBlanket)
|
|
605
|
+
continue;
|
|
606
|
+
const id = CANONICAL_TO_OPENCLAW_TOOL[parsed.tool];
|
|
607
|
+
if (id)
|
|
608
|
+
tools.add(id);
|
|
609
|
+
}
|
|
610
|
+
return Array.from(tools).sort();
|
|
611
|
+
};
|
|
612
|
+
return {
|
|
613
|
+
alsoAllow: map(set.allow),
|
|
614
|
+
deny: map(set.deny ?? []),
|
|
615
|
+
};
|
|
616
|
+
}
|
|
563
617
|
/**
|
|
564
618
|
* Convert canonical permission set to Antigravity format.
|
|
565
619
|
* Antigravity reads ~/.gemini/antigravity-cli/settings.json with
|
|
@@ -1570,6 +1624,51 @@ export function applyPermissionsToVersion(agentId, set, versionHome, merge = tru
|
|
|
1570
1624
|
fs.writeFileSync(permissionsPath, yaml.stringify(config), 'utf-8');
|
|
1571
1625
|
return { success: true };
|
|
1572
1626
|
}
|
|
1627
|
+
if (agentId === 'openclaw') {
|
|
1628
|
+
// OpenClaw's allowlist lives in ~/.openclaw/openclaw.json under `tools`.
|
|
1629
|
+
// Only blanket tool-level rules map (see convertToOpenClawFormat). We
|
|
1630
|
+
// read-modify-write to preserve all other keys (mcp, exec, agents, …) and
|
|
1631
|
+
// never touch `tools.allow` (the absolute allowlist that replaces defaults).
|
|
1632
|
+
const configPath = path.join(versionHome, '.openclaw', 'openclaw.json');
|
|
1633
|
+
let config = {};
|
|
1634
|
+
if (fs.existsSync(configPath)) {
|
|
1635
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
1636
|
+
}
|
|
1637
|
+
const converted = convertToOpenClawFormat(set);
|
|
1638
|
+
const existingTools = (typeof config.tools === 'object' && config.tools !== null && !Array.isArray(config.tools))
|
|
1639
|
+
? config.tools
|
|
1640
|
+
: {};
|
|
1641
|
+
let alsoAllow;
|
|
1642
|
+
let deny;
|
|
1643
|
+
if (merge) {
|
|
1644
|
+
const existingAllow = Array.isArray(existingTools.alsoAllow) ? existingTools.alsoAllow : [];
|
|
1645
|
+
const existingDeny = Array.isArray(existingTools.deny) ? existingTools.deny : [];
|
|
1646
|
+
alsoAllow = Array.from(new Set([...existingAllow, ...converted.alsoAllow]));
|
|
1647
|
+
deny = Array.from(new Set([...existingDeny, ...converted.deny]));
|
|
1648
|
+
}
|
|
1649
|
+
else {
|
|
1650
|
+
alsoAllow = converted.alsoAllow;
|
|
1651
|
+
deny = converted.deny;
|
|
1652
|
+
}
|
|
1653
|
+
// Set or delete each key: avoid writing empty arrays (churn). On a
|
|
1654
|
+
// non-merge replace with nothing to write, delete the stale key.
|
|
1655
|
+
const tools = { ...existingTools };
|
|
1656
|
+
if (alsoAllow.length > 0)
|
|
1657
|
+
tools.alsoAllow = alsoAllow;
|
|
1658
|
+
else
|
|
1659
|
+
delete tools.alsoAllow;
|
|
1660
|
+
if (deny.length > 0)
|
|
1661
|
+
tools.deny = deny;
|
|
1662
|
+
else
|
|
1663
|
+
delete tools.deny;
|
|
1664
|
+
if (Object.keys(tools).length > 0)
|
|
1665
|
+
config.tools = tools;
|
|
1666
|
+
else
|
|
1667
|
+
delete config.tools;
|
|
1668
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
1669
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
1670
|
+
return { success: true };
|
|
1671
|
+
}
|
|
1573
1672
|
return { success: false, error: `Agent '${agentId}' does not support permissions` };
|
|
1574
1673
|
}
|
|
1575
1674
|
catch (err) {
|
package/dist/lib/plugins.d.ts
CHANGED
|
@@ -211,6 +211,26 @@ export declare function goosePluginsDir(versionHome: string): string;
|
|
|
211
211
|
export declare function installGoosePlugin(plugin: DiscoveredPlugin, versionHome: string): boolean;
|
|
212
212
|
export declare function isGoosePluginInstalled(pluginName: string, versionHome: string): boolean;
|
|
213
213
|
export declare function removeGoosePlugin(pluginName: string, versionHome: string): boolean;
|
|
214
|
+
/**
|
|
215
|
+
* Hermes (Nous Research) loads plugins from a flat `$HOME/.hermes/plugins/<name>/`
|
|
216
|
+
* directory holding a `plugin.yaml` manifest — NOT the Claude marketplace layout.
|
|
217
|
+
* Under agents-cli version isolation HOME is the version home, so we install to:
|
|
218
|
+
* {versionHome}/.hermes/plugins/<name>/
|
|
219
|
+
* A plugin does not load until its name is added to `plugins.enabled` (a YAML
|
|
220
|
+
* array) in `{versionHome}/.hermes/config.yaml`; a deny-list `plugins.disabled`
|
|
221
|
+
* wins on conflict, so agents-cli only manages the `enabled` allowlist and never
|
|
222
|
+
* touches `disabled` (user-owned).
|
|
223
|
+
*/
|
|
224
|
+
export declare function hermesPluginsDir(versionHome: string): string;
|
|
225
|
+
/**
|
|
226
|
+
* Add or remove a plugin name in `plugins.enabled` within ~/.hermes/config.yaml,
|
|
227
|
+
* preserving every other key (read → mutate → write). Never touches
|
|
228
|
+
* `plugins.disabled`. No-op (no rewrite) when the desired state already holds.
|
|
229
|
+
*/
|
|
230
|
+
export declare function setHermesPluginEnabled(pluginName: string, versionHome: string, enabled: boolean): void;
|
|
231
|
+
export declare function installHermesPlugin(plugin: DiscoveredPlugin, versionHome: string, enable: boolean): boolean;
|
|
232
|
+
export declare function isHermesPluginInstalled(pluginName: string, versionHome: string): boolean;
|
|
233
|
+
export declare function removeHermesPlugin(pluginName: string, versionHome: string): boolean;
|
|
214
234
|
/**
|
|
215
235
|
* Check if a plugin is synced to a version. True when the plugin lives at the
|
|
216
236
|
* native marketplace install path. Legacy dual-dash entries are not counted —
|