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/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
- }, RPC_TIMEOUT_MS);
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 })) };
@@ -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
- const agent = this.mustGet(ref);
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 = manager.mustGet(req.params.id);
661
- manager.plugins.check('terminal.open', { agent: manager.auditView(agent), kind });
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 = manager.mustGet(req.params.id);
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 = manager.mustGet(req.params.id);
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
- const agent = manager.mustGet(m[1]);
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 });