ai-agentmanager 1.10.0 → 1.11.1
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 +11 -0
- package/README.md +26 -3
- package/bin/acp-client.js +332 -0
- package/docker/agent.Dockerfile +15 -0
- package/docker/compose.runner.yml +22 -0
- package/docker/runner-entrypoint.sh +12 -0
- package/docker/runner.Dockerfile +31 -0
- package/docs/cloud-runner.md +62 -0
- package/package.json +2 -1
- package/public/app.js +37 -2
- package/public/index.html +18 -1
- package/public/machine.js +4 -0
- package/public/menu.js +39 -0
- package/public/style.css +15 -0
- package/public/termclip.js +92 -0
- package/src/backlog.js +4 -1
- package/src/cli.js +36 -0
- package/src/container.js +189 -0
- package/src/engines/acp.js +222 -0
- package/src/engines/index.js +10 -2
- package/src/fleet.js +5 -4
- package/src/hub.js +13 -3
- package/src/hublink.js +4 -2
- package/src/inbox.js +14 -0
- package/src/manager.js +50 -8
- package/src/server.js +35 -2
- package/src/trust.js +1 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.11.1 (2026-11-01)
|
|
4
|
+
|
|
5
|
+
- **Copy and paste in terminals on phones:** **Copy** and **Paste** in the terminal's key bar (and in the Terminal's bar). A phone can't select the terminal's text or paste into it directly, so Copy opens its recent output as plain text you can select (or **Copy all**), and Paste reads the clipboard (the phone asks first) or offers a box to paste into, then inserts it as one paste without pressing Enter.
|
|
6
|
+
|
|
7
|
+
## 1.11.0 (2026-11-01)
|
|
8
|
+
|
|
9
|
+
- **Inbox:** permission menus that newer Claude Code draws as one run of text ("Do you want to proceed? ❯ 1. Yes 2. … 3. No") get their choices again, so they can be answered from the inbox.
|
|
10
|
+
- **Cloud runners:** run AI Agent Manager headless on a server so agents keep working with the laptop closed: `docker/runner.Dockerfile` (AI Agent Manager, Claude Code, Codex, git, gh; data on a `/data` volume), `docker/compose.runner.yml`, and a Railway recipe in [docs/cloud-runner.md](docs/cloud-runner.md). It joins the hub by itself from `AGENTMGR_HUB_URL` and `AGENTMGR_HUB_CODE`, with the hub's new reusable **runner token** (`AGENTMGR_HUB_RUNNER_TOKEN`, at least 24 characters, compared in constant time) or an invite, and keeps its link across restarts; `AGENTMGR_HUB_ALLOW` sets what the hub may do. Move agents to it from the hub. `AGENTMGR_HUB_ALLOW_HTTP=1` allows a plain-http hub on a private network.
|
|
11
|
+
- **ACP agents:** any agent that speaks ACP (the Agent Client Protocol) is an engine: Gemini CLI, OpenCode, Goose and Qwen Code are offered when installed, and `"acp": { "<name>": { "command": ["…"], "label": "…" } }` in config.json adds others as `acp-<name>`. AI Agent Manager runs its own terminal client (`bin/acp-client.js`) in the agent's terminal: messages, tool calls and the plan as text, permission requests as a menu the inbox answers (Bypass allows them by itself), file writes kept inside the agent's folder, resume with `session/load`, and a session log it reads for usage, PR links, stuck detection and Explain.
|
|
12
|
+
- **Agents in containers:** **Run each agent in its own container** in New agents (or `agentmgr add --container`, or by default from Settings): the agent runs in Docker and sees only its own folder (at the same path), its repository's git data, its brief and uploads, not the rest of the computer. Its files are written as you. Sessions, resume, tracking and usage work as on the host; **Open shell** opens inside the container; stopping stops the container. Claude Code (signed in with a token from `claude setup-token`, saved in Settings → Containers or `agentmgr sandbox token`) and Codex (its auth.json, read-only). The image (Node, git, ripgrep, Claude Code, Codex) is built from `docker/agent.Dockerfile` with **Build image** or `agentmgr sandbox build`; `agentmgr sandbox status`. Limits and extras in config.json: `"isolation": { "cpus": 2, "memory": "4g", "network": "…", "mounts": […], "env": […] }`.
|
|
13
|
+
|
|
3
14
|
## 1.10.0 (2026-10-31)
|
|
4
15
|
|
|
5
16
|
- **Review scorecard:** an agent's changes scored 0-100 for Intent, Spec (each acceptance criterion met / not met / unverified, with evidence), Tests, Safety, Scope and Plan, with an overall readiness, a recommendation (Ready to approve / Look at these first / Not ready) and **Where to look**: the few hunks a person should read. Facts the app measures cap the model's scores (failing CI, failing checks, code without tests, plan steps ticked, unassessed criteria) and the recommendation follows fixed rules. At the top of Review changes, as a **Scorecard** document, as a badge in Land, in the PR description, and posted on the PR with **Post to PR** (posting again updates the comment). Runs by itself when the agent marks its work done (`"scorecard": { "auto": false }` turns that off); `agentmgr doc <agent> scorecard`.
|
package/README.md
CHANGED
|
@@ -85,13 +85,13 @@ Runs on macOS and Linux; on Windows, use WSL2. Requires Node 20+, `git`, and the
|
|
|
85
85
|
|
|
86
86
|
```sh
|
|
87
87
|
npm install -g ai-agentmanager # the latest release
|
|
88
|
-
npm install -g ai-agentmanager@1.
|
|
88
|
+
npm install -g ai-agentmanager@1.11.1 # or a specific one
|
|
89
89
|
```
|
|
90
90
|
|
|
91
91
|
Or from the repository, at a release tag:
|
|
92
92
|
|
|
93
93
|
```sh
|
|
94
|
-
git clone --branch v1.
|
|
94
|
+
git clone --branch v1.11.1 --depth 1 https://github.com/jdmunta/ai-agentmanager-core.git
|
|
95
95
|
cd ai-agentmanager-core
|
|
96
96
|
npm install
|
|
97
97
|
npm link # optional: puts `agentmgr` on your PATH
|
|
@@ -111,7 +111,7 @@ npm link
|
|
|
111
111
|
| Installed with | Update |
|
|
112
112
|
| --- | --- |
|
|
113
113
|
| npm | `npm install -g ai-agentmanager@latest` |
|
|
114
|
-
| A clone at a release tag | `git fetch --tags && git checkout v1.
|
|
114
|
+
| A clone at a release tag | `git fetch --tags && git checkout v1.11.1 && npm install` (the tag you want) |
|
|
115
115
|
| A clone of `main` | `git pull && npm install` |
|
|
116
116
|
|
|
117
117
|
**Check your setup.** `agentmgr doctor` checks Node.js, Claude Code and its sign-in, git, `gh`, the data folder, the daemon's port and Chrome, and says how to fix anything missing.
|
|
@@ -739,6 +739,27 @@ Google's **Antigravity CLI** (`agy`, signed in with your Google account) works t
|
|
|
739
739
|
|
|
740
740
|
Meta's **Muse Code** (`muse`, installed with `curl -fsSL https://dev.meta.ai/install.sh | bash` and signed in with `muse login`) works the same way: `--engine muse`. AI Agent Manager adopts the session Muse starts, resumes it with `muse resume` (a message for a resumed session is typed once Muse is idle), trusts the agent's folder for each run with `--trust-workspace` instead of saving it, reads the conversation and token usage from Muse's session log, shows its trust and approval questions in the inbox, stops it cleanly with SIGTERM, and runs Explain with `muse exec` in the read-only profile. Commands and edits aren't read from its log yet, so stuck detection has less to go on for Muse agents. When Muse runs in contributor mode (an account without a plan), the engine picker says so: Meta may use your code and conversations to improve its products. `engines.muse.bin` points at another binary, and `engines.muse.args` adds arguments to every run.
|
|
741
741
|
|
|
742
|
+
## Agents in containers
|
|
743
|
+
|
|
744
|
+
For isolation, an agent can run in its own Docker container: it sees only its own folder (mounted at the same path), its repository's git data, its brief and its uploads, and nothing else on the computer. Everything else works as usual: sessions, resume, tracking and usage, the inbox, **Open shell** (which opens inside the container), stop and restart.
|
|
745
|
+
|
|
746
|
+
1. Start Docker (Docker Desktop, OrbStack or Colima).
|
|
747
|
+
2. **☰ → Settings → Containers → Build image** (or `agentmgr sandbox build`): Node, git, ripgrep, Claude Code and Codex, from `docker/agent.Dockerfile`. About a minute.
|
|
748
|
+
3. For Claude Code, run `claude setup-token` in a terminal and paste the token in **Settings → Containers** (or `agentmgr sandbox token <token>`); it's kept in `~/.agentmanager/secrets.json`, readable only by you. Codex uses its own `auth.json`, read-only.
|
|
749
|
+
4. Tick **Run each agent in its own container** in New agents (`agentmgr add --container`), or make it the default in Settings.
|
|
750
|
+
|
|
751
|
+
Claude Code and Codex can run in containers; Antigravity and Muse Code ship macOS-only programs. Files are written as your user. Limits and extras go in config.json: `"isolation": { "cpus": 2, "memory": "4g", "network": "none", "mounts": [{ "host": "~/.npm", "readOnly": true }], "env": ["NPM_TOKEN"] }` (variables are passed by name, so their values stay out of command lines). `agentmgr sandbox status` shows what's ready.
|
|
752
|
+
|
|
753
|
+
## ACP agents
|
|
754
|
+
|
|
755
|
+
Any agent that speaks [ACP](https://agentclientprotocol.com) (the Agent Client Protocol) works as an engine. Gemini CLI, OpenCode, Goose and Qwen Code show in the Engine picker once installed (and signed in once in a terminal); add others in config.json:
|
|
756
|
+
|
|
757
|
+
```json
|
|
758
|
+
"acp": { "myagent": { "command": ["myagent", "acp"], "label": "My Agent" } }
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
That adds the engine `acp-myagent` (`agentmgr add --engine acp-myagent …`); `"opencode": false` hides a preset. ACP agents have no screen of their own, so AI Agent Manager runs a small terminal client in the agent's terminal: it shows the agent's messages, tool calls and plan, sends what you type as prompts (Ctrl+C cancels the turn), asks permission as a numbered menu that the inbox can answer (**Bypass** allows by itself), writes files only inside the agent's folder and brief, resumes sessions the agent can load, and keeps a log in `~/.agentmanager/acp/<name>/` that drives usage, PR links, stuck detection and Explain.
|
|
762
|
+
|
|
742
763
|
## Several machines: the hub
|
|
743
764
|
|
|
744
765
|
With agents on more than one Mac, a **hub** shows all of them in one place: every machine's agents and one inbox of what needs you. Where a machine allows it, you can also act from the hub: answer, message, start and stop agents, start new ones, and open live terminals. Each Mac keeps running its own daemon. It connects out to the hub, so no ports need opening on it, and the hub's requests travel back over that same connection.
|
|
@@ -809,6 +830,8 @@ The conversation passes through the hub in pieces, in memory only for the move,
|
|
|
809
830
|
|
|
810
831
|
**On Railway.** `hub/Dockerfile` builds the hub alone, with no node-pty and no Claude. Set `RAILWAY_DOCKERFILE_PATH=hub/Dockerfile`, `AGENTMGR_HUB_TOKEN`, `AGENTMGR_HUB_URL` (its https address) and, for Google sign-in, the three variables above; add a volume at `/data` so enrollments survive redeploys, and join with the `https://…up.railway.app` address.
|
|
811
832
|
|
|
833
|
+
**Cloud runners.** For agents that keep working with your laptop closed, run AI Agent Manager on a server (Railway, any VM with Docker) as a runner joined to your hub, and Move agents to it: see [docs/cloud-runner.md](docs/cloud-runner.md).
|
|
834
|
+
|
|
812
835
|
## Assistant
|
|
813
836
|
|
|
814
837
|
An always-on Claude session that works on your behalf. **Assistant** in the top bar (or `agentmgr assistant start`) sets it up. Every 15 minutes the daemon asks it to check for new work, and it starts an agent for each new item, or passes the news to the agent already on it. You don't approve each one; it tells you in its activity log and, with Slack alerts on, in Slack.
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A terminal client for agents that speak ACP (the Agent Client Protocol: JSON-RPC over stdio, as
|
|
3
|
+
// Gemini CLI, OpenCode, Goose and others do). AI Agent Manager runs it in an agent's terminal: it
|
|
4
|
+
// starts the agent, draws its messages, tool calls and plan as text, turns lines you type into
|
|
5
|
+
// prompts, asks permission as a numbered menu (so the inbox can answer it), and keeps a session log
|
|
6
|
+
// (JSONL) that AI Agent Manager reads like any engine's transcript.
|
|
7
|
+
//
|
|
8
|
+
// acp-client.js --log <dir> [--session <id>] [--prompt <text>] [--yes] [--allow-dir <dir>]... -- <agent command...>
|
|
9
|
+
// acp-client.js --once [--deny] -- <agent command...> (prompt on stdin; prints only the reply)
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import readline from 'node:readline';
|
|
14
|
+
|
|
15
|
+
const argv = process.argv.slice(2);
|
|
16
|
+
const sep = argv.indexOf('--');
|
|
17
|
+
if (sep < 0 || sep === argv.length - 1) {
|
|
18
|
+
console.error('usage: acp-client.js [options] -- <agent command...>');
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
21
|
+
const opts = argv.slice(0, sep);
|
|
22
|
+
const command = argv.slice(sep + 1);
|
|
23
|
+
const flag = (n) => opts.includes(n);
|
|
24
|
+
const value = (n) => (opts.includes(n) ? opts[opts.indexOf(n) + 1] : null);
|
|
25
|
+
const values = (n) => opts.flatMap((o, i) => (o === n ? [opts[i + 1]] : []));
|
|
26
|
+
const once = flag('--once');
|
|
27
|
+
const yes = flag('--yes');
|
|
28
|
+
const deny = flag('--deny');
|
|
29
|
+
const logDir = once ? null : value('--log');
|
|
30
|
+
const cwd = process.cwd();
|
|
31
|
+
const allowDirs = [cwd, ...values('--allow-dir')].map((d) => path.resolve(d));
|
|
32
|
+
|
|
33
|
+
const DIM = '\x1b[2m';
|
|
34
|
+
const BOLD = '\x1b[1m';
|
|
35
|
+
const CYAN = '\x1b[36m';
|
|
36
|
+
const YELLOW = '\x1b[33m';
|
|
37
|
+
const RESET = '\x1b[0m';
|
|
38
|
+
const out = (s) => (once ? null : process.stdout.write(s));
|
|
39
|
+
|
|
40
|
+
// ------------------------------------------------------------ the session log
|
|
41
|
+
let logFile = null;
|
|
42
|
+
let pendingLog = [];
|
|
43
|
+
function log(rec) {
|
|
44
|
+
const line = JSON.stringify({ t: new Date().toISOString(), ...rec }) + '\n';
|
|
45
|
+
if (!logDir) return;
|
|
46
|
+
if (!logFile) return void pendingLog.push(line);
|
|
47
|
+
fs.appendFileSync(logFile, line);
|
|
48
|
+
}
|
|
49
|
+
function openLog(sessionId, fresh) {
|
|
50
|
+
if (!logDir) return;
|
|
51
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
52
|
+
logFile = path.join(logDir, `${sessionId}.jsonl`);
|
|
53
|
+
if (fresh || !fs.existsSync(logFile)) fs.appendFileSync(logFile, JSON.stringify({ t: new Date().toISOString(), type: 'meta', sessionId, cwd, agent: command[0] }) + '\n');
|
|
54
|
+
for (const l of pendingLog) fs.appendFileSync(logFile, l);
|
|
55
|
+
pendingLog = [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ------------------------------------------------------------ JSON-RPC over the agent's stdio
|
|
59
|
+
// Its own process group: Ctrl+C in the terminal reaches only this client, which cancels the turn.
|
|
60
|
+
const child = spawn(command[0], command.slice(1), { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: process.env, detached: !once });
|
|
61
|
+
child.on('error', (err) => {
|
|
62
|
+
console.error(`${YELLOW}Could not start ${command[0]}: ${err.message}${RESET}`);
|
|
63
|
+
process.exit(127);
|
|
64
|
+
});
|
|
65
|
+
child.stderr.on('data', (d) => {
|
|
66
|
+
if (!once && process.env.AGENTMGR_ACP_DEBUG) process.stderr.write(`${DIM}${d}${RESET}`);
|
|
67
|
+
});
|
|
68
|
+
let nextId = 1;
|
|
69
|
+
const waiting = new Map();
|
|
70
|
+
const send = (msg) => child.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n');
|
|
71
|
+
const request = (method, params) =>
|
|
72
|
+
new Promise((resolve, reject) => {
|
|
73
|
+
const id = nextId++;
|
|
74
|
+
waiting.set(id, { resolve, reject });
|
|
75
|
+
send({ id, method, params });
|
|
76
|
+
});
|
|
77
|
+
const notify = (method, params) => send({ method, params });
|
|
78
|
+
|
|
79
|
+
readline.createInterface({ input: child.stdout }).on('line', (line) => {
|
|
80
|
+
let msg;
|
|
81
|
+
try {
|
|
82
|
+
msg = JSON.parse(line);
|
|
83
|
+
} catch {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (msg.id != null && !msg.method) {
|
|
87
|
+
const w = waiting.get(msg.id);
|
|
88
|
+
waiting.delete(msg.id);
|
|
89
|
+
if (!w) return;
|
|
90
|
+
msg.error ? w.reject(Object.assign(new Error(msg.error.message ?? 'error'), { code: msg.error.code, data: msg.error.data })) : w.resolve(msg.result);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (msg.method === 'session/update') return onUpdate(msg.params?.update ?? {});
|
|
94
|
+
if (msg.id != null && msg.method) {
|
|
95
|
+
// The agent asks us something.
|
|
96
|
+
handle(msg.method, msg.params ?? {}).then(
|
|
97
|
+
(result) => send({ id: msg.id, result }),
|
|
98
|
+
(err) => send({ id: msg.id, error: { code: -32603, message: err.message } }),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
child.on('exit', (code) => {
|
|
103
|
+
flushMessage();
|
|
104
|
+
out(`\r\n${DIM}[agent exited${code ? ` with code ${code}` : ''}]${RESET}\r\n`);
|
|
105
|
+
process.exit(code ?? 0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ------------------------------------------------------------ what the agent sends
|
|
109
|
+
let message = ''; // the agent's reply being streamed
|
|
110
|
+
let replyText = ''; // all reply text this turn (for --once)
|
|
111
|
+
let thinking = false;
|
|
112
|
+
const tools = new Map(); // toolCallId -> { title, kind }
|
|
113
|
+
function flushMessage() {
|
|
114
|
+
if (message.trim()) log({ type: 'agent', text: message });
|
|
115
|
+
message = '';
|
|
116
|
+
}
|
|
117
|
+
const textOf = (content) =>
|
|
118
|
+
(Array.isArray(content) ? content : [content])
|
|
119
|
+
.map((c) => (c?.type === 'content' ? c.content : c))
|
|
120
|
+
.map((c) => (c?.type === 'text' ? c.text : ''))
|
|
121
|
+
.join('');
|
|
122
|
+
|
|
123
|
+
function onUpdate(u) {
|
|
124
|
+
switch (u.sessionUpdate) {
|
|
125
|
+
case 'agent_message_chunk': {
|
|
126
|
+
const t = u.content?.type === 'text' ? u.content.text : '';
|
|
127
|
+
if (thinking) out(RESET), (thinking = false);
|
|
128
|
+
message += t;
|
|
129
|
+
replyText += t;
|
|
130
|
+
if (once) return;
|
|
131
|
+
out(t.replace(/\n/g, '\r\n'));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
case 'agent_thought_chunk':
|
|
135
|
+
if (!thinking) out(`${DIM}`), (thinking = true);
|
|
136
|
+
out((u.content?.text ?? '').replace(/\n/g, '\r\n'));
|
|
137
|
+
return;
|
|
138
|
+
case 'user_message_chunk':
|
|
139
|
+
// History when a session is loaded.
|
|
140
|
+
out(`\r\n${CYAN}› ${(u.content?.text ?? '').replace(/\n/g, '\r\n ')}${RESET}\r\n`);
|
|
141
|
+
return;
|
|
142
|
+
case 'tool_call': {
|
|
143
|
+
flushMessage();
|
|
144
|
+
tools.set(u.toolCallId, { title: u.title ?? 'tool', kind: u.kind ?? 'other' });
|
|
145
|
+
out(`\r\n${BOLD}⏺ ${u.title ?? 'tool'}${RESET} ${DIM}${u.kind ?? ''}${RESET}\r\n`);
|
|
146
|
+
log({ type: 'tool', id: u.toolCallId, title: u.title, kind: u.kind, status: u.status ?? 'pending', input: u.rawInput ?? null });
|
|
147
|
+
return toolContent(u);
|
|
148
|
+
}
|
|
149
|
+
case 'tool_call_update': {
|
|
150
|
+
const t = tools.get(u.toolCallId) ?? { title: u.title ?? 'tool', kind: u.kind ?? 'other' };
|
|
151
|
+
if (u.title) t.title = u.title;
|
|
152
|
+
tools.set(u.toolCallId, t);
|
|
153
|
+
toolContent(u);
|
|
154
|
+
if (u.status === 'completed' || u.status === 'failed') {
|
|
155
|
+
const text = textOf(u.content ?? []).trim();
|
|
156
|
+
const lines = text.split('\n');
|
|
157
|
+
out(`${DIM} ⎿ ${u.status === 'failed' ? `${YELLOW}failed${RESET}${DIM}` : 'done'}${lines[0] ? `: ${lines.slice(0, 4).join('\r\n ')}${lines.length > 4 ? ` … (${lines.length - 4} more lines)` : ''}` : ''}${RESET}\r\n`);
|
|
158
|
+
log({ type: 'tool', id: u.toolCallId, title: t.title, kind: t.kind, status: u.status, input: u.rawInput ?? null, output: (text || JSON.stringify(u.rawOutput ?? '')).slice(-4000), failed: u.status === 'failed' });
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
case 'plan':
|
|
163
|
+
out(`\r\n${BOLD}Plan${RESET}\r\n${(u.entries ?? []).map((e) => ` ${e.status === 'completed' ? '☑' : e.status === 'in_progress' ? '▶' : '☐'} ${e.content}`).join('\r\n')}\r\n`);
|
|
164
|
+
log({ type: 'plan', entries: (u.entries ?? []).map((e) => ({ content: e.content, status: e.status })) });
|
|
165
|
+
return;
|
|
166
|
+
case 'usage_update':
|
|
167
|
+
log({ type: 'usage', used: u.used ?? null, size: u.size ?? null, cost: u.cost ?? null });
|
|
168
|
+
return;
|
|
169
|
+
default:
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Diffs in a tool call: the file it edits. */
|
|
174
|
+
function toolContent(u) {
|
|
175
|
+
for (const c of u.content ?? []) {
|
|
176
|
+
if (c?.type === 'diff' && c.path) {
|
|
177
|
+
out(`${DIM} ✎ ${c.path}${RESET}\r\n`);
|
|
178
|
+
log({ type: 'edit', path: c.path, removed: String(c.oldText ?? '').split('\n').length, added: String(c.newText ?? '').split('\n').length, oldText: String(c.oldText ?? '').slice(0, 2000), newText: String(c.newText ?? '').slice(0, 2000) });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const inside = (p) => allowDirs.some((d) => p === d || p.startsWith(d + path.sep));
|
|
184
|
+
|
|
185
|
+
/** Requests from the agent: permission, and reading or writing files for it. */
|
|
186
|
+
async function handle(method, p) {
|
|
187
|
+
if (method === 'session/request_permission') return permission(p);
|
|
188
|
+
if (method === 'fs/read_text_file') {
|
|
189
|
+
const text = fs.readFileSync(p.path, 'utf8');
|
|
190
|
+
if (p.line == null && p.limit == null) return { content: text };
|
|
191
|
+
const lines = text.split('\n');
|
|
192
|
+
const from = Math.max(0, (p.line ?? 1) - 1);
|
|
193
|
+
return { content: lines.slice(from, p.limit != null ? from + p.limit : undefined).join('\n') };
|
|
194
|
+
}
|
|
195
|
+
if (method === 'fs/write_text_file') {
|
|
196
|
+
const target = path.resolve(p.path);
|
|
197
|
+
if (!inside(target)) throw new Error(`Writing outside the agent's folder is not allowed: ${target}`);
|
|
198
|
+
const before = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
|
|
199
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
200
|
+
fs.writeFileSync(target, p.content ?? '');
|
|
201
|
+
out(`${DIM} ✎ wrote ${target}${RESET}\r\n`);
|
|
202
|
+
log({ type: 'edit', path: target, removed: before ? before.split('\n').length : 0, added: String(p.content ?? '').split('\n').length, oldText: before.slice(0, 2000), newText: String(p.content ?? '').slice(0, 2000) });
|
|
203
|
+
return {};
|
|
204
|
+
}
|
|
205
|
+
throw Object.assign(new Error(`Not supported: ${method}`), { code: -32601 });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let answer = null; // resolves with the next line typed while a permission menu is open
|
|
209
|
+
async function permission(p) {
|
|
210
|
+
const options = p.options ?? [];
|
|
211
|
+
const title = p.toolCall?.title ?? 'a tool';
|
|
212
|
+
const pick = (kinds) => options.find((o) => kinds.includes(o.kind));
|
|
213
|
+
if (yes || deny || once) {
|
|
214
|
+
const o = deny || once ? pick(['reject_once', 'reject_always']) : pick(['allow_once', 'allow_always']);
|
|
215
|
+
log({ type: 'permission', title, choice: o?.name ?? 'cancelled', auto: true });
|
|
216
|
+
return o ? { outcome: { outcome: 'selected', optionId: o.optionId } } : { outcome: { outcome: 'cancelled' } };
|
|
217
|
+
}
|
|
218
|
+
flushMessage();
|
|
219
|
+
out(`\r\n${YELLOW}${BOLD}Permission needed: ${title}${RESET}\r\n${options.map((o, i) => ` ${i + 1}. ${o.name}`).join('\r\n')}\r\n${YELLOW}Choose 1-${options.length} (Enter for 1):${RESET} `);
|
|
220
|
+
const typed = await new Promise((resolve) => (answer = resolve));
|
|
221
|
+
const n = Number(typed.trim() || '1');
|
|
222
|
+
const o = options[n - 1];
|
|
223
|
+
log({ type: 'permission', title, choice: o?.name ?? 'cancelled' });
|
|
224
|
+
return o ? { outcome: { outcome: 'selected', optionId: o.optionId } } : { outcome: { outcome: 'cancelled' } };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ------------------------------------------------------------ the session and prompts
|
|
228
|
+
let sessionId = null;
|
|
229
|
+
let turn = null; // the prompt in flight
|
|
230
|
+
const queue = [];
|
|
231
|
+
|
|
232
|
+
async function prompt(text) {
|
|
233
|
+
if (turn) return void queue.push(text);
|
|
234
|
+
log({ type: 'user', text });
|
|
235
|
+
replyText = '';
|
|
236
|
+
out(`${DIM}`);
|
|
237
|
+
turn = request('session/prompt', { sessionId, prompt: [{ type: 'text', text }] });
|
|
238
|
+
let stop = 'end_turn';
|
|
239
|
+
try {
|
|
240
|
+
stop = (await turn)?.stopReason ?? 'end_turn';
|
|
241
|
+
} catch (err) {
|
|
242
|
+
stop = 'error';
|
|
243
|
+
out(`\r\n${YELLOW}${err.message}${RESET}\r\n`);
|
|
244
|
+
}
|
|
245
|
+
turn = null;
|
|
246
|
+
flushMessage();
|
|
247
|
+
log({ type: 'turn_end', stopReason: stop });
|
|
248
|
+
if (once) {
|
|
249
|
+
process.stdout.write(replyText.trim() + '\n');
|
|
250
|
+
child.kill();
|
|
251
|
+
process.exit(stop === 'error' ? 1 : 0);
|
|
252
|
+
}
|
|
253
|
+
out(`${RESET}\r\n${stop !== 'end_turn' ? `${DIM}[${stop}]${RESET}\r\n` : ''}`);
|
|
254
|
+
if (queue.length) return prompt(queue.shift());
|
|
255
|
+
ready();
|
|
256
|
+
}
|
|
257
|
+
const ready = () => out(`\r\n${CYAN}›${RESET} `);
|
|
258
|
+
|
|
259
|
+
async function main() {
|
|
260
|
+
const init = await request('initialize', {
|
|
261
|
+
protocolVersion: 1,
|
|
262
|
+
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: false },
|
|
263
|
+
clientInfo: { name: 'ai-agentmanager', title: 'AI Agent Manager', version: '1' },
|
|
264
|
+
});
|
|
265
|
+
const resume = value('--session');
|
|
266
|
+
if (resume && init?.agentCapabilities?.loadSession) {
|
|
267
|
+
sessionId = resume;
|
|
268
|
+
openLog(sessionId, false);
|
|
269
|
+
out(`${DIM}Resuming session ${sessionId}…${RESET}\r\n`);
|
|
270
|
+
await request('session/load', { sessionId, cwd, mcpServers: [] });
|
|
271
|
+
} else {
|
|
272
|
+
if (resume) out(`${DIM}This agent can't resume sessions; starting a new one.${RESET}\r\n`);
|
|
273
|
+
sessionId = (await request('session/new', { cwd, mcpServers: [] })).sessionId;
|
|
274
|
+
openLog(sessionId, true);
|
|
275
|
+
}
|
|
276
|
+
out(`${DIM}${init?.agentInfo?.title ?? command[0]} · session ${sessionId}${RESET}\r\n`);
|
|
277
|
+
const first = value('--prompt');
|
|
278
|
+
if (once) {
|
|
279
|
+
let text = '';
|
|
280
|
+
for await (const chunk of process.stdin) text += chunk;
|
|
281
|
+
return prompt(text);
|
|
282
|
+
}
|
|
283
|
+
if (first) await prompt(first);
|
|
284
|
+
else ready();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Lines you type: an answer to an open permission menu, else a prompt. Ctrl+C cancels the turn.
|
|
288
|
+
if (!once) {
|
|
289
|
+
// Don't echo control characters (a pasted message's markers) as ^[[200~.
|
|
290
|
+
if (process.stdin.isTTY) spawn('stty', ['-echoctl'], { stdio: ['inherit', 'ignore', 'ignore'] });
|
|
291
|
+
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
292
|
+
// A message typed in as a bracketed paste (several lines) is one prompt, not one per line.
|
|
293
|
+
let pasting = null;
|
|
294
|
+
rl.on('line', (raw) => {
|
|
295
|
+
let line = raw;
|
|
296
|
+
if (line.includes('\x1b[200~')) {
|
|
297
|
+
pasting = [];
|
|
298
|
+
line = line.slice(line.indexOf('\x1b[200~') + 6);
|
|
299
|
+
}
|
|
300
|
+
if (pasting) {
|
|
301
|
+
const end = line.indexOf('\x1b[201~');
|
|
302
|
+
if (end < 0) return void pasting.push(line);
|
|
303
|
+
pasting.push(line.slice(0, end));
|
|
304
|
+
line = pasting.join('\n') + line.slice(end + 6);
|
|
305
|
+
pasting = null;
|
|
306
|
+
}
|
|
307
|
+
if (answer) {
|
|
308
|
+
const a = answer;
|
|
309
|
+
answer = null;
|
|
310
|
+
return a(line);
|
|
311
|
+
}
|
|
312
|
+
if (line.trim() && sessionId) prompt(line);
|
|
313
|
+
else if (!turn) ready();
|
|
314
|
+
});
|
|
315
|
+
process.on('SIGINT', () => {
|
|
316
|
+
if (turn && sessionId) {
|
|
317
|
+
notify('session/cancel', { sessionId });
|
|
318
|
+
out(`\r\n${DIM}[cancelling…]${RESET}\r\n`);
|
|
319
|
+
} else {
|
|
320
|
+
child.kill();
|
|
321
|
+
process.exit(130);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
for (const sig of ['SIGTERM', 'SIGHUP']) process.on(sig, () => (child.kill('SIGTERM'), process.exit(0)));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
main().catch((err) => {
|
|
328
|
+
const auth = /auth/i.test(err.message) || err.code === -32000;
|
|
329
|
+
console.error(`\r\n${YELLOW}${auth ? `${command[0]} needs signing in first (run it once in a terminal): ` : ''}${err.message}${RESET}`);
|
|
330
|
+
child.kill();
|
|
331
|
+
process.exit(1);
|
|
332
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# The image agents run in when isolated ("Run in a container"): Node, git and common build tools,
|
|
2
|
+
# with Claude Code and Codex. Build it with `agentmgr sandbox build` (or Settings → Containers).
|
|
3
|
+
# Only the agent's own folder, its git data, brief and uploads are mounted from the host.
|
|
4
|
+
FROM node:22-bookworm-slim
|
|
5
|
+
|
|
6
|
+
RUN apt-get update \
|
|
7
|
+
&& apt-get install -y --no-install-recommends git ca-certificates curl ripgrep jq python3 make g++ openssh-client less procps \
|
|
8
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
9
|
+
|
|
10
|
+
RUN npm install -g @anthropic-ai/claude-code @openai/codex && npm cache clean --force
|
|
11
|
+
|
|
12
|
+
# Agents run as the host's user id (files they write stay yours); any user can use this home.
|
|
13
|
+
RUN mkdir -p /home/agent && chmod 1777 /home/agent
|
|
14
|
+
ENV HOME=/home/agent
|
|
15
|
+
WORKDIR /home/agent
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# A cloud runner on any server with Docker: `docker compose -f docker/compose.runner.yml up -d`
|
|
2
|
+
# from the repository root, with the variables below in a .env file next to it (see docs/cloud-runner.md).
|
|
3
|
+
services:
|
|
4
|
+
runner:
|
|
5
|
+
build:
|
|
6
|
+
context: ..
|
|
7
|
+
dockerfile: docker/runner.Dockerfile
|
|
8
|
+
restart: unless-stopped
|
|
9
|
+
volumes:
|
|
10
|
+
- runner-data:/data
|
|
11
|
+
environment:
|
|
12
|
+
AGENTMGR_HUB_URL: ${AGENTMGR_HUB_URL} # https://your-hub.example.com
|
|
13
|
+
AGENTMGR_HUB_CODE: ${AGENTMGR_HUB_CODE} # the hub's runner token (or a one-time invite)
|
|
14
|
+
AGENTMGR_RUNNER_NAME: ${AGENTMGR_RUNNER_NAME:-cloud}
|
|
15
|
+
AGENTMGR_HUB_ALLOW: ${AGENTMGR_HUB_ALLOW:-view,control,move}
|
|
16
|
+
CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN} # from `claude setup-token`
|
|
17
|
+
OPENAI_API_KEY: ${OPENAI_API_KEY:-} # for Codex, if you use it
|
|
18
|
+
GH_TOKEN: ${GH_TOKEN} # clones, pushes and pull requests
|
|
19
|
+
GIT_USER_NAME: ${GIT_USER_NAME:-}
|
|
20
|
+
GIT_USER_EMAIL: ${GIT_USER_EMAIL:-}
|
|
21
|
+
volumes:
|
|
22
|
+
runner-data:
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Start a cloud runner: folders on the volume, git identity and GitHub access from the environment,
|
|
3
|
+
# Claude Code's first-run screens answered, then the daemon (it joins the hub by itself).
|
|
4
|
+
set -e
|
|
5
|
+
mkdir -p "$HOME" "$AGENTMGR_HOME"
|
|
6
|
+
git config --global user.name "${GIT_USER_NAME:-AI Agent Manager runner}"
|
|
7
|
+
git config --global user.email "${GIT_USER_EMAIL:-runner@agentmanager.invalid}"
|
|
8
|
+
git config --global init.defaultBranch main
|
|
9
|
+
if [ -n "$GH_TOKEN" ]; then gh auth setup-git >/dev/null 2>&1 || true; fi
|
|
10
|
+
[ -f "$HOME/.claude.json" ] || echo '{"hasCompletedOnboarding":true,"theme":"dark"}' > "$HOME/.claude.json"
|
|
11
|
+
# The hub reaches agents through this runner's own connection; nothing listens publicly.
|
|
12
|
+
exec agentmgr serve
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# A cloud runner: AI Agent Manager running headless on a server, joined to your hub, so agents keep
|
|
2
|
+
# working with your laptop closed. Build from the repository root:
|
|
3
|
+
# docker build -f docker/runner.Dockerfile -t ai-agentmanager-runner .
|
|
4
|
+
# Run with a volume on /data and the environment in docs/cloud-runner.md (hub address and code,
|
|
5
|
+
# Claude token, GitHub token). Move agents to it from the hub.
|
|
6
|
+
FROM node:22-bookworm-slim
|
|
7
|
+
|
|
8
|
+
RUN apt-get update \
|
|
9
|
+
&& apt-get install -y --no-install-recommends git ca-certificates curl ripgrep jq python3 make g++ openssh-client less procps \
|
|
10
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
11
|
+
# GitHub CLI (pull requests, checks).
|
|
12
|
+
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
|
13
|
+
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \
|
|
14
|
+
&& apt-get update && apt-get install -y --no-install-recommends gh && rm -rf /var/lib/apt/lists/*
|
|
15
|
+
RUN npm install -g @anthropic-ai/claude-code @openai/codex && npm cache clean --force
|
|
16
|
+
|
|
17
|
+
WORKDIR /opt/agentmanager
|
|
18
|
+
COPY package.json package-lock.json ./
|
|
19
|
+
# The install runs scripts/fix-pty.js (node-pty's helper permissions).
|
|
20
|
+
COPY scripts ./scripts
|
|
21
|
+
RUN npm ci --omit=dev && npm cache clean --force
|
|
22
|
+
COPY bin ./bin
|
|
23
|
+
COPY src ./src
|
|
24
|
+
COPY public ./public
|
|
25
|
+
COPY docker ./docker
|
|
26
|
+
RUN ln -s /opt/agentmanager/bin/agentmgr.js /usr/local/bin/agentmgr
|
|
27
|
+
|
|
28
|
+
# Everything that has to survive a redeploy lives on the /data volume.
|
|
29
|
+
ENV HOME=/data/home AGENTMGR_HOME=/data/agentmanager AGENTMGR_REMOTE=0 NODE_ENV=production
|
|
30
|
+
VOLUME /data
|
|
31
|
+
ENTRYPOINT ["/opt/agentmanager/docker/runner-entrypoint.sh"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Cloud runners: agents that keep working with your laptop closed
|
|
2
|
+
|
|
3
|
+
A cloud runner is AI Agent Manager running headless on a server, joined to your [hub](distributed.md). It shows up next to your Macs; you start agents on it from the hub, or **Move** an agent there from a Mac and close the laptop. Agents use your own subscriptions (a Claude token, your OpenAI key) and your GitHub token, nothing billed by AI Agent Manager.
|
|
4
|
+
|
|
5
|
+
## What you need
|
|
6
|
+
|
|
7
|
+
- A hub on an `https` address (Railway, a VPS behind a TLS proxy, or Tailscale).
|
|
8
|
+
- On the hub, a **runner token**: a long random secret that lets machines join by themselves. Set it on the hub's service:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
AGENTMGR_HUB_RUNNER_TOKEN=$(openssl rand -hex 24) # at least 24 characters
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
(A one-time invite code from `agentmgr hub invite` works too, but it expires in 10 minutes, so a runner that is rebuilt from scratch couldn't join again.)
|
|
15
|
+
- A Claude token: run `claude setup-token` on any computer and keep the token. For Codex, an OpenAI API key.
|
|
16
|
+
- A GitHub token (`GH_TOKEN`) that can clone, push and open pull requests on your repositories.
|
|
17
|
+
|
|
18
|
+
## The runner's settings
|
|
19
|
+
|
|
20
|
+
| Variable | |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `AGENTMGR_HUB_URL` | The hub, e.g. `https://hub.example.com` |
|
|
23
|
+
| `AGENTMGR_HUB_CODE` | The hub's runner token (or an invite code) |
|
|
24
|
+
| `AGENTMGR_RUNNER_NAME` | Its name on the hub (default: the hostname) |
|
|
25
|
+
| `AGENTMGR_HUB_ALLOW` | What the hub may do: `view,control,move` (move lets you send agents to it) |
|
|
26
|
+
| `CLAUDE_CODE_OAUTH_TOKEN` | From `claude setup-token` |
|
|
27
|
+
| `OPENAI_API_KEY` | For Codex (optional) |
|
|
28
|
+
| `GH_TOKEN` | For git and `gh` |
|
|
29
|
+
| `GIT_USER_NAME`, `GIT_USER_EMAIL` | The author of its commits |
|
|
30
|
+
| `AGENTMGR_HUB_ALLOW_HTTP=1` | Only for a hub on the same private network over plain http (one docker-compose, one VPC) |
|
|
31
|
+
|
|
32
|
+
It joins the hub by itself on first start and keeps its key on its volume, so restarts and redeploys reconnect without joining again. Everything it keeps (agents, workspaces, Claude and Codex homes) lives on the `/data` volume. Nothing on it listens publicly: the hub reaches it over its own outgoing connection.
|
|
33
|
+
|
|
34
|
+
## Railway
|
|
35
|
+
|
|
36
|
+
1. **New service → GitHub repo** (your fork of ai-agentmanager-core).
|
|
37
|
+
2. **Settings → Build**: Dockerfile path `docker/runner.Dockerfile`.
|
|
38
|
+
3. **Settings → Volumes**: add a volume mounted at `/data`.
|
|
39
|
+
4. **Variables**: the table above.
|
|
40
|
+
5. Deploy. In the hub, the runner appears under machines within a minute.
|
|
41
|
+
|
|
42
|
+
## Any server with Docker
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
git clone https://github.com/jdmunta/ai-agentmanager-core && cd ai-agentmanager-core
|
|
46
|
+
cat > docker/.env <<'VARS'
|
|
47
|
+
AGENTMGR_HUB_URL=https://hub.example.com
|
|
48
|
+
AGENTMGR_HUB_CODE=<the hub's runner token>
|
|
49
|
+
AGENTMGR_RUNNER_NAME=cloud-1
|
|
50
|
+
CLAUDE_CODE_OAUTH_TOKEN=<from claude setup-token>
|
|
51
|
+
GH_TOKEN=<github token>
|
|
52
|
+
VARS
|
|
53
|
+
docker compose -f docker/compose.runner.yml --env-file docker/.env up -d
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Or by hand: `docker build -f docker/runner.Dockerfile -t ai-agentmanager-runner .` then `docker run -d --restart unless-stopped -v runner-data:/data -e … ai-agentmanager-runner`.
|
|
57
|
+
|
|
58
|
+
## Using it
|
|
59
|
+
|
|
60
|
+
- **Move an agent there**: on the hub, **Move to… → cloud-1** (or `agentmgr fleet move mini/<agent> --to cloud-1`). Its branch goes through your repository's remote and its conversation through the hub; it carries on where it was. Both machines must allow `move`.
|
|
61
|
+
- **Start agents on it**: New agent on the hub, choosing the runner; or give the shared backlog work and let the runner pick it up.
|
|
62
|
+
- Answer its prompts and read its terminals from the hub, from any device.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-agentmanager",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.1",
|
|
4
4
|
"description": "Run background coding agents (Claude Code, Codex) per intent, and keep track of them from a web UI, your phone or the CLI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"bin",
|
|
30
30
|
"src",
|
|
31
31
|
"public",
|
|
32
|
+
"docker",
|
|
32
33
|
"scripts",
|
|
33
34
|
"docs",
|
|
34
35
|
"README.md",
|