ai-agentmanager 1.3.1 → 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 +12 -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 +43 -0
- 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 +12 -5
- package/src/server.js +48 -8
- package/src/studio.js +255 -0
- package/src/update.js +160 -0
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 })) };
|
package/src/identity.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Who this Agent Manager runs as, for the menu in the top bar: the computer's user, the Claude
|
|
2
|
+
// account its agents use (as Claude Code recorded it in ~/.claude.json), and the git identity
|
|
3
|
+
// their commits get. Read on request; nothing here leaves the machine.
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const gitConfig = (key) =>
|
|
10
|
+
new Promise((resolve) => execFile('git', ['config', '--global', key], { timeout: 3000 }, (e, out) => resolve(e ? null : out.trim() || null)));
|
|
11
|
+
|
|
12
|
+
/** The signed-in Claude account from Claude Code's settings file, or null. */
|
|
13
|
+
export function claudeAccount(file = path.join(os.homedir(), '.claude.json')) {
|
|
14
|
+
try {
|
|
15
|
+
const a = JSON.parse(fs.readFileSync(file, 'utf8')).oauthAccount;
|
|
16
|
+
if (!a?.emailAddress) return null;
|
|
17
|
+
return { email: a.emailAddress, name: a.displayName || a.fullName || null, organization: a.organizationName || null };
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function identity() {
|
|
24
|
+
const [gitName, gitEmail] = await Promise.all([gitConfig('user.name'), gitConfig('user.email')]);
|
|
25
|
+
let user = null;
|
|
26
|
+
try {
|
|
27
|
+
user = os.userInfo().username;
|
|
28
|
+
} catch {
|
|
29
|
+
// No passwd entry (some containers).
|
|
30
|
+
}
|
|
31
|
+
const claude = claudeAccount();
|
|
32
|
+
return {
|
|
33
|
+
user,
|
|
34
|
+
host: os.hostname().replace(/\.local$/, ''),
|
|
35
|
+
claude,
|
|
36
|
+
git: gitName || gitEmail ? { name: gitName, email: gitEmail } : null,
|
|
37
|
+
// What the menu shows first: a name, then an email.
|
|
38
|
+
display: claude?.name || gitName || user || 'You',
|
|
39
|
+
email: claude?.email || gitEmail || null,
|
|
40
|
+
};
|
|
41
|
+
}
|
package/src/manager.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import pty from 'node-pty';
|
|
6
7
|
import { expandHome, paths } from './config.js';
|
|
@@ -195,6 +196,9 @@ function loadScrollback(id) {
|
|
|
195
196
|
}
|
|
196
197
|
const baseLabel = ({ ref, sha }) => `${ref} @ ${sha.slice(0, 9)}`;
|
|
197
198
|
|
|
199
|
+
/** The id of the machine terminal's shells (agent ids are hex, so it can't clash). */
|
|
200
|
+
export const MACHINE = '__machine__';
|
|
201
|
+
|
|
198
202
|
export class AgentManager extends EventEmitter {
|
|
199
203
|
constructor({ store, config, plugins = new Plugins() }) {
|
|
200
204
|
super();
|
|
@@ -791,6 +795,8 @@ export class AgentManager extends EventEmitter {
|
|
|
791
795
|
start(ref, { fresh = false, sessionId } = {}) {
|
|
792
796
|
const agent = this.mustGet(ref);
|
|
793
797
|
if (this.closing) throw Object.assign(new Error('AI Agent Manager is shutting down'), { status: 503 });
|
|
798
|
+
// Its session continues on the other machine; two copies of one conversation would diverge.
|
|
799
|
+
if (agent.movedTo) throw Object.assign(new Error(`${agent.name} moved to ${agent.movedTo.machine}; it continues there`), { status: 409 });
|
|
794
800
|
const live = this.runtimes.get(agent.id);
|
|
795
801
|
if (!live?.pty) {
|
|
796
802
|
this.plugins.check('agent.start', { agent: this.auditView(agent), fresh });
|
|
@@ -1354,7 +1360,8 @@ export class AgentManager extends EventEmitter {
|
|
|
1354
1360
|
|
|
1355
1361
|
/** Start (if needed) login shell number `n` in the agent's folder, for running commands by hand. */
|
|
1356
1362
|
openShell(ref, n = 1) {
|
|
1357
|
-
|
|
1363
|
+
// The machine terminal: a shell in the home folder, not tied to any agent.
|
|
1364
|
+
const agent = ref === MACHINE ? { id: MACHINE, workdir: os.homedir(), machine: true } : this.mustGet(ref);
|
|
1358
1365
|
const shells = this.shellsOf(agent.id);
|
|
1359
1366
|
const prev = shells.get(n);
|
|
1360
1367
|
if (prev?.pty) return prev;
|
|
@@ -1375,7 +1382,7 @@ export class AgentManager extends EventEmitter {
|
|
|
1375
1382
|
cols: rt.cols,
|
|
1376
1383
|
rows: rt.rows,
|
|
1377
1384
|
cwd: agent.workdir,
|
|
1378
|
-
env: this.childEnv(agent),
|
|
1385
|
+
env: this.childEnv(agent.machine ? null : agent),
|
|
1379
1386
|
});
|
|
1380
1387
|
rt.pty = proc;
|
|
1381
1388
|
shells.set(n, rt);
|
|
@@ -1390,7 +1397,7 @@ export class AgentManager extends EventEmitter {
|
|
|
1390
1397
|
const a = this.store.get(agent.id);
|
|
1391
1398
|
if (a) this.changed(a);
|
|
1392
1399
|
});
|
|
1393
|
-
this.changed(agent);
|
|
1400
|
+
if (!agent.machine) this.changed(agent);
|
|
1394
1401
|
return rt;
|
|
1395
1402
|
}
|
|
1396
1403
|
|
|
@@ -1403,8 +1410,8 @@ export class AgentManager extends EventEmitter {
|
|
|
1403
1410
|
}
|
|
1404
1411
|
|
|
1405
1412
|
attachShell(ref, client, n = 1) {
|
|
1406
|
-
const agent = this.mustGet(ref);
|
|
1407
|
-
this.plugins.check('terminal.open', { agent: this.auditView(agent), kind: `shell:${n}` });
|
|
1413
|
+
const agent = ref === MACHINE ? { id: MACHINE } : this.mustGet(ref);
|
|
1414
|
+
this.plugins.check('terminal.open', ref === MACHINE ? { agent: null, kind: 'machine' } : { agent: this.auditView(agent), kind: `shell:${n}` });
|
|
1408
1415
|
const rt = this.openShell(agent.id, n);
|
|
1409
1416
|
rt.clients.add(client);
|
|
1410
1417
|
return {
|
package/src/server.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import http from 'node:http';
|
|
3
3
|
import https from 'node:https';
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { createRequire } from 'node:module';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import express from 'express';
|
|
8
9
|
import { WebSocketServer } from 'ws';
|
|
9
10
|
import { ensureHome, HOST, loadConfig, paths, PORT, saveConfig } from './config.js';
|
|
10
|
-
import { AgentManager, shellNumber } from './manager.js';
|
|
11
|
+
import { AgentManager, MACHINE, shellNumber } from './manager.js';
|
|
11
12
|
import { Backlog } from './backlog.js';
|
|
12
13
|
import { Races } from './races.js';
|
|
13
14
|
import { Review } from './review.js';
|
|
@@ -18,6 +19,9 @@ import { Landing } from './landing.js';
|
|
|
18
19
|
import { Budgets } from './budgets.js';
|
|
19
20
|
import { stats } from './stats.js';
|
|
20
21
|
import { Playbooks } from './playbooks.js';
|
|
22
|
+
import { Updates } from './update.js';
|
|
23
|
+
import { identity } from './identity.js';
|
|
24
|
+
import { Studio } from './studio.js';
|
|
21
25
|
import { Memory } from './memory.js';
|
|
22
26
|
import { doctor } from './doctor.js';
|
|
23
27
|
import { Triggers, tokenMatches } from './triggers.js';
|
|
@@ -55,7 +59,7 @@ function isAllowedOrigin(origin, port) {
|
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
const PUBLIC_REMOTE_API = new Set(['POST /api/login', 'GET /api/session']);
|
|
58
|
-
const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|triggers(\/|$)|shutdown$)/;
|
|
62
|
+
const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|triggers(\/|$)|update(\/|$)|editors$|agents\/[\w-]+\/open-in$|shutdown$)/;
|
|
59
63
|
const HOOK_PATH = '/api/hooks/agents';
|
|
60
64
|
const NO_REPO_KINDS = new Set(['general', 'assistant']);
|
|
61
65
|
|
|
@@ -69,6 +73,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
69
73
|
const backlog = new Backlog({ manager, store, config });
|
|
70
74
|
const races = new Races({ manager, store });
|
|
71
75
|
const review = new Review({ manager });
|
|
76
|
+
const studio = new Studio({ manager, review });
|
|
72
77
|
const teams = new Teams({ manager, store, config });
|
|
73
78
|
const cleanup = new Cleanup({ manager, store, config, teams });
|
|
74
79
|
const landing = new Landing({ manager, store, config });
|
|
@@ -87,6 +92,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
87
92
|
const budgets = new Budgets({ manager, store, config, notifier });
|
|
88
93
|
const playbooks = new Playbooks({ manager, store });
|
|
89
94
|
const memory = new Memory({ manager, config });
|
|
95
|
+
const updates = new Updates({ config });
|
|
90
96
|
let remoteServer = null;
|
|
91
97
|
let remotePort = null;
|
|
92
98
|
// Live browser tabs: what a page does is saved on the agent's tab; popups become new tabs.
|
|
@@ -233,6 +239,23 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
233
239
|
api.post('/agents/:id/plan/changes', wrap((req) => manager.requestPlanChanges(req.params.id, req.body?.note)));
|
|
234
240
|
api.post('/agents/:id/plan/review', wrap((req) => manager.setPlanReview(req.params.id, req.body?.on === true)));
|
|
235
241
|
api.post('/agents/:id/handoff', wrap((req) => manager.maybeHandoff(manager.mustGet(req.params.id).id, { force: true })));
|
|
242
|
+
// ---------------------------------------------------------- studio: files and editor
|
|
243
|
+
api.get('/agents/:id/files', wrap((req) => studio.list(req.params.id, req.query.dir ?? '')));
|
|
244
|
+
api.post('/agents/:id/files', wrap((req) => studio.create(req.params.id, req.body?.path, req.body?.type)));
|
|
245
|
+
api.post('/agents/:id/files/rename', wrap((req) => studio.rename(req.params.id, req.body?.from, req.body?.to)));
|
|
246
|
+
api.get('/agents/:id/file', wrap((req) => studio.read(req.params.id, req.query.path)));
|
|
247
|
+
api.put('/agents/:id/file', wrap((req) => studio.write(req.params.id, req.body?.path, req.body?.content, { mtimeMs: req.body?.mtimeMs ?? null, force: Boolean(req.body?.force) })));
|
|
248
|
+
api.delete('/agents/:id/file', wrap((req) => studio.remove(req.params.id, req.query.path)));
|
|
249
|
+
api.get('/agents/:id/file/original', wrap((req) => studio.original(req.params.id, req.query.path)));
|
|
250
|
+
api.get('/agents/:id/search', wrap((req) => studio.search(req.params.id, req.query.q)));
|
|
251
|
+
// Opening VS Code or Cursor happens on this computer, so only from it.
|
|
252
|
+
api.get('/editors', wrap(() => studio.editors()));
|
|
253
|
+
api.post('/agents/:id/open-in', wrap((req) => studio.openIn(req.params.id, req.body?.editor, req.body?.path ?? null)));
|
|
254
|
+
// The machine terminal: shells in the home folder, not tied to an agent (Open Terminal).
|
|
255
|
+
api.get('/machine/shells', wrap(() => ({ shells: manager.shellNumbers(MACHINE), home: os.homedir() })));
|
|
256
|
+
api.post('/machine/shell/new', wrap(() => ({ n: manager.nextShellNumber(MACHINE) })));
|
|
257
|
+
api.post('/machine/shell/close', wrap((req) => manager.closeShell(MACHINE, req.body?.n ? Number(req.body.n) : undefined)));
|
|
258
|
+
api.post('/machine/shell/drop', wrap((req) => manager.dropShell(MACHINE, Number(req.body?.n) || 1)));
|
|
236
259
|
api.post('/agents/:id/shell/close', wrap((req) => manager.closeShell(req.params.id, req.body?.n ? Number(req.body.n) : undefined)));
|
|
237
260
|
api.post('/agents/:id/shell/new', wrap((req) => ({ n: manager.nextShellNumber(manager.mustGet(req.params.id).id) })));
|
|
238
261
|
api.post('/agents/:id/shell/drop', wrap((req) => manager.dropShell(req.params.id, Number(req.body?.n) || 1)));
|
|
@@ -409,6 +432,18 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
409
432
|
api.post('/agents/:id/review/ack', wrap((req) => manager.ackReview(req.params.id, req.body?.action)));
|
|
410
433
|
api.get('/about', wrap(() => about({ port, config: { home: paths.home, claudeBin: config.claudeBin } })));
|
|
411
434
|
api.get('/about/latest', wrap(() => latestRelease()));
|
|
435
|
+
api.get('/me', wrap(async (req) => ({ ...(await identity()), remote: Boolean(req.remote) })));
|
|
436
|
+
// Updating runs npm or git here and restarts the daemon, so only from this computer.
|
|
437
|
+
api.get('/update', wrap(() => updates.status()));
|
|
438
|
+
api.post('/update/check', wrap(async () => (await updates.refresh(), updates.status())));
|
|
439
|
+
api.post('/update', wrap(() => updates.apply()));
|
|
440
|
+
api.put(
|
|
441
|
+
'/update',
|
|
442
|
+
wrap((req) => {
|
|
443
|
+
saveConfig({ updates: updates.setCheck(req.body?.check !== false) });
|
|
444
|
+
return updates.status();
|
|
445
|
+
}),
|
|
446
|
+
);
|
|
412
447
|
api.get('/judge', wrap(() => manager.judge.status()));
|
|
413
448
|
// On or off for this daemon, kept in config.json. The judge spends calls on your own engine plan.
|
|
414
449
|
api.put('/judge', wrap((req) => {
|
|
@@ -651,14 +686,17 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
651
686
|
});
|
|
652
687
|
|
|
653
688
|
const termKind = (q) => (shellNumber(q) ? `shell:${shellNumber(q)}` : 'claude');
|
|
689
|
+
// The machine terminal (Open Terminal) has no agent: its shells live under MACHINE.
|
|
690
|
+
const termAgent = (id) => (id === MACHINE ? { id: MACHINE } : manager.mustGet(id));
|
|
654
691
|
const attachTerm = (id, kind, client) =>
|
|
655
692
|
shellNumber(kind) ? manager.attachShell(id, client, shellNumber(kind)) : manager.attach(id, client);
|
|
656
693
|
api.get('/agents/:id/term/stream', (req, res) => {
|
|
657
694
|
let agent;
|
|
658
695
|
const kind = termKind(req.query.kind);
|
|
659
696
|
try {
|
|
660
|
-
agent =
|
|
661
|
-
manager.plugins.check('terminal.open', { agent:
|
|
697
|
+
agent = termAgent(req.params.id);
|
|
698
|
+
if (agent.id === MACHINE) manager.plugins.check('terminal.open', { agent: null, kind: 'machine' });
|
|
699
|
+
else manager.plugins.check('terminal.open', { agent: manager.auditView(agent), kind });
|
|
662
700
|
} catch (err) {
|
|
663
701
|
return res.status(err.status || 404).json({ error: err.message });
|
|
664
702
|
}
|
|
@@ -699,7 +737,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
699
737
|
api.post(
|
|
700
738
|
'/agents/:id/term/input',
|
|
701
739
|
wrap((req) => {
|
|
702
|
-
const agent =
|
|
740
|
+
const agent = termAgent(req.params.id);
|
|
703
741
|
if (typeof req.body?.d !== 'string') throw fail(400, 'd (string) is required');
|
|
704
742
|
manager.write(agent.id, req.body.d, termKind(req.body.kind));
|
|
705
743
|
return { ok: true };
|
|
@@ -708,7 +746,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
708
746
|
api.post(
|
|
709
747
|
'/agents/:id/term/resize',
|
|
710
748
|
wrap((req) => {
|
|
711
|
-
const agent =
|
|
749
|
+
const agent = termAgent(req.params.id);
|
|
712
750
|
manager.resize(agent.id, req.body?.cols | 0, req.body?.rows | 0, termKind(req.body?.kind));
|
|
713
751
|
return { ok: true };
|
|
714
752
|
}),
|
|
@@ -780,12 +818,13 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
780
818
|
if (!m) return ws.close(1008, 'unknown endpoint');
|
|
781
819
|
let session;
|
|
782
820
|
try {
|
|
783
|
-
|
|
821
|
+
// The machine terminal has no agent; its shells live under MACHINE.
|
|
822
|
+
const agent = m[1] === MACHINE ? { id: MACHINE } : manager.mustGet(m[1]);
|
|
784
823
|
const client = {
|
|
785
824
|
send: (msg) => ws.readyState === ws.OPEN && ws.send(JSON.stringify(msg)),
|
|
786
825
|
close: () => ws.close(1000, 'agent removed'),
|
|
787
826
|
};
|
|
788
|
-
const kind = termKind(url.searchParams.get('kind'));
|
|
827
|
+
const kind = agent.id === MACHINE ? `shell:${shellNumber(url.searchParams.get('kind')) || 1}` : termKind(url.searchParams.get('kind'));
|
|
789
828
|
session = attachTerm(agent.id, kind, client);
|
|
790
829
|
client.send({ t: 'hello', id: agent.id, kind, running: session.running, backlog: session.backlog });
|
|
791
830
|
ws.on('message', (raw) => {
|
|
@@ -854,6 +893,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
854
893
|
triggers.close();
|
|
855
894
|
budgets.close();
|
|
856
895
|
playbooks.close();
|
|
896
|
+
updates.close();
|
|
857
897
|
assistant.close();
|
|
858
898
|
console.log(`[agentmgr] shutting down (${reason}); closing agents so their sessions are saved`);
|
|
859
899
|
await manager.stopAll({ final: true });
|