ai-agentmanager 1.3.1 → 1.5.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,12 +1,15 @@
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';
7
8
  import { DEFAULT_ENGINE, ENGINES, engineOf, installed, notInstalled } from './engines/index.js';
8
9
  import {
9
10
  DOC_KINDS,
11
+ diagramsOnly,
12
+ diagramsPrompt,
10
13
  briefDir,
11
14
  briefPath,
12
15
  briefPrompt,
@@ -32,11 +35,24 @@ const SUMMARIES = {
32
35
  insights: { field: 'insights', prompt: insightsPrompt },
33
36
  ...Object.fromEntries(Object.keys(DOC_KINDS).map((k) => [k, { doc: k, prompt: (ctx) => docPrompt(k, ctx) }])),
34
37
  humanai: { doc: 'humanai', run: 'runHumanAi' },
38
+ diagrams: { doc: 'diagrams', run: 'runDiagrams' },
35
39
  };
36
40
  /** Read / write where a summary of `kind` is kept on the agent record. */
37
41
  const getSummary = (agent, kind) => (SUMMARIES[kind].doc ? agent.docs?.[kind] : agent[SUMMARIES[kind].field]);
38
42
  const summaryPatch = (agent, kind, value) =>
39
43
  SUMMARIES[kind].doc ? { docs: { ...agent.docs, [kind]: value } } : { [SUMMARIES[kind].field]: value };
44
+
45
+ /** The folders of a repo with how many files each holds, two levels deep: a map for drawing it. */
46
+ async function repoLayout(dir) {
47
+ const files = (await git.run('git', ['ls-files'], { cwd: dir, timeout: 20_000 })).split('\n').filter(Boolean);
48
+ const counts = new Map();
49
+ for (const f of files) {
50
+ const parts = f.split('/');
51
+ const key = parts.length > 2 ? `${parts[0]}/${parts[1]}/` : parts.length === 2 ? `${parts[0]}/` : f;
52
+ counts.set(key, (counts.get(key) ?? 0) + 1);
53
+ }
54
+ return [...counts].slice(0, 150).map(([k, n]) => (k.endsWith('/') ? `${k} (${n} files)` : k)).join('\n');
55
+ }
40
56
  import { spawn } from 'node:child_process';
41
57
  import * as git from './git.js';
42
58
  import { conflictMessage, findConflicts, related, repoKey, summarize as summarizeConflicts } from './conflicts.js';
@@ -195,6 +211,9 @@ function loadScrollback(id) {
195
211
  }
196
212
  const baseLabel = ({ ref, sha }) => `${ref} @ ${sha.slice(0, 9)}`;
197
213
 
214
+ /** The id of the machine terminal's shells (agent ids are hex, so it can't clash). */
215
+ export const MACHINE = '__machine__';
216
+
198
217
  export class AgentManager extends EventEmitter {
199
218
  constructor({ store, config, plugins = new Plugins() }) {
200
219
  super();
@@ -791,6 +810,8 @@ export class AgentManager extends EventEmitter {
791
810
  start(ref, { fresh = false, sessionId } = {}) {
792
811
  const agent = this.mustGet(ref);
793
812
  if (this.closing) throw Object.assign(new Error('AI Agent Manager is shutting down'), { status: 503 });
813
+ // Its session continues on the other machine; two copies of one conversation would diverge.
814
+ if (agent.movedTo) throw Object.assign(new Error(`${agent.name} moved to ${agent.movedTo.machine}; it continues there`), { status: 409 });
794
815
  const live = this.runtimes.get(agent.id);
795
816
  if (!live?.pty) {
796
817
  this.plugins.check('agent.start', { agent: this.auditView(agent), fresh });
@@ -841,15 +862,15 @@ export class AgentManager extends EventEmitter {
841
862
  resume = true;
842
863
  }
843
864
  }
844
- const args = engine.launchArgs({
845
- ...this.launchOptions(agent),
846
- sessionId: sid,
847
- resume,
848
- name: agent.name,
849
- // A team member's first message is its first step, not just the problem.
850
- prompt: resume ? (handoff ? handoffMessage(agent) : null) : (agent.kickoff ?? agent.intent),
851
- });
865
+ // A team member's first message is its first step, not just the problem.
866
+ const prompt = resume ? (handoff ? handoffMessage(agent) : null) : (agent.kickoff ?? agent.intent);
867
+ const args = engine.launchArgs({ ...this.launchOptions(agent), sessionId: sid, resume, name: agent.name, prompt });
852
868
  const proc = this.spawnAgent(agent, agent.workdir, args);
869
+ // Engines whose resume takes no message get it typed once the session is idle.
870
+ if (resume && prompt && engine.supports.resumePrompt === false) {
871
+ if (!this.outbox.has(agent.id)) this.outbox.set(agent.id, []);
872
+ this.outbox.get(agent.id).unshift({ text: prompt, source: 'system', at: now() });
873
+ }
853
874
 
854
875
  const session = agent.sessions.find((s) => s.id === sid);
855
876
  if (session) {
@@ -1096,7 +1117,8 @@ export class AgentManager extends EventEmitter {
1096
1117
  done.dispose();
1097
1118
  resolve(this.view(this.store.get(agent.id) ?? agent));
1098
1119
  });
1099
- rt.pty.kill('SIGHUP');
1120
+ // A hangup, as when a terminal closes, unless the engine shuts down cleanly on another signal.
1121
+ rt.pty.kill(engineOf(agent).stopSignal ?? 'SIGHUP');
1100
1122
  rt.killTimer = setTimeout(() => rt.pty?.kill('SIGKILL'), timeoutMs);
1101
1123
  });
1102
1124
  }
@@ -1162,7 +1184,7 @@ export class AgentManager extends EventEmitter {
1162
1184
  }
1163
1185
 
1164
1186
  childEnv(agent) {
1165
- const env = { ...process.env, ...(agent ? { AGENTMGR_AGENT_ID: agent.id } : {}), TERM: 'xterm-256color', COLORTERM: 'truecolor' };
1187
+ const env = { ...process.env, ...(agent ? { AGENTMGR_AGENT_ID: agent.id } : {}), TERM: 'xterm-256color', COLORTERM: 'truecolor', AGENTMGR_TERMINAL: '1' };
1166
1188
  for (const key of STRIP_ENV) delete env[key];
1167
1189
  return env;
1168
1190
  }
@@ -1354,7 +1376,8 @@ export class AgentManager extends EventEmitter {
1354
1376
 
1355
1377
  /** Start (if needed) login shell number `n` in the agent's folder, for running commands by hand. */
1356
1378
  openShell(ref, n = 1) {
1357
- const agent = this.mustGet(ref);
1379
+ // The machine terminal: a shell in the home folder, not tied to any agent.
1380
+ const agent = ref === MACHINE ? { id: MACHINE, workdir: os.homedir(), machine: true } : this.mustGet(ref);
1358
1381
  const shells = this.shellsOf(agent.id);
1359
1382
  const prev = shells.get(n);
1360
1383
  if (prev?.pty) return prev;
@@ -1375,7 +1398,7 @@ export class AgentManager extends EventEmitter {
1375
1398
  cols: rt.cols,
1376
1399
  rows: rt.rows,
1377
1400
  cwd: agent.workdir,
1378
- env: this.childEnv(agent),
1401
+ env: this.childEnv(agent.machine ? null : agent),
1379
1402
  });
1380
1403
  rt.pty = proc;
1381
1404
  shells.set(n, rt);
@@ -1390,7 +1413,7 @@ export class AgentManager extends EventEmitter {
1390
1413
  const a = this.store.get(agent.id);
1391
1414
  if (a) this.changed(a);
1392
1415
  });
1393
- this.changed(agent);
1416
+ if (!agent.machine) this.changed(agent);
1394
1417
  return rt;
1395
1418
  }
1396
1419
 
@@ -1403,8 +1426,8 @@ export class AgentManager extends EventEmitter {
1403
1426
  }
1404
1427
 
1405
1428
  attachShell(ref, client, n = 1) {
1406
- const agent = this.mustGet(ref);
1407
- this.plugins.check('terminal.open', { agent: this.auditView(agent), kind: `shell:${n}` });
1429
+ const agent = ref === MACHINE ? { id: MACHINE } : this.mustGet(ref);
1430
+ this.plugins.check('terminal.open', ref === MACHINE ? { agent: null, kind: 'machine' } : { agent: this.auditView(agent), kind: `shell:${n}` });
1408
1431
  const rt = this.openShell(agent.id, n);
1409
1432
  rt.clients.add(client);
1410
1433
  return {
@@ -1919,8 +1942,10 @@ export class AgentManager extends EventEmitter {
1919
1942
 
1920
1943
  /** One tool-less, read-only call to an engine; resolves to its text output. */
1921
1944
  oneShot(agent, text, { engine, model, timeoutMs = 300_000 }) {
1922
- // Some engines take the prompt as an argument instead of on stdin.
1923
- const args = [...engine.extraArgs(this.config), ...engine.oneShotArgs(this.config, { model, prompt: text })];
1945
+ // Some engines take the prompt as an argument instead of on stdin, and some need their extra
1946
+ // arguments after a subcommand (oneShotExtra: they place them).
1947
+ const extra = engine.extraArgs(this.config);
1948
+ const args = engine.oneShotExtra ? engine.oneShotArgs(this.config, { model, prompt: text, extra }) : [...extra, ...engine.oneShotArgs(this.config, { model, prompt: text })];
1924
1949
  return new Promise((resolve, reject) => {
1925
1950
  const child = spawn(engine.bin(this.config), args, { cwd: paths.home, env: this.childEnv(agent), stdio: ['pipe', 'pipe', 'pipe'] });
1926
1951
  let out = '';
@@ -1938,6 +1963,37 @@ export class AgentManager extends EventEmitter {
1938
1963
  });
1939
1964
  }
1940
1965
 
1966
+ /** Diagrams only: the problem and its solution drawn from the documents already written and the live context. */
1967
+ async runDiagrams(agent, ctx) {
1968
+ const cur0 = this.store.get(agent.id) ?? agent;
1969
+ const written = [
1970
+ ...['analysis', 'design', 'flow', 'proposal'].map((k) => [DOC_KINDS[k].title, cur0.docs?.[k]?.markdown]),
1971
+ ['Implementation plan', cur0.planReview?.markdown],
1972
+ ['Explanation', cur0.explanation?.markdown],
1973
+ ].filter(([, md]) => md);
1974
+ const documents = written.map(([title, md]) => `<document title="${title}">\n${md.slice(0, 12_000)}\n</document>`).join('\n');
1975
+ const repoMap = agent.workdir ? await repoLayout(agent.workdir).catch(() => '') : '';
1976
+ const writer = this.writerFor(agent);
1977
+ const markdown = diagramsOnly(await this.oneShot(agent, diagramsPrompt({ ...ctx, documents, repoMap }), writer));
1978
+ const cur = this.store.get(agent.id);
1979
+ if (!cur) return null;
1980
+ const from = written.map(([title]) => title);
1981
+ return this.view(
1982
+ this.patch(
1983
+ agent.id,
1984
+ summaryPatch(cur, 'diagrams', {
1985
+ markdown,
1986
+ from,
1987
+ writer: `${writer.engine.label}${writer.model ? ` (${writer.model})` : ''}`,
1988
+ at: now(),
1989
+ briefUpdatedAt: cur.brief?.updatedAt ?? null,
1990
+ pending: false,
1991
+ error: null,
1992
+ }),
1993
+ ),
1994
+ );
1995
+ }
1996
+
1941
1997
  /** Human vs AI: the writer splits and answers the open questions, then another model double-checks it. */
1942
1998
  async runHumanAi(agent, ctx) {
1943
1999
  const writer = this.writerFor(agent);