ai-agentmanager 1.11.1 → 1.12.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.
@@ -0,0 +1,131 @@
1
+ // The agent over HTTP: a chat page for people, a small API for apps, webhooks for triggers, and
2
+ // A2A (the agent-to-agent protocol: an agent card, and tasks over JSON-RPC) for other agents.
3
+ import crypto from 'node:crypto';
4
+ import http from 'node:http';
5
+ import express from 'express';
6
+
7
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
8
+
9
+ /** The A2A agent card: who the agent is and how to reach it. */
10
+ export function agentCard(agent, base) {
11
+ const s = agent.spec;
12
+ return {
13
+ protocolVersion: '0.3.0',
14
+ name: s.title,
15
+ description: s.description || s.goal,
16
+ url: `${base}/a2a`,
17
+ preferredTransport: 'JSONRPC',
18
+ version: '1.0.0',
19
+ capabilities: { streaming: false, pushNotifications: false },
20
+ defaultInputModes: ['text/plain'],
21
+ defaultOutputModes: ['text/plain'],
22
+ skills: [{ id: s.name, name: s.title, description: s.goal, tags: [s.engine] }],
23
+ };
24
+ }
25
+
26
+ const taskOf = (t) => ({
27
+ kind: 'task',
28
+ id: t.id,
29
+ contextId: t.contextId,
30
+ status: { state: t.state, timestamp: t.at, ...(t.result != null ? { message: { kind: 'message', role: 'agent', messageId: `${t.id}-r`, parts: [{ kind: 'text', text: t.result }] } } : {}) },
31
+ ...(t.result != null && t.state === 'completed' ? { artifacts: [{ artifactId: `${t.id}-a`, parts: [{ kind: 'text', text: t.result }] }] } : {}),
32
+ });
33
+
34
+ const PAGE = (agent) => `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
35
+ <title>${esc(agent.spec.title)}</title><style>
36
+ :root{--bg:#f6f7f9;--panel:#fff;--text:#1c2330;--muted:#667085;--border:#dde1e7;--accent:#3b5bdb}
37
+ @media (prefers-color-scheme:dark){:root{--bg:#0d0f14;--panel:#151821;--text:#e5e8ef;--muted:#8b93a7;--border:#2a2f3c;--accent:#6d8bff}}
38
+ body{margin:0;font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:var(--bg);color:var(--text);display:flex;flex-direction:column;height:100vh}
39
+ header{padding:12px 16px;border-bottom:1px solid var(--border);background:var(--panel)}header b{font-size:16px}header p{margin:2px 0 0;color:var(--muted);font-size:13px}
40
+ #log{flex:1;overflow:auto;padding:16px;display:flex;flex-direction:column;gap:10px}
41
+ .m{max-width:760px;padding:10px 12px;border-radius:10px;white-space:pre-wrap;word-break:break-word}.you{align-self:flex-end;background:var(--accent);color:#fff}.agent{background:var(--panel);border:1px solid var(--border)}
42
+ #ap{display:flex;flex-direction:column;gap:8px;padding:0 16px}#ap:not(:empty){padding:12px 16px;border-bottom:1px solid var(--border)}.ap{padding:10px 12px;border-radius:10px;border:1px solid #e8a33d;background:var(--panel)}.ap pre{margin:6px 0;font-size:12px;white-space:pre-wrap;max-height:160px;overflow:auto}.ap .no{background:transparent;color:var(--text);border:1px solid var(--border)}
43
+ .step{font-size:12px;color:var(--muted)}form{display:flex;gap:8px;padding:12px 16px;border-top:1px solid var(--border);background:var(--panel)}
44
+ textarea{flex:1;resize:none;font:inherit;padding:8px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:inherit}button{font:inherit;padding:8px 14px;border-radius:8px;border:0;background:var(--accent);color:#fff}
45
+ </style></head><body><header><b>${esc(agent.spec.title)}</b><p>${esc(agent.spec.description || agent.spec.goal)}</p></header>
46
+ <div id="ap"></div><div id="log"></div><form id="f"><textarea id="t" rows="2" placeholder="Ask or tell the agent…"></textarea><button>Send</button></form>
47
+ <script>
48
+ const log=document.getElementById('log');const conv=localStorage.getItem('conv')||crypto.randomUUID();localStorage.setItem('conv',conv);
49
+ const add=(cls,text)=>{const d=document.createElement('div');d.className='m '+cls;d.textContent=text;log.append(d);log.scrollTop=log.scrollHeight;return d};
50
+ document.getElementById('f').onsubmit=async(e)=>{e.preventDefault();const t=document.getElementById('t');const text=t.value.trim();if(!text)return;t.value='';add('you',text);
51
+ const wait=add('agent step','Working on it…');const r=await fetch('api/chat',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({message:text,conversation:conv})}).then(r=>r.json()).catch(e=>({error:e.message}));
52
+ wait.remove();add('agent',r.result??('Error: '+r.error))};
53
+ const ap=document.getElementById('ap');const esc=(s)=>String(s).replace(/[&<>"]/g,(c)=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[c]);
54
+ async function approvals(){const l=await fetch('api/approvals').then(r=>r.json()).catch(()=>[]);ap.innerHTML=l.map(a=>'<div class="ap"><b>Approve '+esc(a.tool)+'?</b> <span class="step">asked '+new Date(a.at).toLocaleTimeString()+'</span><pre>'+esc(JSON.stringify(a.input,null,2))+'</pre><button data-id="'+esc(a.id)+'" data-allow="1">Approve</button> <button class="no" data-id="'+esc(a.id)+'">Deny</button></div>').join('')}
55
+ ap.onclick=async(e)=>{const b=e.target.closest('button[data-id]');if(!b)return;b.disabled=true;await fetch('api/approvals/'+b.dataset.id,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({allow:!!b.dataset.allow})});approvals()};
56
+ approvals();setInterval(approvals,2000);
57
+ document.getElementById('t').onkeydown=(e)=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();document.getElementById('f').requestSubmit()}};
58
+ </script></body></html>`;
59
+
60
+ /** Start the HTTP side; resolves with { server, url, close }. */
61
+ export function startHttp(agent, { port, host = '127.0.0.1', log = () => {} } = {}) {
62
+ const app = express();
63
+ app.use(express.json({ limit: '2mb' }));
64
+ const tasks = new Map(); // A2A task id -> { id, contextId, state, result, at }
65
+ const wrap = (fn) => (req, res) => Promise.resolve(fn(req, res)).then((v) => v !== undefined && res.json(v)).catch((err) => res.status(err.status ?? 500).json({ error: err.message }));
66
+
67
+ app.get('/', (req, res) => res.type('html').send(PAGE(agent)));
68
+ app.get('/api/health', (req, res) => res.json({ ok: true, agent: agent.spec.name, engine: agent.spec.engine, running: agent.current, triggers: agent.spec.triggers.map((t) => t.label) }));
69
+ app.post('/api/chat', wrap(async (req) => {
70
+ const message = String(req.body?.message ?? '').trim();
71
+ if (!message) throw Object.assign(new Error('message is required'), { status: 400 });
72
+ return agent.run(message, { source: 'chat', conversation: String(req.body?.conversation ?? '').slice(0, 80) || crypto.randomUUID() });
73
+ }));
74
+ // Tools waiting for a person's approval, and their answers (JSON only: a page elsewhere can't post here).
75
+ app.get('/api/approvals', wrap(() => agent.approvals.pending()));
76
+ app.post('/api/approvals/:id', wrap((req) => {
77
+ if (!req.is('application/json') || typeof req.body?.allow !== 'boolean') throw Object.assign(new Error('Send {"allow": true|false}'), { status: 400 });
78
+ if (!agent.approvals.decide(req.params.id, req.body.allow, { by: 'the chat page', note: String(req.body.note ?? '') })) throw Object.assign(new Error('Already decided'), { status: 409 });
79
+ return { ok: true };
80
+ }));
81
+ agent.approvals.channels.add('chat');
82
+ app.post('/api/runs', wrap((req) => agent.run(String(req.body?.input ?? ''), { source: 'api' })));
83
+ app.get('/api/runs', wrap(() => agent.runs()));
84
+ app.get('/api/runs/:id', wrap((req) => agent.runLog(req.params.id) ?? Promise.reject(Object.assign(new Error('No such run'), { status: 404 }))));
85
+
86
+ // Webhook triggers: the body (JSON or text) is the run's input.
87
+ for (const t of agent.spec.triggers.filter((x) => x.kind === 'webhook')) {
88
+ app.post(t.path, express.text({ type: () => true, limit: '2mb' }), wrap(async (req) => {
89
+ const run = agent.run(typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? {}), { source: t.label });
90
+ run.catch(() => {});
91
+ return { accepted: true };
92
+ }));
93
+ log(`trigger: ${t.label}`);
94
+ }
95
+
96
+ // A2A.
97
+ if (agent.spec.interfaces.a2a) {
98
+ app.get('/.well-known/agent.json', (req, res) => res.json(agentCard(agent, `http://${req.headers.host}`)));
99
+ app.get('/.well-known/agent-card.json', (req, res) => res.json(agentCard(agent, `http://${req.headers.host}`)));
100
+ app.post('/a2a', async (req, res) => {
101
+ const { id, method, params = {} } = req.body ?? {};
102
+ const reply = (result) => res.json({ jsonrpc: '2.0', id, result });
103
+ const fail = (code, message) => res.json({ jsonrpc: '2.0', id, error: { code, message } });
104
+ if (method === 'message/send') {
105
+ const msg = params.message ?? {};
106
+ const text = (msg.parts ?? []).filter((p) => p.kind === 'text' || p.type === 'text').map((p) => p.text).join('\n').trim();
107
+ if (!text) return fail(-32602, 'message.parts needs a text part');
108
+ const t = { id: crypto.randomUUID(), contextId: msg.contextId ?? crypto.randomUUID(), state: 'working', result: null, at: new Date().toISOString() };
109
+ tasks.set(t.id, t);
110
+ const r = await agent.run(text, { source: 'a2a', conversation: `a2a:${t.contextId}` });
111
+ Object.assign(t, { state: r.status === 'failed' ? 'failed' : 'completed', result: r.result, at: new Date().toISOString() });
112
+ return reply(taskOf(t));
113
+ }
114
+ if (method === 'tasks/get') {
115
+ const t = tasks.get(params.id);
116
+ return t ? reply(taskOf(t)) : fail(-32001, 'Task not found');
117
+ }
118
+ return fail(-32601, `Method not found: ${method}`);
119
+ });
120
+ }
121
+
122
+ const server = http.createServer(app);
123
+ return new Promise((resolve, reject) => {
124
+ server.once('error', reject);
125
+ server.listen(port, host, () => {
126
+ const url = `http://${host}:${server.address().port}`;
127
+ log(`chat and API: ${url}${agent.spec.interfaces.a2a ? ` · A2A card: ${url}/.well-known/agent.json` : ''}`);
128
+ resolve({ server, url, close: () => (agent.approvals.channels.delete('chat'), new Promise((r) => server.close(r))) });
129
+ });
130
+ });
131
+ }
@@ -0,0 +1,114 @@
1
+ // Package an agent as one executable (Node's single executable applications): the runtime bundled
2
+ // with esbuild, the agent's files embedded as assets, injected with postject into the official Node
3
+ // binary (from nodejs.org, same version as this one, checksum-verified, cached) for each platform.
4
+ // The prepared blob depends only on the Node version, so every platform builds from here; macOS
5
+ // ones must be signed, so they build on a Mac. The engine CLIs it drives are installed separately.
6
+ import { execFile } from 'node:child_process';
7
+ import crypto from 'node:crypto';
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { createRequire } from 'node:module';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { loadAgent } from './spec.js';
14
+
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
16
+ // Assembled at run time: the literal must not appear in the bundle we inject (this file is part of
17
+ // it), or postject finds the sentinel twice.
18
+ const FUSE = ['NODE', 'SEA', 'FUSE', 'fce680ab2cc467b6e072b8b5df1996b2'].join('_');
19
+ export const HOST = `${process.platform === 'darwin' ? 'macos' : process.platform}-${process.arch}`;
20
+ export const PLATFORMS = ['macos-arm64', 'macos-x64', 'linux-x64', 'linux-arm64'];
21
+ const sh = (cmd, args, opts = {}) =>
22
+ new Promise((resolve, reject) =>
23
+ execFile(cmd, args, { timeout: 20 * 60_000, maxBuffer: 64 * 1024 * 1024, ...opts }, (e, out, err) => (e ? reject(new Error(`${path.basename(cmd)} failed: ${`${err}\n${out}`.replace(/\x1b\[[0-9;]*m/g, '').trim().split('\n').slice(-4).join(' ') || e.message}`)) : resolve(String(out)))),
24
+ );
25
+
26
+ /** The agent's own files to embed: agent.yaml, its instructions file, tests, and anything it names. */
27
+ function agentFiles(dir) {
28
+ const keep = ['agent.yaml', 'agent.yml', 'agent.json', 'instructions.md', 'tests.yaml', 'README.md'];
29
+ return keep.filter((f) => fs.existsSync(path.join(dir, f)));
30
+ }
31
+
32
+ /** One file: the runtime and its libraries (express, yaml, commander) as CommonJS. */
33
+ async function bundle(outFile) {
34
+ const esbuild = await import('esbuild');
35
+ await esbuild.build({
36
+ entryPoints: [path.join(ROOT, 'src', 'agentkit', 'sea-main.js')],
37
+ outfile: outFile,
38
+ bundle: true,
39
+ platform: 'node',
40
+ format: 'cjs',
41
+ target: 'node22',
42
+ // import.meta.url doesn't exist in CommonJS; give the modules that use it this file's URL.
43
+ define: { 'import.meta.url': '__agentkitUrl' },
44
+ banner: { js: "const __agentkitUrl = require('node:url').pathToFileURL(__filename).href;" },
45
+ logLevel: 'error',
46
+ });
47
+ }
48
+
49
+ /** The official Node binary for a platform (this Node's version), downloaded once and checked. */
50
+ export async function nodeBinary(platform, { cacheDir, log = () => {} } = {}) {
51
+ const v = process.version;
52
+ const [osName, arch] = platform.split('-');
53
+ const dist = `node-${v}-${osName === 'macos' ? 'darwin' : 'linux'}-${arch}`;
54
+ const dir = path.join(cacheDir, dist);
55
+ const bin = path.join(dir, 'node');
56
+ if (fs.existsSync(bin)) return bin;
57
+ fs.mkdirSync(dir, { recursive: true });
58
+ const base = `https://nodejs.org/dist/${v}`;
59
+ log(`downloading Node ${v} for ${platform}`);
60
+ const sums = await (await fetch(`${base}/SHASUMS256.txt`)).text();
61
+ const want = new RegExp(`^([0-9a-f]{64})\\s+${dist}\\.tar\\.gz$`, 'm').exec(sums)?.[1];
62
+ if (!want) throw new Error(`nodejs.org has no ${dist}.tar.gz`);
63
+ const res = await fetch(`${base}/${dist}.tar.gz`);
64
+ if (!res.ok) throw new Error(`Download of ${dist} failed (HTTP ${res.status})`);
65
+ const tgz = Buffer.from(await res.arrayBuffer());
66
+ const got = crypto.createHash('sha256').update(tgz).digest('hex');
67
+ if (got !== want) throw new Error(`${dist}.tar.gz failed its checksum; not using it`);
68
+ const tmp = path.join(dir, 'node.tar.gz');
69
+ fs.writeFileSync(tmp, tgz);
70
+ await sh('tar', ['-xzf', tmp, '-C', dir, '--strip-components', '2', `${dist}/bin/node`]);
71
+ fs.rmSync(tmp);
72
+ return bin;
73
+ }
74
+
75
+ /** Inject the blob into a copy of `nodeBin` (signature removed and re-applied for macOS). */
76
+ async function inject(nodeBin, blob, out, { mac }) {
77
+ fs.copyFileSync(nodeBin, out);
78
+ fs.chmodSync(out, 0o755);
79
+ if (mac) await sh('codesign', ['--remove-signature', out]);
80
+ await sh(process.execPath, [createRequire(import.meta.url).resolve('postject/dist/cli.js'), out, 'NODE_SEA_BLOB', blob, '--sentinel-fuse', FUSE, ...(mac ? ['--macho-segment-name', 'NODE_SEA'] : [])]);
81
+ if (mac) await sh('codesign', ['--sign', '-', out]);
82
+ }
83
+
84
+ /**
85
+ * Build `<outDir>/<name>-<platform>` for an agent folder (any platform; macOS ones on a Mac).
86
+ * Returns { file, platform, size }.
87
+ */
88
+ export async function packageAgent(agentDir, { outDir, platform = HOST, cacheDir = path.join(os.homedir(), '.agentmanager', 'node-dist'), log = () => {} } = {}) {
89
+ const spec = loadAgent(agentDir);
90
+ if (spec.engine.startsWith('acp:')) throw Object.assign(new Error('ACP engines are not supported in a packaged agent yet; run its folder with agentkit'), { status: 400 });
91
+ if (!PLATFORMS.includes(platform)) throw Object.assign(new Error(`platform: one of ${PLATFORMS.join(', ')}`), { status: 400 });
92
+ if (platform.startsWith('macos-') && process.platform !== 'darwin') throw Object.assign(new Error('macOS executables are built on a Mac (they must be signed there)'), { status: 400 });
93
+ fs.mkdirSync(outDir, { recursive: true });
94
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), `agentkit-pack-${spec.name}-`));
95
+ try {
96
+ log('bundling the runtime');
97
+ await bundle(path.join(work, 'main.cjs'));
98
+ const files = agentFiles(spec.dir);
99
+ const assets = { 'manifest.json': path.join(work, 'manifest.json') };
100
+ for (const f of files) assets[`files/${f}`] = path.join(spec.dir, f);
101
+ const build = crypto.createHash('sha256').update(files.map((f) => fs.readFileSync(path.join(spec.dir, f))).join('\0')).digest('hex').slice(0, 16);
102
+ fs.writeFileSync(assets['manifest.json'], JSON.stringify({ name: spec.name, title: spec.title, build, files }));
103
+ const out = path.join(outDir, `${spec.name}-${platform}`);
104
+ fs.writeFileSync(path.join(work, 'sea.json'), JSON.stringify({ main: path.join(work, 'main.cjs'), output: path.join(work, 'blob'), disableExperimentalSEAWarning: true, useCodeCache: false, assets }));
105
+ log('preparing the blob');
106
+ await sh(process.execPath, ['--experimental-sea-config', path.join(work, 'sea.json')]);
107
+ const nodeBin = await nodeBinary(platform, { cacheDir, log });
108
+ log(`making the executable for ${platform}`);
109
+ await inject(nodeBin, path.join(work, 'blob'), out, { mac: platform.startsWith('macos-') });
110
+ return { file: out, platform, size: fs.statSync(out).size };
111
+ } finally {
112
+ fs.rmSync(work, { recursive: true, force: true });
113
+ }
114
+ }
@@ -0,0 +1,106 @@
1
+ // Reporting to AI Agent Manager: the agent says who it is, sends each run (status, cost, result)
2
+ // and its open approvals, and picks up the decisions people made there. It checks in every 30
3
+ // seconds (every 2 while an approval waits), and right away after a run or a new approval.
4
+ import https from 'node:https';
5
+ import os from 'node:os';
6
+
7
+ const EXCERPT = 2000;
8
+
9
+ /**
10
+ * POST JSON over https to a server with a self-signed certificate (AI Agent Manager's remote
11
+ * access), trusting only the certificate whose SHA-256 fingerprint is `pin`. The body is sent only
12
+ * after the fingerprint matches.
13
+ */
14
+ export function pinnedPost(url, { headers, body, pin, timeoutMs = 15_000 }) {
15
+ const want = String(pin).replace(/:/g, '').toLowerCase();
16
+ return new Promise((resolve, reject) => {
17
+ const req = https.request(url, { method: 'POST', headers: { ...headers, 'content-length': Buffer.byteLength(body) }, rejectUnauthorized: false, agent: false, timeout: timeoutMs }, (res) => {
18
+ let text = '';
19
+ res.on('data', (d) => (text += d));
20
+ res.on('end', () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, json: async () => JSON.parse(text) }));
21
+ });
22
+ req.on('socket', (sock) =>
23
+ sock.once('secureConnect', () => {
24
+ const got = sock.getPeerX509Certificate?.()?.fingerprint256.replace(/:/g, '').toLowerCase();
25
+ if (got !== want) return req.destroy(new Error('the certificate does not match AGENTKIT_REPORT_CERT'));
26
+ req.end(body);
27
+ }),
28
+ );
29
+ req.on('timeout', () => req.destroy(new Error('timed out')));
30
+ req.on('error', reject);
31
+ });
32
+ }
33
+
34
+ export function startReporting(agent, { url, token, cert = null, chat = () => null, version = null, log = () => {}, fetchImpl = fetch, everyMs = 30_000, fastMs = 2_000 }) {
35
+ const endpoint = `${url.replace(/\/+$/, '')}/api/agentkit/report`;
36
+ const post = cert && endpoint.startsWith('https:') ? (u, o) => pinnedPost(u, { headers: o.headers, body: o.body, pin: cert }) : fetchImpl;
37
+ const startedAt = new Date().toISOString();
38
+ let unsent = []; // runs not yet acknowledged
39
+ let timer = null;
40
+ let busy = false;
41
+ let again = false;
42
+ let ok = null;
43
+ let stopped = false;
44
+
45
+ const onRun = (r) => {
46
+ unsent = [...unsent, { id: r.id, status: r.status, source: r.source, costUsd: r.costUsd, ms: r.ms, result: String(r.result ?? '').slice(0, EXCERPT), at: new Date().toISOString() }].slice(-50);
47
+ push();
48
+ };
49
+ const onApprovals = () => push();
50
+ agent.on('run', onRun);
51
+ agent.on('approvals', onApprovals);
52
+
53
+ async function push() {
54
+ if (stopped) return;
55
+ if (busy) return void (again = true);
56
+ busy = true;
57
+ clearTimeout(timer);
58
+ const s = agent.spec;
59
+ const sending = unsent;
60
+ const body = {
61
+ agent: { name: s.name, title: s.title, description: s.description, engine: s.engine, triggers: s.triggers.map((t) => t.label), host: os.hostname(), pid: process.pid, chat: chat(), version, startedAt },
62
+ current: agent.current,
63
+ runs: sending,
64
+ pending: agent.approvals.pending(),
65
+ };
66
+ try {
67
+ const res = await post(endpoint, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify(body), signal: AbortSignal.timeout(15_000) });
68
+ if (!res.ok) throw new Error(res.status === 401 ? 'the report token was not accepted' : `HTTP ${res.status}`);
69
+ const reply = await res.json();
70
+ unsent = unsent.filter((r) => !sending.includes(r));
71
+ for (const d of reply.decisions ?? []) agent.approvals.decide(d.id, d.allow, { by: d.by ?? 'AI Agent Manager', note: d.note ?? '' });
72
+ agent.approvals.channels.add('manager');
73
+ if (ok !== true) log(`reporting to AI Agent Manager at ${url}`);
74
+ ok = true;
75
+ } catch (e) {
76
+ agent.approvals.channels.delete('manager');
77
+ if (ok !== false) log(`could not report to AI Agent Manager (${e.message}); trying again`);
78
+ ok = false;
79
+ } finally {
80
+ busy = false;
81
+ if (!stopped) {
82
+ if (again) (again = false), setImmediate(push);
83
+ else timer = setTimeout(push, agent.approvals.pending().length ? fastMs : everyMs);
84
+ }
85
+ }
86
+ }
87
+
88
+ push();
89
+ // `push()` checks in now (say, once the chat page is up); `stop()` ends reporting.
90
+ return {
91
+ push,
92
+ /** Wait (a few seconds at most) until every run is sent: before a one-off run exits. */
93
+ flush: async (ms = 5000) => {
94
+ for (const end = Date.now() + ms; (unsent.length || busy) && Date.now() < end; ) {
95
+ if (!busy) push();
96
+ await new Promise((r) => setTimeout(r, 100));
97
+ }
98
+ },
99
+ stop: () => {
100
+ stopped = true;
101
+ clearTimeout(timer);
102
+ agent.off('run', onRun);
103
+ agent.off('approvals', onApprovals);
104
+ },
105
+ };
106
+ }
@@ -0,0 +1,25 @@
1
+ // The entry point of a packaged agent (a single executable made by package.js). Its own files
2
+ // (agent.yaml, instructions, tests) are embedded; the first run unpacks them to ~/.agentkit/<name>/,
3
+ // where its memory and runs live too. Later runs keep that folder, so edits to its agent.yaml stay
4
+ // (until a newer build of the same agent replaces the files it shipped).
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import sea from 'node:sea';
9
+ import { main } from './cli.js';
10
+
11
+ const manifest = JSON.parse(sea.getAsset('manifest.json', 'utf8'));
12
+ const dir = process.env.AGENTKIT_HOME ? path.resolve(process.env.AGENTKIT_HOME) : path.join(os.homedir(), '.agentkit', manifest.name);
13
+ fs.mkdirSync(dir, { recursive: true });
14
+ const stamp = path.join(dir, '.build');
15
+ const shipped = fs.existsSync(stamp) ? fs.readFileSync(stamp, 'utf8').trim() : '';
16
+ if (shipped !== manifest.build) {
17
+ for (const f of manifest.files) {
18
+ const target = path.join(dir, f);
19
+ fs.mkdirSync(path.dirname(target), { recursive: true });
20
+ fs.writeFileSync(target, Buffer.from(sea.getAsset(`files/${f}`)));
21
+ }
22
+ fs.writeFileSync(stamp, manifest.build);
23
+ }
24
+
25
+ main(process.argv.slice(2), { name: manifest.name, defaultRef: dir, packaged: true });
@@ -0,0 +1,118 @@
1
+ // Keep an agent running in the background: a launchd agent (macOS) or a systemd user service
2
+ // (Linux) that starts it at login and restarts it if it crashes. It runs through your login shell,
3
+ // so it finds the engine CLIs and the tokens your terminal has.
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 sh = (cmd, args) => new Promise((resolve, reject) => execFile(cmd, args, (e, out, err) => (e ? reject(new Error(String(err || e.message).trim())) : resolve(String(out)))));
10
+ const xml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
11
+ const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
12
+
13
+ /** Where the service files go, and what they're called, for an agent. */
14
+ export function servicePaths(name, home = os.homedir()) {
15
+ const label = `dev.agentkit.${name}`;
16
+ return {
17
+ label,
18
+ plist: path.join(home, 'Library', 'LaunchAgents', `${label}.plist`),
19
+ unitName: `agentkit-${name}.service`,
20
+ unit: path.join(home, '.config', 'systemd', 'user', `agentkit-${name}.service`),
21
+ };
22
+ }
23
+
24
+ /**
25
+ * What the service runs: `argv` starts the agent in the foreground (`<exe> run` for a packaged one,
26
+ * `node agentkit.js run <dir>` otherwise); settings that change where it lives or which engine
27
+ * binaries it uses carry over.
28
+ */
29
+ export function serviceSpec({ name, dir, argv, env = process.env, shell = process.env.SHELL || '/bin/sh' }) {
30
+ const keep = {};
31
+ for (const [k, v] of Object.entries(env)) if (/^AGENTKIT_/.test(k) && v) keep[k] = v;
32
+ return { name, dir, log: path.join(dir, 'agent.log'), env: keep, command: [shell, '-l', '-c', `exec ${argv.map(q).join(' ')}`] };
33
+ }
34
+
35
+ export function plist(s, label = servicePaths(s.name).label) {
36
+ return `<?xml version="1.0" encoding="UTF-8"?>
37
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
38
+ <plist version="1.0">
39
+ <dict>
40
+ <key>Label</key><string>${xml(label)}</string>
41
+ <key>ProgramArguments</key>
42
+ <array>
43
+ ${s.command.map((a) => ` <string>${xml(a)}</string>`).join('\n')}
44
+ </array>
45
+ <key>RunAtLoad</key><true/>
46
+ <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
47
+ <key>WorkingDirectory</key><string>${xml(s.dir)}</string>
48
+ <key>EnvironmentVariables</key>
49
+ <dict>
50
+ ${Object.entries(s.env).map(([k, v]) => ` <key>${xml(k)}</key><string>${xml(v)}</string>`).join('\n')}
51
+ </dict>
52
+ <key>StandardOutPath</key><string>${xml(s.log)}</string>
53
+ <key>StandardErrorPath</key><string>${xml(s.log)}</string>
54
+ </dict>
55
+ </plist>
56
+ `;
57
+ }
58
+
59
+ export function unit(s) {
60
+ const cmd = s.command.map((a) => `"${a.replace(/(["\\])/g, '\\$1')}"`).join(' ');
61
+ return `[Unit]
62
+ Description=agentkit agent ${s.name}
63
+
64
+ [Service]
65
+ ExecStart=${cmd}
66
+ WorkingDirectory=${s.dir}
67
+ Restart=on-failure
68
+ ${Object.entries(s.env).map(([k, v]) => `Environment=${k}=${v}\n`).join('')}StandardOutput=append:${s.log}
69
+ StandardError=append:${s.log}
70
+
71
+ [Install]
72
+ WantedBy=default.target
73
+ `;
74
+ }
75
+
76
+ const uid = () => process.getuid();
77
+
78
+ export async function install(s) {
79
+ const p = servicePaths(s.name);
80
+ if (process.platform === 'darwin') {
81
+ fs.mkdirSync(path.dirname(p.plist), { recursive: true });
82
+ await sh('launchctl', ['bootout', `gui/${uid()}/${p.label}`]).catch(() => {});
83
+ fs.writeFileSync(p.plist, plist(s, p.label));
84
+ await sh('launchctl', ['bootstrap', `gui/${uid()}`, p.plist]);
85
+ return { file: p.plist, manager: 'launchd' };
86
+ }
87
+ if (process.platform === 'linux') {
88
+ fs.mkdirSync(path.dirname(p.unit), { recursive: true });
89
+ fs.writeFileSync(p.unit, unit(s));
90
+ await sh('systemctl', ['--user', 'daemon-reload']);
91
+ await sh('systemctl', ['--user', 'enable', '--now', p.unitName]);
92
+ return { file: p.unit, manager: 'systemd', note: 'To keep it running when you are logged out: loginctl enable-linger $USER' };
93
+ }
94
+ throw new Error(`Background service is not supported on ${process.platform}`);
95
+ }
96
+
97
+ export async function uninstall(name) {
98
+ const p = servicePaths(name);
99
+ if (process.platform === 'darwin') {
100
+ await sh('launchctl', ['bootout', `gui/${uid()}/${p.label}`]).catch(() => {});
101
+ fs.rmSync(p.plist, { force: true });
102
+ return { file: p.plist };
103
+ }
104
+ if (process.platform === 'linux') {
105
+ await sh('systemctl', ['--user', 'disable', '--now', p.unitName]).catch(() => {});
106
+ fs.rmSync(p.unit, { force: true });
107
+ await sh('systemctl', ['--user', 'daemon-reload']).catch(() => {});
108
+ return { file: p.unit };
109
+ }
110
+ throw new Error(`Background service is not supported on ${process.platform}`);
111
+ }
112
+
113
+ export async function status(name) {
114
+ const p = servicePaths(name);
115
+ if (process.platform === 'darwin') return { installed: fs.existsSync(p.plist), running: await sh('launchctl', ['print', `gui/${uid()}/${p.label}`]).then((o) => /state = running/.test(o), () => false), file: p.plist };
116
+ if (process.platform === 'linux') return { installed: fs.existsSync(p.unit), running: await sh('systemctl', ['--user', 'is-active', p.unitName]).then(() => true, () => false), file: p.unit };
117
+ return { installed: false, running: false, file: null };
118
+ }
@@ -0,0 +1,109 @@
1
+ // agent.yaml: what an agentkit agent is (docs/agentkit.md). Loaded once at start: paths resolved
2
+ // against the agent's folder, ${VAR} filled from the environment, defaults applied, mistakes
3
+ // reported in one message.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import YAML from 'yaml';
7
+
8
+ export const ENGINES = new Set(['claude', 'codex', 'muse']);
9
+ const DURATION = /^(\d+)\s*(s|m|h|d)$/;
10
+ export const toMs = (d) => {
11
+ const m = DURATION.exec(String(d).trim());
12
+ return m ? Number(m[1]) * { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[m[2]] : null;
13
+ };
14
+
15
+ /** ${VAR} from the environment (missing ones become empty, and are reported). */
16
+ function fillEnv(value, env, missing) {
17
+ if (typeof value === 'string') return value.replace(/\$\{(\w+)\}/g, (_, k) => (k in env ? env[k] : (missing.add(k), '')));
18
+ if (Array.isArray(value)) return value.map((v) => fillEnv(v, env, missing));
19
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, fillEnv(v, env, missing)]));
20
+ return value;
21
+ }
22
+
23
+ /** Read and check an agent: a folder with agent.yaml (or agent.json), or the file itself. */
24
+ export function loadAgent(ref, { env = process.env } = {}) {
25
+ let file = path.resolve(ref);
26
+ if (fs.existsSync(file) && fs.statSync(file).isDirectory()) {
27
+ file = ['agent.yaml', 'agent.yml', 'agent.json'].map((n) => path.join(file, n)).find((f) => fs.existsSync(f)) ?? path.join(file, 'agent.yaml');
28
+ }
29
+ if (!fs.existsSync(file)) throw new Error(`No agent.yaml at ${file}`);
30
+ const dir = path.dirname(file);
31
+ const raw = file.endsWith('.json') ? JSON.parse(fs.readFileSync(file, 'utf8')) : YAML.parse(fs.readFileSync(file, 'utf8'));
32
+ return checkAgent(raw ?? {}, { dir, env, file });
33
+ }
34
+
35
+ export function checkAgent(raw, { dir, env = process.env, file = 'agent.yaml' } = {}) {
36
+ const errors = [];
37
+ const missing = new Set();
38
+ const a = fillEnv(raw, env, missing);
39
+ if (!/^[a-z][a-z0-9-]{1,40}$/.test(a.name ?? '')) errors.push('name: lowercase letters, digits and "-" (2-41 characters), starting with a letter');
40
+ if (!String(a.goal ?? '').trim()) errors.push('goal: say what the agent achieves');
41
+ const engine = String(a.engine ?? 'claude');
42
+ if (!ENGINES.has(engine) && !/^acp:[a-z0-9-]+$/.test(engine)) errors.push(`engine: claude, codex, muse or acp:<name> (not "${engine}")`);
43
+ // Instructions: a file next to agent.yaml, or inline text.
44
+ let instructions = String(a.instructions ?? '');
45
+ if (instructions && !instructions.includes('\n') && /\.(md|txt)$/.test(instructions)) {
46
+ const f = path.resolve(dir, instructions);
47
+ if (fs.existsSync(f)) instructions = fs.readFileSync(f, 'utf8');
48
+ else errors.push(`instructions: ${instructions} not found next to the agent`);
49
+ }
50
+ const triggers = (Array.isArray(a.triggers) ? a.triggers : []).map((t, i) => {
51
+ if (t?.every != null) {
52
+ const ms = toMs(t.every);
53
+ if (!ms || ms < 10_000) errors.push(`triggers[${i}].every: a duration of at least 10s, like 10m or 1h`);
54
+ return { kind: 'every', ms, label: `every ${t.every}` };
55
+ }
56
+ if (t?.cron) {
57
+ if (String(t.cron).trim().split(/\s+/).length !== 5) errors.push(`triggers[${i}].cron: five fields (minute hour day month weekday)`);
58
+ return { kind: 'cron', cron: String(t.cron), label: `cron ${t.cron}` };
59
+ }
60
+ if (t?.webhook) return { kind: 'webhook', path: `/${String(t.webhook).replace(/^\/+/, '')}`, label: `webhook ${t.webhook}` };
61
+ if (t?.watch) return { kind: 'watch', dir: path.resolve(dir, String(t.watch)), label: `watch ${t.watch}` };
62
+ errors.push(`triggers[${i}]: every, cron, webhook or watch`);
63
+ return null;
64
+ }).filter(Boolean);
65
+ const approveTimeoutMs = a.approveTimeout != null ? toMs(a.approveTimeout) : 30 * 60_000;
66
+ if (!approveTimeoutMs) errors.push('approveTimeout: a duration, like 30m or 2h');
67
+ // Where it reports its runs and asks for approvals: AI Agent Manager (the environment wins).
68
+ // cert: the SHA-256 fingerprint of its remote-access certificate (self-signed), when over https.
69
+ const report = { url: env.AGENTKIT_REPORT_URL || a.report?.url || null, token: env.AGENTKIT_REPORT_TOKEN || a.report?.token || null, cert: env.AGENTKIT_REPORT_CERT || a.report?.cert || null };
70
+ if (report.url && !/^https?:\/\//.test(report.url)) errors.push('report.url: an http(s) address of AI Agent Manager');
71
+ const loop = { plan: true, check: true, maxSteps: 4, budgetUsd: null, ...(a.loop ?? {}) };
72
+ loop.maxSteps = Math.max(1, Math.min(20, Number(loop.maxSteps) || 4));
73
+ const iface = a.interfaces ?? {};
74
+ const out = {
75
+ name: a.name,
76
+ title: String(a.title ?? a.name ?? ''),
77
+ description: String(a.description ?? ''),
78
+ goal: String(a.goal ?? '').trim(),
79
+ instructions: instructions.trim(),
80
+ engine,
81
+ model: a.model ? String(a.model) : null,
82
+ dir,
83
+ file,
84
+ workdir: path.resolve(dir, String(a.workdir ?? './work')),
85
+ tools: a.tools && typeof a.tools === 'object' ? a.tools : {},
86
+ allow: [a.allow ?? []].flat().map(String),
87
+ approve: [a.approve ?? []].flat().map(String),
88
+ approveTimeoutMs,
89
+ report: report.url ? report : null,
90
+ triggers,
91
+ loop,
92
+ memory: a.memory !== false,
93
+ interfaces: {
94
+ http: iface.http === false ? null : { port: Number(iface.http?.port ?? 7700), host: String(iface.http?.host ?? '127.0.0.1') },
95
+ a2a: iface.a2a !== false,
96
+ acp: iface.acp !== false,
97
+ mcp: iface.mcp !== false,
98
+ },
99
+ missingEnv: [...missing],
100
+ };
101
+ if (errors.length) throw Object.assign(new Error(`${file}:\n- ${errors.join('\n- ')}`), { errors });
102
+ return out;
103
+ }
104
+
105
+ /** The agent's own files: memory, run logs and engine sessions per conversation. */
106
+ export function agentPaths(agent) {
107
+ const state = path.join(agent.dir, '.agentkit');
108
+ return { state, memory: path.join(state, 'memory.md'), runs: path.join(state, 'runs'), sessions: path.join(state, 'sessions.json'), mcp: path.join(state, 'mcp.json') };
109
+ }