ai-agentmanager 1.3.0 → 1.4.0
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 +17 -0
- package/README.md +23 -5
- package/docs/distributed.md +2 -1
- package/docs/plugins.md +2 -1
- package/package.json +1 -1
- package/public/app.js +107 -4
- package/public/code.js +449 -0
- package/public/hub/hub.js +33 -5
- package/public/index.html +62 -6
- package/public/machine.js +114 -0
- package/public/menu.js +92 -0
- package/public/style.css +80 -0
- package/public/update.js +88 -0
- package/src/cleanup.js +71 -2
- package/src/cli.js +74 -12
- package/src/client.js +46 -6
- package/src/fleet.js +1 -0
- package/src/git.js +2 -2
- package/src/handoff.js +240 -0
- package/src/hub.js +50 -2
- package/src/hubactions.js +14 -1
- package/src/identity.js +41 -0
- package/src/manager.js +25 -17
- package/src/server.js +50 -9
- package/src/studio.js +255 -0
- package/src/update.js +160 -0
package/src/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import { Command, Option } from 'commander';
|
|
4
4
|
import WebSocket from 'ws';
|
|
5
5
|
import { HOST, PORT } from './config.js';
|
|
6
|
-
import { api, BASE, ensureDaemon, isRunning } from './client.js';
|
|
6
|
+
import { api, BASE, ensureDaemon, health, isRunning, waitForExit } from './client.js';
|
|
7
7
|
import { about, latestRelease } from './about.js';
|
|
8
8
|
|
|
9
9
|
const tty = process.stdout.isTTY;
|
|
@@ -271,10 +271,16 @@ export async function main(argv) {
|
|
|
271
271
|
.command('serve')
|
|
272
272
|
.description('Run the agent daemon (API + web UI) in the foreground')
|
|
273
273
|
.action(async () => {
|
|
274
|
-
|
|
274
|
+
const h = await health();
|
|
275
|
+
if (h && !h.closing) {
|
|
275
276
|
// Clean exit, so a login service doesn't keep retrying while another daemon owns the port.
|
|
276
277
|
return console.log(`[agentmgr] a daemon is already running at ${BASE}; not starting another`);
|
|
277
278
|
}
|
|
279
|
+
if (h?.closing) {
|
|
280
|
+
// One that is shutting down closes its agents first; start once it has exited.
|
|
281
|
+
console.log(`[agentmgr] waiting for the previous daemon (pid ${h.pid}) to finish shutting down`);
|
|
282
|
+
if (!(await waitForExit(h.pid))) return console.log(`[agentmgr] the previous daemon (pid ${h.pid}) is still shutting down; not starting`);
|
|
283
|
+
}
|
|
278
284
|
const { startServer } = await import('./server.js');
|
|
279
285
|
const { url } = await startServer();
|
|
280
286
|
console.log(`[agentmgr] listening on ${url}`);
|
|
@@ -1340,9 +1346,11 @@ export async function main(argv) {
|
|
|
1340
1346
|
.option('--strategy <s>', 'close-delete (both automatically), close (close automatically, delete by hand), manual, or off')
|
|
1341
1347
|
.option('--close-after <days>', 'close sessions idle this many days')
|
|
1342
1348
|
.option('--delete-after <days>', 'delete agents closed this many days')
|
|
1349
|
+
.option('--max-open <n>', 'keep at most this many agents open; the ones idle longest are closed above it ("off": no limit)')
|
|
1343
1350
|
.action(async (opts) => {
|
|
1344
1351
|
await daemon();
|
|
1345
1352
|
const patch = {
|
|
1353
|
+
...(opts.maxOpen ? { maxRunning: /^(off|none|0)$/i.test(opts.maxOpen) ? null : Number(opts.maxOpen) } : {}),
|
|
1346
1354
|
...(opts.strategy ? { strategy: opts.strategy } : {}),
|
|
1347
1355
|
...(opts.closeAfter ? { closeAfterDays: Number(opts.closeAfter) } : {}),
|
|
1348
1356
|
...(opts.deleteAfter ? { deleteAfterDays: Number(opts.deleteAfter) } : {}),
|
|
@@ -1359,6 +1367,8 @@ export async function main(argv) {
|
|
|
1359
1367
|
const c = await api('GET', '/cleanup');
|
|
1360
1368
|
const what = { 'close-delete': 'closes and deletes automatically', close: 'closes automatically; delete with --run', manual: 'nothing automatic; use --run', off: 'off' }[c.strategy];
|
|
1361
1369
|
console.log(`${bold('Clean up')}: ${c.strategy} (${what}) · close after ${c.closeAfterDays} days idle · delete ${c.deleteAfterDays} days after closing`);
|
|
1370
|
+
console.log(`${bold('Open agents')}: ${c.running} open · ${c.maxRunning ? `at most ${c.maxRunning} (the ones idle longest close above it)` : 'no limit (set one: --max-open <n>)'}`);
|
|
1371
|
+
if (c.lastCap) console.log(dim(` last closed for the limit ${new Date(c.lastCap.at).toLocaleString()}: ${c.lastCap.closed.map((x) => x.name).join(', ')}`));
|
|
1362
1372
|
const rows = [
|
|
1363
1373
|
...c.close.map((x) => [yellow('close'), x.name, `idle ${x.idleDays}d`, '']),
|
|
1364
1374
|
...c.delete.map((x) => [red('delete'), x.name, `closed ${x.closedDays}d`, '']),
|
|
@@ -1685,6 +1695,19 @@ export async function main(argv) {
|
|
|
1685
1695
|
.option('--url <url>', 'hub address')
|
|
1686
1696
|
.action((ref, opts) => fleetAct(opts, ref, action, {}, (a, r) => `${action === 'start' ? 'starting' : 'stopping'} ${r.name}/${a.name}`));
|
|
1687
1697
|
}
|
|
1698
|
+
fleetCmd
|
|
1699
|
+
.command('move')
|
|
1700
|
+
.description('Move an agent to another machine and continue it there: same conversation, branch and uncommitted work (both machines need "move")')
|
|
1701
|
+
.argument('<agent>', 'machine/agent (or a unique agent id or name)')
|
|
1702
|
+
.requiredOption('--to <machine>', 'the machine to move it to')
|
|
1703
|
+
.option('--url <url>', 'hub address')
|
|
1704
|
+
.action(async (ref, opts) => {
|
|
1705
|
+
const f = await hubApi(opts, 'GET', '/fleet');
|
|
1706
|
+
const { runner, agent } = resolveFleetAgent(f, ref);
|
|
1707
|
+
console.log(dim(`moving ${runner.name}/${agent.name} to ${opts.to}…`));
|
|
1708
|
+
const out = await hubApi(opts, 'POST', `/runners/${runner.id}/agents/${agent.id}/move`, { to: opts.to });
|
|
1709
|
+
console.log(`${green('✓')} ${agent.name} now runs on ${out.to.machine} as ${out.to.agent}${out.agent?.status ? ` (${out.agent.status})` : ''}`);
|
|
1710
|
+
});
|
|
1688
1711
|
fleetCmd
|
|
1689
1712
|
.command('new')
|
|
1690
1713
|
.description('Start a new agent on another machine, in one of its projects (needs "create")')
|
|
@@ -1789,10 +1812,11 @@ export async function main(argv) {
|
|
|
1789
1812
|
.description('Register the daemon with launchd (macOS) or systemd --user (Linux) and start it')
|
|
1790
1813
|
.action(async () => {
|
|
1791
1814
|
const svc = await import('./service.js');
|
|
1792
|
-
|
|
1815
|
+
const h = await health();
|
|
1816
|
+
if (h) {
|
|
1793
1817
|
// Hand over to the service-managed daemon; running agents are resumed by it.
|
|
1794
|
-
await api('POST', '/shutdown');
|
|
1795
|
-
|
|
1818
|
+
if (!h.closing) await api('POST', '/shutdown');
|
|
1819
|
+
await waitForExit(h.pid, { onWait: (s) => console.log(dim(`closing agents… (${s}s)`)) });
|
|
1796
1820
|
}
|
|
1797
1821
|
const res = await svc.install();
|
|
1798
1822
|
for (let i = 0; i < 100 && !(await isRunning()); i++) await new Promise((r) => setTimeout(r, 200));
|
|
@@ -2001,7 +2025,8 @@ export async function main(argv) {
|
|
|
2001
2025
|
.description('Flush, stop the daemon, and start it again; running agents resume their sessions')
|
|
2002
2026
|
.action(async () => {
|
|
2003
2027
|
const svc = await import('./service.js');
|
|
2004
|
-
|
|
2028
|
+
const before = await health();
|
|
2029
|
+
if (before && !before.closing) {
|
|
2005
2030
|
try {
|
|
2006
2031
|
const r = await api('POST', '/flush');
|
|
2007
2032
|
console.log(dim(`saved ${r.agents} agents, scrollback for ${r.scrollback}`));
|
|
@@ -2011,24 +2036,61 @@ export async function main(argv) {
|
|
|
2011
2036
|
console.log(dim('running daemon is an older version (no flush); restarting it to upgrade'));
|
|
2012
2037
|
}
|
|
2013
2038
|
await api('POST', '/shutdown');
|
|
2014
|
-
|
|
2039
|
+
}
|
|
2040
|
+
// Wait for the old process itself to exit: it answers until every agent is closed.
|
|
2041
|
+
if (before?.pid) {
|
|
2042
|
+
const gone = await waitForExit(before.pid, { onWait: (s) => console.log(dim(`closing agents… (${s}s)`)) });
|
|
2043
|
+
if (!gone) throw new Error(`The daemon (pid ${before.pid}) is still shutting down after 5 minutes; check the log`);
|
|
2015
2044
|
}
|
|
2016
2045
|
const st = await svc.status();
|
|
2017
2046
|
if (st.loaded) await svc.kickstart();
|
|
2018
2047
|
else await ensureDaemon({ quiet: true });
|
|
2019
|
-
|
|
2020
|
-
|
|
2048
|
+
const back = async () => {
|
|
2049
|
+
const h = await health();
|
|
2050
|
+
return Boolean(h && !h.closing && h.pid !== before?.pid);
|
|
2051
|
+
};
|
|
2052
|
+
for (let i = 0; i < 150 && !(await back()); i++) await new Promise((res) => setTimeout(res, 200));
|
|
2053
|
+
if (!(await back())) throw new Error('Daemon did not come back; check the log');
|
|
2021
2054
|
const agents = await api('GET', '/agents');
|
|
2022
2055
|
console.log(`${green('✓')} restarted${st.loaded ? ' (login service)' : ''}; ${agents.filter((a) => a.desired === 'running').length} agents set to resume`);
|
|
2023
2056
|
});
|
|
2024
2057
|
|
|
2058
|
+
program
|
|
2059
|
+
.command('update')
|
|
2060
|
+
.description('Install the newest release and restart onto it (agents resume); a git checkout moves to the newest release tag')
|
|
2061
|
+
.option('--check', 'only say whether a newer release exists')
|
|
2062
|
+
.option('--auto-check <on|off>', 'whether the daemon checks for a new release twice a day (shown in the app)')
|
|
2063
|
+
.action(async (opts) => {
|
|
2064
|
+
const up = await import('./update.js');
|
|
2065
|
+
if (opts.autoCheck) {
|
|
2066
|
+
const on = /^(on|true|yes|1)$/i.test(opts.autoCheck);
|
|
2067
|
+
if ((await health())?.pid) await api('PUT', '/update', { check: on });
|
|
2068
|
+
else (await import('./config.js')).saveConfig({ updates: { check: on } });
|
|
2069
|
+
return console.log(`Update checks ${on ? green('on') : dim('off')}`);
|
|
2070
|
+
}
|
|
2071
|
+
const info = await up.checkUpdate();
|
|
2072
|
+
if (!info.newer) return console.log(`${green('✓')} ${info.current} is the newest release`);
|
|
2073
|
+
if (info.blocker) throw new Error(`${info.latest} is available, but: ${info.blocker}`);
|
|
2074
|
+
if (opts.check) return console.log(`${yellow(info.latest)} is available (you have ${info.current}); install it: agentmgr update`);
|
|
2075
|
+
const r = await up.installUpdate({ log: (m) => console.log(dim(m)) });
|
|
2076
|
+
console.log(`${green('✓')} installed ${r.to} (was ${r.from})`);
|
|
2077
|
+
if (!(await health())) return console.log(dim('The daemon is not running; it starts on the new version next time'));
|
|
2078
|
+
// The new version's own restart, not the copy of it loaded in this process.
|
|
2079
|
+
const { spawnSync } = await import('node:child_process');
|
|
2080
|
+
const res = spawnSync(process.execPath, [process.argv[1], 'restart'], { stdio: 'inherit' });
|
|
2081
|
+
if (res.status !== 0) process.exitCode = res.status ?? 1;
|
|
2082
|
+
});
|
|
2083
|
+
|
|
2025
2084
|
program
|
|
2026
2085
|
.command('shutdown')
|
|
2027
2086
|
.description('Stop the daemon (running agents are closed; sessions are kept)')
|
|
2028
2087
|
.action(async () => {
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2088
|
+
const h = await health();
|
|
2089
|
+
if (!h) return console.log('Daemon is not running');
|
|
2090
|
+
if (!h.closing) await api('POST', '/shutdown');
|
|
2091
|
+
// It closes every agent before it exits; say stopped only once it has.
|
|
2092
|
+
const gone = await waitForExit(h.pid, { onWait: (s) => console.log(dim(`closing agents… (${s}s)`)) });
|
|
2093
|
+
console.log(gone ? 'Daemon stopped' : yellow(`The daemon (pid ${h.pid}) is still shutting down; check the log`));
|
|
2032
2094
|
});
|
|
2033
2095
|
|
|
2034
2096
|
try {
|
package/src/client.js
CHANGED
|
@@ -19,18 +19,58 @@ export async function api(method, route, body) {
|
|
|
19
19
|
return data;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** The daemon's /health answer ({ ok, pid, closing, … }), or null when nothing answers. */
|
|
23
|
+
export async function health() {
|
|
24
|
+
try {
|
|
25
|
+
return await api('GET', '/health');
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A daemon that answers and isn't shutting down. */
|
|
22
32
|
export async function isRunning() {
|
|
33
|
+
const h = await health();
|
|
34
|
+
return Boolean(h && !h.closing);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const alive = (pid) => {
|
|
23
38
|
try {
|
|
24
|
-
|
|
39
|
+
process.kill(pid, 0);
|
|
25
40
|
return true;
|
|
26
|
-
} catch {
|
|
27
|
-
return
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return e.code === 'EPERM';
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Wait for a daemon process to exit. Shutting down closes every agent first (to save their
|
|
48
|
+
* sessions), which takes a while with many agents, and it answers /health until then.
|
|
49
|
+
* Returns true once it is gone, false after `timeoutMs`.
|
|
50
|
+
*/
|
|
51
|
+
export async function waitForExit(pid, { timeoutMs = 300_000, onWait = null } = {}) {
|
|
52
|
+
if (!pid) return true;
|
|
53
|
+
const start = Date.now();
|
|
54
|
+
let told = 0;
|
|
55
|
+
while (alive(pid)) {
|
|
56
|
+
if (Date.now() - start > timeoutMs) return false;
|
|
57
|
+
if (onWait && Date.now() - told > 10_000) {
|
|
58
|
+
told = Date.now();
|
|
59
|
+
onWait(Math.round((Date.now() - start) / 1000));
|
|
60
|
+
}
|
|
61
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
28
62
|
}
|
|
63
|
+
return true;
|
|
29
64
|
}
|
|
30
65
|
|
|
31
|
-
/** Start the daemon in the background if it is not already listening. */
|
|
66
|
+
/** Start the daemon in the background if it is not already listening (waiting out one that is shutting down). */
|
|
32
67
|
export async function ensureDaemon({ quiet = false } = {}) {
|
|
33
|
-
|
|
68
|
+
const h = await health();
|
|
69
|
+
if (h && !h.closing) return false;
|
|
70
|
+
if (h?.closing) {
|
|
71
|
+
if (!quiet) process.stderr.write(`agentmgr: waiting for the daemon (pid ${h.pid}) to finish shutting down…\n`);
|
|
72
|
+
if (!(await waitForExit(h.pid))) throw new Error(`The daemon (pid ${h.pid}) is still shutting down; try again in a minute`);
|
|
73
|
+
}
|
|
34
74
|
ensureHome();
|
|
35
75
|
const log = fs.openSync(paths.log, 'a');
|
|
36
76
|
const child = spawn(process.execPath, [BIN, 'serve'], {
|
|
@@ -39,7 +79,7 @@ export async function ensureDaemon({ quiet = false } = {}) {
|
|
|
39
79
|
env: process.env,
|
|
40
80
|
});
|
|
41
81
|
child.unref();
|
|
42
|
-
for (let i = 0; i <
|
|
82
|
+
for (let i = 0; i < 150; i++) {
|
|
43
83
|
await new Promise((r) => setTimeout(r, 100));
|
|
44
84
|
if (await isRunning()) {
|
|
45
85
|
if (!quiet) process.stderr.write(`agentmgr: started daemon at ${BASE} (log: ${paths.log})\n`);
|
package/src/fleet.js
CHANGED
|
@@ -45,6 +45,7 @@ export function fleetAgent(a) {
|
|
|
45
45
|
stuck: Array.isArray(a.stuck) ? a.stuck.length : num(a.stuck) ?? 0,
|
|
46
46
|
conflicts: Array.isArray(a.conflicts) ? a.conflicts.length : num(a.conflicts) ?? 0,
|
|
47
47
|
raceId: str(a.raceId, 20),
|
|
48
|
+
movedTo: str(typeof a.movedTo === 'string' ? a.movedTo : a.movedTo?.machine, 60),
|
|
48
49
|
error: str(a.error, 300),
|
|
49
50
|
createdAt: iso(a.createdAt),
|
|
50
51
|
updatedAt: iso(a.updatedAt),
|
package/src/git.js
CHANGED
|
@@ -7,7 +7,7 @@ const baseEnv = () => ({ ...process.env, GIT_TERMINAL_PROMPT: '0' });
|
|
|
7
7
|
|
|
8
8
|
const cancelled = () => Object.assign(new Error('Cancelled'), { cancelled: true });
|
|
9
9
|
|
|
10
|
-
export function run(cmd, args, { cwd, timeout = 60_000, signal, env } = {}) {
|
|
10
|
+
export function run(cmd, args, { cwd, timeout = 60_000, signal, env, trim = true } = {}) {
|
|
11
11
|
return new Promise((resolve, reject) => {
|
|
12
12
|
execFile(cmd, args, { cwd, timeout, signal, env: { ...baseEnv(), ...env }, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
13
13
|
if (err) {
|
|
@@ -15,7 +15,7 @@ export function run(cmd, args, { cwd, timeout = 60_000, signal, env } = {}) {
|
|
|
15
15
|
err.message = `${cmd} ${args.join(' ')} failed: ${(stderr || err.message).trim()}`;
|
|
16
16
|
reject(err);
|
|
17
17
|
} else {
|
|
18
|
-
resolve(stdout.trim());
|
|
18
|
+
resolve(trim ? stdout.trim() : stdout);
|
|
19
19
|
}
|
|
20
20
|
});
|
|
21
21
|
});
|
package/src/handoff.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Moving an agent to another machine through the hub, so it continues there: same session (its
|
|
2
|
+
// conversation), same branch, its uncommitted work. Code travels through the repo's own remote
|
|
3
|
+
// (a handoff branch holding a snapshot of the folder, deleted once the move is done); the
|
|
4
|
+
// conversation travels through the hub in chunks; the hub keeps neither. Both machines must
|
|
5
|
+
// allow "move" in their hub settings.
|
|
6
|
+
//
|
|
7
|
+
// On the machine it leaves: export (close the session, snapshot and push the work, hold the
|
|
8
|
+
// transcript), chunk (pieces of the transcript), done (mark it moved, or start it again if the
|
|
9
|
+
// move failed). On the machine it joins: import (create the agent in the matching project, with
|
|
10
|
+
// the same session id), put (transcript pieces), finish (check out the work, place the
|
|
11
|
+
// conversation, resume it if it was running).
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import * as git from './git.js';
|
|
16
|
+
import { projectRef } from './fleet.js';
|
|
17
|
+
import { engineOf } from './engines/index.js';
|
|
18
|
+
import { briefPath } from './brief.js';
|
|
19
|
+
|
|
20
|
+
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
21
|
+
export const CHUNK_BYTES = 512 * 1024; // base64 keeps a piece under the runner link's 1 MB frames
|
|
22
|
+
const EXPIRE_MS = 30 * 60_000;
|
|
23
|
+
export const handoffBranch = (agentId) => `agentmgr-handoff/${agentId}`;
|
|
24
|
+
const MAX_TRANSCRIPT_BYTES = 200 * 1024 * 1024;
|
|
25
|
+
/** A plain object of links small enough to keep (they come from the other machine). */
|
|
26
|
+
const small = (o) => Boolean(o) && typeof o === 'object' && !Array.isArray(o) && JSON.stringify(o).length < 100_000;
|
|
27
|
+
|
|
28
|
+
/** A transcript's lines with the old folder replaced by the new one where Claude recorded it. */
|
|
29
|
+
export function moveTranscript(text, fromCwd, toCwd) {
|
|
30
|
+
if (!fromCwd || fromCwd === toCwd) return text;
|
|
31
|
+
return text
|
|
32
|
+
.split('\n')
|
|
33
|
+
.map((line) => {
|
|
34
|
+
if (!line.includes(fromCwd)) return line;
|
|
35
|
+
try {
|
|
36
|
+
const o = JSON.parse(line);
|
|
37
|
+
if (o.cwd === fromCwd) o.cwd = toCwd;
|
|
38
|
+
else if (typeof o.cwd === 'string' && o.cwd.startsWith(`${fromCwd}/`)) o.cwd = toCwd + o.cwd.slice(fromCwd.length);
|
|
39
|
+
return JSON.stringify(o);
|
|
40
|
+
} catch {
|
|
41
|
+
return line;
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
.join('\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class Handoff {
|
|
48
|
+
constructor({ manager, log = (m) => console.log(`[agentmgr] ${m}`) }) {
|
|
49
|
+
this.manager = manager;
|
|
50
|
+
this.log = log;
|
|
51
|
+
this.exports = new Map(); // exportId -> { agentId, buf, wasRunning, at, remote, branch }
|
|
52
|
+
this.imports = new Map(); // importId -> { agentId, manifest, parts: [], at }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
sweep() {
|
|
56
|
+
const old = Date.now() - EXPIRE_MS;
|
|
57
|
+
for (const [k, v] of this.exports) if (v.at < old) this.exports.delete(k);
|
|
58
|
+
for (const [k, v] of this.imports) if (v.at < old) this.imports.delete(k);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ------------------------------------------------------------- leaving
|
|
62
|
+
|
|
63
|
+
async export(agent) {
|
|
64
|
+
this.sweep();
|
|
65
|
+
const m = this.manager;
|
|
66
|
+
if (agent.kind !== 'task') throw err(400, 'Only task agents can move to another machine');
|
|
67
|
+
if (agent.status === 'preparing') throw err(409, 'The agent is still setting up');
|
|
68
|
+
if (agent.movedTo) throw err(409, `Already moved to ${agent.movedTo.machine}`);
|
|
69
|
+
const dir = agent.workdir;
|
|
70
|
+
if (!dir || !(await git.isGitRepo(dir))) throw err(400, 'The agent folder is not a git repository');
|
|
71
|
+
const remote = await git.run('git', ['remote', 'get-url', 'origin'], { cwd: dir }).catch(() => '');
|
|
72
|
+
if (!remote) throw err(400, "The agent's repository has no remote (origin), so its work can't reach another machine");
|
|
73
|
+
const engine = engineOf(agent);
|
|
74
|
+
if (engine.id !== 'claude') throw err(400, `Moving ${engine.label} sessions isn't supported yet`);
|
|
75
|
+
const info = m.projectInfo(agent);
|
|
76
|
+
const wasRunning = Boolean(m.runtimes.get(agent.id)?.pty);
|
|
77
|
+
// Close the session so Claude writes everything; it stays closed here from now on.
|
|
78
|
+
await m.stop(agent.id);
|
|
79
|
+
await m.flush().catch(() => {});
|
|
80
|
+
const snap = await git.snapshot(dir, `agentmgr handoff of ${agent.name}`);
|
|
81
|
+
if (!snap) throw err(400, 'The agent folder has no commits yet');
|
|
82
|
+
const branch = handoffBranch(agent.id);
|
|
83
|
+
await git.run('git', ['push', '--force', '--no-verify', 'origin', `${snap.sha}:refs/heads/${branch}`], { cwd: dir, timeout: 180_000 });
|
|
84
|
+
const file = engine.sessionFile(dir, agent.sessionId);
|
|
85
|
+
const buf = fs.existsSync(file) ? fs.readFileSync(file) : Buffer.alloc(0);
|
|
86
|
+
if (buf.length > MAX_TRANSCRIPT_BYTES) throw err(413, 'The conversation is too large to move');
|
|
87
|
+
const exportId = crypto.randomBytes(8).toString('hex');
|
|
88
|
+
this.exports.set(exportId, { agentId: agent.id, buf, wasRunning, at: Date.now(), dir, branch });
|
|
89
|
+
const brief = m.briefFile?.(agent.id)?.markdown ?? null;
|
|
90
|
+
this.log(`handoff: ${agent.name} packed for another machine (${buf.length} bytes of conversation, work on ${branch})`);
|
|
91
|
+
return {
|
|
92
|
+
exportId,
|
|
93
|
+
manifest: {
|
|
94
|
+
from: { agentId: agent.id, workdir: fs.realpathSync(dir) },
|
|
95
|
+
name: agent.name,
|
|
96
|
+
intent: agent.intent,
|
|
97
|
+
priority: agent.priority,
|
|
98
|
+
model: agent.model ?? null,
|
|
99
|
+
permissionMode: agent.permissionMode ?? null,
|
|
100
|
+
notes: agent.notes ?? null,
|
|
101
|
+
baseRef: agent.baseRef ?? null,
|
|
102
|
+
links: { prs: agent.prs ?? {}, bugs: agent.bugs ?? {} },
|
|
103
|
+
brief: brief && brief.length < 200_000 ? brief : null,
|
|
104
|
+
project: { ref: projectRef(info), name: info.name },
|
|
105
|
+
branch: agent.git?.branch ?? agent.branch ?? null,
|
|
106
|
+
handoffBranch: branch,
|
|
107
|
+
head: snap.head,
|
|
108
|
+
snapshot: snap.sha,
|
|
109
|
+
sessionId: agent.sessionId,
|
|
110
|
+
transcriptBytes: buf.length,
|
|
111
|
+
chunks: Math.ceil(buf.length / CHUNK_BYTES),
|
|
112
|
+
wasRunning,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
chunk(exportId, i) {
|
|
118
|
+
const x = this.exports.get(String(exportId));
|
|
119
|
+
if (!x) throw err(404, 'That move expired; start it again');
|
|
120
|
+
const n = Number(i);
|
|
121
|
+
if (!(n >= 0) || n * CHUNK_BYTES >= Math.max(x.buf.length, 1)) throw err(400, 'No such piece');
|
|
122
|
+
return { i: n, data: x.buf.subarray(n * CHUNK_BYTES, (n + 1) * CHUNK_BYTES).toString('base64') };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** After the other machine answered: mark the agent moved (and drop the handoff branch), or undo. */
|
|
126
|
+
async done(exportId, { ok, machine, agentId: newId } = {}) {
|
|
127
|
+
const x = this.exports.get(String(exportId));
|
|
128
|
+
if (!x) throw err(404, 'That move expired');
|
|
129
|
+
this.exports.delete(String(exportId));
|
|
130
|
+
const m = this.manager;
|
|
131
|
+
if (!ok) {
|
|
132
|
+
// It stays here: open it again as it was.
|
|
133
|
+
await git.run('git', ['push', '--no-verify', 'origin', '--delete', x.branch], { cwd: x.dir, timeout: 60_000 }).catch(() => {});
|
|
134
|
+
if (x.wasRunning) await m.start(x.agentId).catch(() => {});
|
|
135
|
+
return m.get(x.agentId);
|
|
136
|
+
}
|
|
137
|
+
// The other machine has the work now; the branch was only the way there.
|
|
138
|
+
await git.run('git', ['push', '--no-verify', 'origin', '--delete', x.branch], { cwd: x.dir, timeout: 60_000 }).catch(() => {});
|
|
139
|
+
m.patch(x.agentId, { movedTo: { machine: String(machine ?? 'another machine').slice(0, 60), agentId: newId ?? null, at: new Date().toISOString() }, desired: 'stopped' });
|
|
140
|
+
this.log(`handoff: ${m.store.get(x.agentId)?.name} moved to ${machine}`);
|
|
141
|
+
return m.get(x.agentId);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ------------------------------------------------------------- joining
|
|
145
|
+
|
|
146
|
+
/** Create the agent in the project that matches, with the same session; it sets up its folder. */
|
|
147
|
+
import(manifest, policy, allowedProject) {
|
|
148
|
+
this.sweep();
|
|
149
|
+
const m = this.manager;
|
|
150
|
+
const mf = manifest && typeof manifest === 'object' ? manifest : {};
|
|
151
|
+
if (!/^[\w-]{8,64}$/.test(String(mf.sessionId ?? ''))) throw err(400, 'Missing session');
|
|
152
|
+
if (!/^[0-9a-f]{40,64}$/.test(String(mf.snapshot ?? '')) || !/^[0-9a-f]{40,64}$/.test(String(mf.head ?? ''))) throw err(400, 'Missing work snapshot');
|
|
153
|
+
if (!/^agentmgr-handoff\/[\w-]{1,40}$/.test(String(mf.handoffBranch ?? ''))) throw err(400, 'Bad handoff branch');
|
|
154
|
+
const ref = String(mf.project?.ref ?? '');
|
|
155
|
+
const entry = m.listProjects().find((p) => projectRef(p) === ref);
|
|
156
|
+
if (!entry) throw err(400, `This machine has no project for ${mf.project?.name ?? ref}`);
|
|
157
|
+
if (!allowedProject(policy.projects, entry.name, entry.shortcut)) throw err(403, 'This machine does not share that project with the hub');
|
|
158
|
+
if (m.store.list().some((a) => a.sessionId === mf.sessionId && !a.movedTo)) throw err(409, 'That session is already on this machine');
|
|
159
|
+
const [agent] = m.createMany([String(mf.intent ?? '').slice(0, 10_000) || 'Moved agent'], {
|
|
160
|
+
project: entry.shortcut,
|
|
161
|
+
priority: mf.priority,
|
|
162
|
+
model: mf.model ?? undefined,
|
|
163
|
+
permissionMode: ['default', 'acceptEdits', 'plan'].includes(mf.permissionMode) ? mf.permissionMode : undefined,
|
|
164
|
+
sessionId: mf.sessionId,
|
|
165
|
+
start: false,
|
|
166
|
+
name: mf.name ? String(mf.name).slice(0, 60) : undefined,
|
|
167
|
+
});
|
|
168
|
+
const importId = crypto.randomBytes(8).toString('hex');
|
|
169
|
+
this.imports.set(importId, { agentId: agent.id, manifest: mf, parts: [], at: Date.now() });
|
|
170
|
+
return { importId, agentId: agent.id, name: agent.name };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
put(importId, i, data) {
|
|
174
|
+
const x = this.imports.get(String(importId));
|
|
175
|
+
if (!x) throw err(404, 'That move expired');
|
|
176
|
+
const n = Number(i);
|
|
177
|
+
if (!(n >= 0 && n < Math.max(1, Number(x.manifest.chunks) || 0))) throw err(400, 'No such piece');
|
|
178
|
+
x.parts[n] = Buffer.from(String(data ?? ''), 'base64');
|
|
179
|
+
return { i: n };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A move that failed after the agent was created here: remove it again. */
|
|
183
|
+
async cancel(importId) {
|
|
184
|
+
const x = this.imports.get(String(importId));
|
|
185
|
+
if (!x) return { ok: true };
|
|
186
|
+
this.imports.delete(String(importId));
|
|
187
|
+
await this.manager.remove(x.agentId, { deleteWorkspace: true }).catch(() => {});
|
|
188
|
+
return { ok: true };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async finish(importId, { timeoutMs = 170_000 } = {}) {
|
|
192
|
+
const x = this.imports.get(String(importId));
|
|
193
|
+
if (!x) throw err(404, 'That move expired');
|
|
194
|
+
const m = this.manager;
|
|
195
|
+
const mf = x.manifest;
|
|
196
|
+
const chunks = Number(mf.chunks) || 0;
|
|
197
|
+
for (let i = 0; i < chunks; i++) if (!x.parts[i]) throw err(400, `Piece ${i + 1} of the conversation is missing`);
|
|
198
|
+
const buf = Buffer.concat(x.parts.slice(0, chunks));
|
|
199
|
+
if (buf.length !== Number(mf.transcriptBytes)) throw err(400, 'The conversation arrived incomplete');
|
|
200
|
+
// Wait for its folder (a clone or worktree of the project).
|
|
201
|
+
const until = Date.now() + timeoutMs;
|
|
202
|
+
let agent = m.store.get(x.agentId);
|
|
203
|
+
while (agent && (agent.status === 'preparing' || !agent.workdir || !fs.existsSync(agent.workdir))) {
|
|
204
|
+
if (agent.status === 'error') throw err(500, `Setting up the folder failed: ${agent.error ?? 'unknown error'}`);
|
|
205
|
+
if (Date.now() > until) throw err(504, 'Setting up the folder is taking too long');
|
|
206
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
207
|
+
agent = m.store.get(x.agentId);
|
|
208
|
+
}
|
|
209
|
+
if (!agent) throw err(404, 'The new agent was removed');
|
|
210
|
+
const dir = agent.workdir;
|
|
211
|
+
const opts = { cwd: dir, timeout: 180_000 };
|
|
212
|
+
await git.run('git', ['fetch', '--no-tags', 'origin', `+refs/heads/${mf.handoffBranch}:refs/agentmgr/handoff/${agent.id}`], opts);
|
|
213
|
+
const branch = /^[\w./-]{1,200}$/.test(String(mf.branch ?? '')) && !String(mf.branch).includes('..') ? mf.branch : null;
|
|
214
|
+
if (branch) await git.run('git', ['checkout', '-q', '-B', branch, mf.head], opts);
|
|
215
|
+
else await git.run('git', ['checkout', '-q', '--detach', mf.head], opts);
|
|
216
|
+
await git.restoreSnapshot(dir, mf.snapshot);
|
|
217
|
+
await git.run('git', ['update-ref', '-d', `refs/agentmgr/handoff/${agent.id}`], opts).catch(() => {});
|
|
218
|
+
// The conversation, where Claude looks for it in this folder.
|
|
219
|
+
const engine = engineOf(agent);
|
|
220
|
+
const file = engine.sessionFile(dir, mf.sessionId);
|
|
221
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
222
|
+
fs.writeFileSync(file, moveTranscript(buf.toString('utf8'), mf.from?.workdir, fs.realpathSync(dir)));
|
|
223
|
+
m.patch(agent.id, {
|
|
224
|
+
movedFrom: { machine: mf.fromMachine ? String(mf.fromMachine).slice(0, 60) : null, agentId: mf.from?.agentId ?? null, at: new Date().toISOString() },
|
|
225
|
+
...(mf.notes ? { notes: String(mf.notes).slice(0, 20_000) } : {}),
|
|
226
|
+
...(mf.baseRef ? { baseRef: String(mf.baseRef).slice(0, 300) } : {}),
|
|
227
|
+
...(small(mf.links?.prs) ? { prs: mf.links.prs } : {}),
|
|
228
|
+
...(small(mf.links?.bugs) ? { bugs: mf.links.bugs } : {}),
|
|
229
|
+
...(branch ? { branch } : {}),
|
|
230
|
+
});
|
|
231
|
+
if (mf.brief) {
|
|
232
|
+
fs.mkdirSync(path.dirname(briefPath(agent.id)), { recursive: true });
|
|
233
|
+
fs.writeFileSync(briefPath(agent.id), String(mf.brief));
|
|
234
|
+
}
|
|
235
|
+
this.imports.delete(String(importId));
|
|
236
|
+
if (mf.wasRunning) await m.start(agent.id);
|
|
237
|
+
this.log(`handoff: ${agent.name} arrived${mf.fromMachine ? ` from ${mf.fromMachine}` : ''}${mf.wasRunning ? ' and resumed' : ''}`);
|
|
238
|
+
return m.get(agent.id);
|
|
239
|
+
}
|
|
240
|
+
}
|
package/src/hub.js
CHANGED
|
@@ -35,7 +35,10 @@ export const ACTION_PERMS = {
|
|
|
35
35
|
answer: 'inbox', 'prompt-key': 'inbox', nudge: 'inbox', 'dismiss-stuck': 'inbox', 'warn-conflict': 'inbox',
|
|
36
36
|
tell: 'control', start: 'control', stop: 'control', checkpoint: 'control', priority: 'control', create: 'create',
|
|
37
37
|
signin: 'signin',
|
|
38
|
+
'handoff-export': 'move', 'handoff-chunk': 'move', 'handoff-done': 'move', 'handoff-import': 'move', 'handoff-put': 'move', 'handoff-finish': 'move', 'handoff-cancel': 'move',
|
|
38
39
|
};
|
|
40
|
+
// Run only as steps of a move (POST /api/runners/:id/agents/:agent/move), never one by one.
|
|
41
|
+
const HANDOFF_STEP = /^handoff-/;
|
|
39
42
|
|
|
40
43
|
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
|
|
41
44
|
const secret = () => crypto.randomBytes(32).toString('base64url');
|
|
@@ -490,7 +493,7 @@ export async function startHub({
|
|
|
490
493
|
};
|
|
491
494
|
|
|
492
495
|
/** Ask a machine to do something; resolves with its reply. */
|
|
493
|
-
function rpc(runnerId, action, args) {
|
|
496
|
+
function rpc(runnerId, action, args, { timeoutMs = RPC_TIMEOUT_MS } = {}) {
|
|
494
497
|
const l = live.get(runnerId);
|
|
495
498
|
if (!l?.hello) return Promise.reject(fail(409, 'That machine is offline'));
|
|
496
499
|
const perm = ACTION_PERMS[action];
|
|
@@ -501,7 +504,7 @@ export async function startHub({
|
|
|
501
504
|
const timer = setTimeout(() => {
|
|
502
505
|
l.rpcs.delete(id);
|
|
503
506
|
reject(fail(504, 'The machine did not answer in time'));
|
|
504
|
-
},
|
|
507
|
+
}, timeoutMs);
|
|
505
508
|
l.rpcs.set(id, { resolve, reject, timer });
|
|
506
509
|
l.ws.send(JSON.stringify({ t: 'rpc', id, action, args }));
|
|
507
510
|
});
|
|
@@ -550,12 +553,57 @@ export async function startHub({
|
|
|
550
553
|
}),
|
|
551
554
|
);
|
|
552
555
|
|
|
556
|
+
// Move an agent to another machine: its work through the repo's remote, its conversation through
|
|
557
|
+
// here in pieces (kept in memory only for the move). Both machines must allow "move".
|
|
558
|
+
async function moveAgent(from, agentId, to, by) {
|
|
559
|
+
const step = (what) => log(`move ${agentId} ${from.name} → ${to.name}: ${what}`);
|
|
560
|
+
const exp = await rpc(from.id, 'handoff-export', { agent: agentId }, { timeoutMs: 240_000 });
|
|
561
|
+
step('packed');
|
|
562
|
+
let imp = null;
|
|
563
|
+
try {
|
|
564
|
+
imp = await rpc(to.id, 'handoff-import', { manifest: { ...exp.manifest, fromMachine: from.name } }, { timeoutMs: 60_000 });
|
|
565
|
+
for (let i = 0; i < exp.manifest.chunks; i++) {
|
|
566
|
+
const piece = await rpc(from.id, 'handoff-chunk', { exportId: exp.exportId, i }, { timeoutMs: 60_000 });
|
|
567
|
+
await rpc(to.id, 'handoff-put', { importId: imp.importId, i, data: piece.data }, { timeoutMs: 60_000 });
|
|
568
|
+
}
|
|
569
|
+
step(`conversation sent (${exp.manifest.chunks} piece(s))`);
|
|
570
|
+
const fin = await rpc(to.id, 'handoff-finish', { importId: imp.importId }, { timeoutMs: 240_000 });
|
|
571
|
+
await rpc(from.id, 'handoff-done', { exportId: exp.exportId, ok: true, machine: to.name, agentId: imp.agentId }, { timeoutMs: 60_000 }).catch((e) => step(`marking it moved failed: ${e.message}`));
|
|
572
|
+
audit(`${by} moved ${from.name}/${agentId} to ${to.name}/${imp.agentId}`);
|
|
573
|
+
return { ok: true, from: { machine: from.name, agent: agentId }, to: { machine: to.name, agent: imp.agentId }, agent: fin.agent };
|
|
574
|
+
} catch (e) {
|
|
575
|
+
step(`failed: ${e.message}`);
|
|
576
|
+
if (imp) await rpc(to.id, 'handoff-cancel', { importId: imp.importId }, { timeoutMs: 60_000 }).catch(() => {});
|
|
577
|
+
await rpc(from.id, 'handoff-done', { exportId: exp.exportId, ok: false }, { timeoutMs: 60_000 }).catch(() => {});
|
|
578
|
+
audit(`${by} failed to move ${from.name}/${agentId} to ${to.name}: ${e.message}`);
|
|
579
|
+
throw e;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
const runnerByRef = (ref) => store.data.runners[ref] ?? Object.values(store.data.runners).find((x) => x.name.toLowerCase() === String(ref ?? '').toLowerCase());
|
|
583
|
+
api.post(
|
|
584
|
+
'/runners/:id/agents/:agent/move',
|
|
585
|
+
wrap(async (req) => {
|
|
586
|
+
const from = runnerByRef(req.params.id);
|
|
587
|
+
const to = runnerByRef(req.body?.to);
|
|
588
|
+
if (!from) throw fail(404, `No machine ${req.params.id}`);
|
|
589
|
+
if (!to) throw fail(400, 'Pick the machine to move it to');
|
|
590
|
+
if (to.id === from.id) throw fail(400, 'It is already on that machine');
|
|
591
|
+
for (const r of [from, to]) {
|
|
592
|
+
const l = live.get(r.id);
|
|
593
|
+
if (!l?.hello) throw fail(409, `${r.name} is offline`);
|
|
594
|
+
if (!l.hello.policy.allow.includes('move')) throw fail(403, `${r.name} doesn't allow "move" from the hub`);
|
|
595
|
+
}
|
|
596
|
+
return moveAgent(from, String(req.params.agent).slice(0, 40), to, req.auth);
|
|
597
|
+
}),
|
|
598
|
+
);
|
|
599
|
+
|
|
553
600
|
api.post(
|
|
554
601
|
'/runners/:id/actions',
|
|
555
602
|
wrap(async (req) => {
|
|
556
603
|
const r = store.data.runners[req.params.id];
|
|
557
604
|
if (!r) throw fail(404, `No machine ${req.params.id}`);
|
|
558
605
|
const { action, args = {} } = req.body ?? {};
|
|
606
|
+
if (HANDOFF_STEP.test(String(action))) throw fail(400, 'Moving an agent is one request: POST /api/runners/<machine>/agents/<agent>/move');
|
|
559
607
|
const what = `${String(action).slice(0, 40)}${args.agent ? ` on ${r.name}/${String(args.agent).slice(0, 40)}` : ` on ${r.name}`}`;
|
|
560
608
|
try {
|
|
561
609
|
const out = await rpc(r.id, action, args);
|
package/src/hubactions.js
CHANGED
|
@@ -10,11 +10,13 @@
|
|
|
10
10
|
// terminal live Claude terminals
|
|
11
11
|
// shell shells in an agent's folder (runs any command: grant with care)
|
|
12
12
|
// signin one-time sign-in links to this machine's own page (full access there: grant with care)
|
|
13
|
+
// move move agents to and from other machines (their work goes through the repo's remote)
|
|
13
14
|
import { engineOf, installed } from './engines/index.js';
|
|
14
15
|
import { allowedProject } from './fleet.js';
|
|
15
16
|
import { createTicket } from './remote.js';
|
|
17
|
+
import { Handoff } from './handoff.js';
|
|
16
18
|
|
|
17
|
-
export const PERMISSIONS = ['view', 'inbox', 'control', 'create', 'terminal', 'shell', 'signin'];
|
|
19
|
+
export const PERMISSIONS = ['view', 'inbox', 'control', 'create', 'terminal', 'shell', 'signin', 'move'];
|
|
18
20
|
// The hub may not start agents that skip permission prompts.
|
|
19
21
|
const HUB_PERMISSION_MODES = new Set(['default', 'acceptEdits', 'plan']);
|
|
20
22
|
|
|
@@ -58,8 +60,18 @@ export const ACTIONS = {
|
|
|
58
60
|
priority: { perm: 'control', run: (m, a, args) => m.update(a.id, { priority: args.priority }) },
|
|
59
61
|
create: { perm: 'create', agentless: true, run: (m, _a, args, policy) => createAgent(m, args, policy) },
|
|
60
62
|
signin: { perm: 'signin', agentless: true, run: () => ({ ticket: createTicket() }) },
|
|
63
|
+
// Moving an agent between machines (src/handoff.js); both machines must allow "move".
|
|
64
|
+
'handoff-export': { perm: 'move', raw: true, run: (m, a) => handoffOf(m).export(a) },
|
|
65
|
+
'handoff-chunk': { perm: 'move', agentless: true, raw: true, run: (m, _a, args) => handoffOf(m).chunk(args.exportId, args.i) },
|
|
66
|
+
'handoff-done': { perm: 'move', agentless: true, run: (m, _a, args) => handoffOf(m).done(args.exportId, { ok: Boolean(args.ok), machine: args.machine, agentId: args.agentId }) },
|
|
67
|
+
'handoff-import': { perm: 'move', agentless: true, raw: true, run: (m, _a, args, policy) => handoffOf(m).import(args.manifest, policy, allowedProject) },
|
|
68
|
+
'handoff-put': { perm: 'move', agentless: true, raw: true, run: (m, _a, args) => handoffOf(m).put(args.importId, args.i, args.data) },
|
|
69
|
+
'handoff-finish': { perm: 'move', agentless: true, run: (m, _a, args) => handoffOf(m).finish(args.importId) },
|
|
70
|
+
'handoff-cancel': { perm: 'move', agentless: true, raw: true, run: (m, _a, args) => handoffOf(m).cancel(args.importId) },
|
|
61
71
|
};
|
|
62
72
|
|
|
73
|
+
const handoffOf = (m) => (m.handoff ??= new Handoff({ manager: m }));
|
|
74
|
+
|
|
63
75
|
function createAgent(manager, args, policy) {
|
|
64
76
|
const entry = manager.resolveProject(String(args.project ?? ''));
|
|
65
77
|
if (!entry) throw err(400, 'Pick one of the projects this machine shares');
|
|
@@ -89,6 +101,7 @@ export async function runAction(manager, policy, action, args = {}) {
|
|
|
89
101
|
manager.plugins?.check('hub.action', { action, agent: agent ? manager.auditView(agent) : null });
|
|
90
102
|
manager.plugins?.emit('hub.action', { action, agent: agent ? manager.auditView(agent) : null });
|
|
91
103
|
const out = await def.run(manager, agent, args, policy);
|
|
104
|
+
if (def.raw) return { ok: true, ...out };
|
|
92
105
|
// Enough for the hub to confirm; the next state frame carries the rest.
|
|
93
106
|
if (out?.ticket) return { ok: true, ticket: out.ticket };
|
|
94
107
|
if (Array.isArray(out)) return { ok: true, agents: out.map((a) => ({ id: a.id, name: a.name })) };
|