ai-agentmanager 1.11.0 → 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.
package/public/index.html CHANGED
@@ -64,6 +64,7 @@
64
64
  <button class="side-tab" data-tab="inbox" role="tab" title="Everything waiting on you: prompts, questions, stuck agents, overlaps">Inbox <span id="inbox-count" class="badge"></span></button>
65
65
  <button class="side-tab" data-tab="backlog" role="tab">Backlog <span id="backlog-count" class="badge"></span></button>
66
66
  <button class="side-tab" data-tab="land" role="tab" title="Agents' open pull requests: what each needs to get merged">Land <span id="land-count" class="badge"></span></button>
67
+ <button class="side-tab" data-tab="built" role="tab" title="Agents you build for a task or workflow: they plan, act, check themselves and run in the background">Built <span id="built-count" class="badge"></span></button>
67
68
  </div>
68
69
  <div id="agents-view">
69
70
  <div class="counts" id="counts"></div>
@@ -79,6 +80,7 @@
79
80
  <div id="backlog-view" hidden></div>
80
81
  <div id="inbox-view" hidden></div>
81
82
  <div id="land-view" hidden></div>
83
+ <div id="built-view" hidden></div>
82
84
  <div id="empty" class="empty" hidden>
83
85
  <p>No agents yet.</p>
84
86
  <p>Add one or more intents and a repo or folder to get started.</p>
@@ -161,6 +163,8 @@
161
163
  </div>
162
164
  <div id="term-drop" class="term-drop" hidden>Drop files to attach</div>
163
165
  <div class="keybar" id="keybar" aria-label="Terminal keys">
166
+ <button data-clip-act="copy" title="Copy text from the terminal">Copy</button>
167
+ <button data-clip-act="paste" title="Paste into the terminal">Paste</button>
164
168
  <button data-key="esc">Esc</button>
165
169
  <button data-key="tab">Tab</button>
166
170
  <button data-key="stab" title="Shift+Tab: cycle permission mode">⇧Tab</button>
@@ -375,6 +379,25 @@
375
379
  </form>
376
380
  </dialog>
377
381
 
382
+ <dialog id="build-dialog">
383
+ <form id="build-form" method="dialog">
384
+ <h2>Build an agent</h2>
385
+ <p class="hint">Describe the task or workflow it takes over: what comes in, what it should do, what it must never do. It's designed for you to review (analysis, spec, test cases) before it runs.</p>
386
+ <label>Task or workflow
387
+ <textarea name="task" rows="8" required placeholder="Every morning, look at new support tickets in Jira, label each by product area, route it to the right team, and draft a first reply for a person to send. Never close or reply to tickets on its own."></textarea>
388
+ </label>
389
+ <div id="build-attach"></div>
390
+ <label>Engine <select name="engine"><option value="claude">Claude Code</option></select></label>
391
+ <p id="build-error" class="error" hidden></p>
392
+ <div class="dialog-actions"><button type="button" id="build-cancel" class="ghost">Cancel</button><button type="submit" class="primary">Design the agent</button></div>
393
+ </form>
394
+ </dialog>
395
+
396
+ <dialog id="built-dialog" class="doc-dialog">
397
+ <div class="doc-head"><h2 id="built-title"></h2><span class="spacer"></span><button type="button" id="built-close" class="small ghost">Close</button></div>
398
+ <div id="built-body" class="built-body"></div>
399
+ </dialog>
400
+
378
401
  <dialog id="doc-dialog" class="doc-dialog">
379
402
  <div class="doc-head">
380
403
  <h2 id="doc-title"></h2>
@@ -767,6 +790,8 @@
767
790
  <button class="term-tab term-add" id="machine-new" title="Open another terminal">+ Terminal</button>
768
791
  <span id="machine-title" class="hint"></span>
769
792
  <span class="spacer"></span>
793
+ <button id="machine-copy" class="ghost small" title="Copy text from the terminal">Copy</button>
794
+ <button id="machine-paste" class="ghost small" title="Paste into the terminal">Paste</button>
770
795
  <button id="machine-close" class="ghost small" title="Hide (the shells keep running)">Hide</button>
771
796
  </div>
772
797
  <div id="machine-term" class="terminal"></div>
package/public/machine.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // any signed-in device (over remote access it streams like an agent's shell). The shells keep
3
3
  // running when the window is hidden; × ends one.
4
4
  import { openStream } from '/transport.js';
5
+ import { openCopy, pasteInto } from '/termclip.js';
5
6
 
6
7
  const MACHINE = '__machine__';
7
8
 
@@ -86,6 +87,9 @@ export function initMachine({ api, esc, toast, $, Terminal, FitAddon }) {
86
87
  shells = [...new Set([...shells, n])];
87
88
  connect(n);
88
89
  };
90
+ // Copy / Paste buttons: on a phone the terminal's text can't be selected or pasted into directly.
91
+ $('#machine-copy').onclick = () => xterm && openCopy(xterm, { esc, toast });
92
+ $('#machine-paste').onclick = () => xterm && pasteInto(xterm, { toast });
89
93
  $('#machine-close').onclick = () => {
90
94
  conn?.close();
91
95
  conn = null;
package/public/style.css CHANGED
@@ -497,6 +497,7 @@ dialog::backdrop { background: rgba(0, 0, 0, .45); }
497
497
  .need-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 4px; }
498
498
  .need-kind { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
499
499
  .need.prompt .need-kind { color: var(--red); }
500
+ .need.approval .need-kind { color: var(--red); }
500
501
  .need.question .need-kind { color: var(--accent); }
501
502
  .need.stuck .need-kind { color: var(--yellow); }
502
503
  .need.conflict .need-kind { color: #8e59ff; }
@@ -940,3 +941,35 @@ form.dropping { outline: 2px dashed var(--accent, #6b8cff); outline-offset: 4px;
940
941
  /* Settings → More: separators belong to the links, so hidden (local-only) ones leave none behind. */
941
942
  .settings-links button + button::before { content: '·'; display: inline-block; margin: 0 8px; color: var(--muted); }
942
943
  body.remote .settings-links .local-only { display: none; }
944
+
945
+ /* Copy / Paste sheets for terminals on touch screens. */
946
+ .clip-sheet { width: min(760px, calc(100vw - 16px)); max-height: calc(100vh - 32px); padding: 0; display: flex; flex-direction: column; }
947
+ .clip-sheet:not([open]) { display: none; }
948
+ .clip-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
949
+ .clip-text { margin: 0; padding: 10px 12px; overflow: auto; flex: 1; min-height: 200px; max-height: 70vh; font: 12px/1.45 var(--mono, ui-monospace, monospace); white-space: pre-wrap; word-break: break-word; -webkit-user-select: text; user-select: text; background: var(--term-bg); color: #e6edf3; }
950
+ .clip-box { margin: 10px 12px; width: calc(100% - 24px); font: 13px var(--mono, ui-monospace, monospace); }
951
+ .clip-foot { display: flex; align-items: center; gap: 8px; padding: 0 12px 12px; }
952
+ .clip-foot .hint { flex: 1; }
953
+
954
+ /* Built agents. */
955
+ .built-head { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; }
956
+ .built-list { list-style: none; margin: 0; padding: 0; }
957
+ .built-list li { padding: 9px 12px; border-bottom: 1px solid var(--border); cursor: pointer; }
958
+ .built-list li:hover { background: var(--panel-2); }
959
+ .built-list .line1 { display: flex; align-items: center; gap: 6px; }
960
+ .built-empty { padding: 0 12px; }
961
+ .dot-run { color: var(--green); font-size: 10px; }
962
+ .dot-run.off { color: var(--muted); }
963
+ .reporting-head { margin-top: 18px; }
964
+ .report-approval { margin: 8px 0; padding: 8px 10px; border: 1px solid var(--yellow); border-radius: 8px; }
965
+ .report-approval pre, .need-input { font-size: 12px; white-space: pre-wrap; max-height: 160px; overflow: auto; margin: 6px 0; }
966
+ #built-connect { padding: 0 12px; } #built-connect pre { white-space: pre-wrap; word-break: break-all; padding: 8px; border: 1px solid var(--border); border-radius: 6px; }
967
+ .built-body { padding: 0 4px 12px; display: flex; flex-direction: column; gap: 10px; }
968
+ .built-body details > summary { cursor: pointer; padding: 4px 0; }
969
+ .built-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
970
+ .built-exe { margin: 10px 0; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; }
971
+ .built-exe-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
972
+ .built-tests li { margin-bottom: 8px; } .built-tests li.pass b { color: var(--green); } .built-tests li.fail b { color: var(--red); }
973
+ .built-tests pre, .built-runs div { white-space: pre-wrap; font-size: 12.5px; }
974
+ .code-area { width: 100%; font: 12.5px/1.45 var(--mono); margin-top: 6px; }
975
+ .built-runs { list-style: none; padding: 0; } .built-runs li { padding: 6px 0; border-bottom: 1px solid var(--border); }
@@ -0,0 +1,92 @@
1
+ // Copy and paste for terminals on touch screens. xterm draws its text itself, so a phone can't
2
+ // long-press-select it, and its hidden input rarely gets the paste menu. Copy opens the terminal's
3
+ // recent output as plain text the phone can select (or Copy all); Paste reads the clipboard (the
4
+ // phone asks first) or, where it can't, offers a box to paste into, then sends it as one paste.
5
+
6
+ const MAX_LINES = 600;
7
+
8
+ /** The terminal's text: the last lines of its buffer, trailing blanks trimmed. */
9
+ export function terminalText(xterm, max = MAX_LINES) {
10
+ const buf = xterm.buffer.active;
11
+ const lines = [];
12
+ for (let i = Math.max(0, buf.length - max); i < buf.length; i++) {
13
+ const line = buf.getLine(i);
14
+ if (!line) continue;
15
+ // A wrapped line continues the one before it.
16
+ if (line.isWrapped && lines.length) lines[lines.length - 1] += line.translateToString(true);
17
+ else lines.push(line.translateToString(true));
18
+ }
19
+ while (lines.length && !lines.at(-1).trim()) lines.pop();
20
+ return lines.join('\n');
21
+ }
22
+
23
+ function sheet(html) {
24
+ const d = document.createElement('dialog');
25
+ d.className = 'clip-sheet';
26
+ d.innerHTML = html;
27
+ document.body.append(d);
28
+ d.addEventListener('close', () => d.remove());
29
+ d.showModal();
30
+ return d;
31
+ }
32
+
33
+ async function writeClipboard(text) {
34
+ try {
35
+ await navigator.clipboard.writeText(text);
36
+ return true;
37
+ } catch {
38
+ // Older browsers, or no permission: copy through a selected text area.
39
+ const t = Object.assign(document.createElement('textarea'), { value: text });
40
+ t.style.cssText = 'position:fixed;opacity:0';
41
+ document.body.append(t);
42
+ t.select();
43
+ const ok = document.execCommand?.('copy');
44
+ t.remove();
45
+ return Boolean(ok);
46
+ }
47
+ }
48
+
49
+ /** Copy: the terminal's text in a sheet, selectable with the phone's own handles, and Copy all. */
50
+ export function openCopy(xterm, { esc, toast }) {
51
+ const text = terminalText(xterm);
52
+ const d = sheet(`<div class="clip-head"><b>Copy from the terminal</b><span class="hint">Select with your finger, or copy it all</span><span class="spacer"></span><button type="button" class="small primary" data-clip="all">Copy all</button><button type="button" class="small ghost" data-clip="close">Close</button></div>
53
+ <pre class="clip-text">${esc(text) || '<span class="hint">Nothing in this terminal yet.</span>'}</pre>`);
54
+ // Open at the end, where the latest output is.
55
+ const pre = d.querySelector('.clip-text');
56
+ pre.scrollTop = pre.scrollHeight;
57
+ d.addEventListener('click', async (e) => {
58
+ const act = e.target.closest('[data-clip]')?.dataset.clip;
59
+ if (act === 'close') d.close();
60
+ if (act === 'all') {
61
+ toast((await writeClipboard(text)) ? 'Copied the terminal text' : 'Copy failed: select the text and use your phone\'s Copy', 3000);
62
+ d.close();
63
+ }
64
+ });
65
+ }
66
+
67
+ /** Paste: the clipboard into the terminal (one paste, not submitted), or a box to paste into. */
68
+ export async function pasteInto(xterm, { toast }) {
69
+ try {
70
+ const text = await navigator.clipboard.readText();
71
+ if (text) {
72
+ xterm.paste(text);
73
+ return;
74
+ }
75
+ } catch {
76
+ // Not allowed here (or refused): fall back to a box the phone can paste into.
77
+ }
78
+ const d = sheet(`<div class="clip-head"><b>Paste into the terminal</b><span class="spacer"></span><button type="button" class="small ghost" data-clip="close">Cancel</button></div>
79
+ <textarea class="clip-box" rows="6" placeholder="Long-press here and choose Paste"></textarea>
80
+ <div class="clip-foot"><span class="hint">It goes in as typed text; press Enter (⏎) in the terminal when you want it run.</span><button type="button" class="small primary" data-clip="send">Insert</button></div>`);
81
+ const box = d.querySelector('.clip-box');
82
+ box.focus();
83
+ d.addEventListener('click', (e) => {
84
+ const act = e.target.closest('[data-clip]')?.dataset.clip;
85
+ if (act === 'close') d.close();
86
+ if (act === 'send') {
87
+ if (box.value) xterm.paste(box.value);
88
+ else toast('Nothing to paste');
89
+ d.close();
90
+ }
91
+ });
92
+ }
@@ -0,0 +1,171 @@
1
+ // The agent at work: each input becomes a run of plan → act → check (→ act again on what's left)
2
+ // → reflect, through the engine (engines.js), one run at a time. Runs are logged as JSONL; memory
3
+ // carries lessons between runs; a conversation (a chat, an A2A context) keeps its engine session.
4
+ import crypto from 'node:crypto';
5
+ import { EventEmitter } from 'node:events';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { Approvals } from './approvals.js';
9
+ import { agentPaths } from './spec.js';
10
+ import { APPROVER, approverServer, turn, writeMcpConfig } from './engines.js';
11
+
12
+ const now = () => new Date().toISOString();
13
+ const readJson = (f, d) => {
14
+ try {
15
+ return JSON.parse(fs.readFileSync(f, 'utf8'));
16
+ } catch {
17
+ return d;
18
+ }
19
+ };
20
+
21
+ /** The check step's verdict, from a reply that should hold {"done": …, "remaining": …}. */
22
+ export function verdictOf(text) {
23
+ const m = /\{[\s\S]*\}/.exec(String(text ?? ''));
24
+ try {
25
+ const j = JSON.parse(m?.[0] ?? '');
26
+ return { done: j.done === true, remaining: String(j.remaining ?? '').trim() };
27
+ } catch {
28
+ // No JSON: read "done" / "not done" in plain words, else assume done (don't loop on noise).
29
+ return { done: !/\bnot (yet )?(done|met|complete)\b/i.test(String(text)), remaining: '' };
30
+ }
31
+ }
32
+
33
+ export class Agent extends EventEmitter {
34
+ /** `approvals`: 'ask' (a person decides approval tools, where the engine can ask) or 'deny'. */
35
+ constructor(spec, { env = process.env, engineTurn = turn, approvals = 'ask' } = {}) {
36
+ super();
37
+ this.spec = spec;
38
+ this.env = env;
39
+ this.engineTurn = engineTurn;
40
+ this.paths = agentPaths(spec);
41
+ fs.mkdirSync(this.paths.runs, { recursive: true });
42
+ this.mcpFile = writeMcpConfig(spec, this.paths.mcp);
43
+ this.approvals = new Approvals(spec, { timeoutMs: spec.approveTimeoutMs ?? 30 * 60_000 });
44
+ this.approvals.on('change', () => this.emit('approvals', this.approvals.pending()));
45
+ // Only Claude can ask mid-run (the others get approval tools taken away instead).
46
+ this.asks = approvals === 'ask' && spec.engine === 'claude' && spec.approve.length > 0;
47
+ this.approverReady = false;
48
+ this.sessions = readJson(this.paths.sessions, {});
49
+ this.queue = Promise.resolve();
50
+ this.current = null;
51
+ }
52
+
53
+ memory() {
54
+ try {
55
+ return fs.readFileSync(this.paths.memory, 'utf8').trim();
56
+ } catch {
57
+ return '';
58
+ }
59
+ }
60
+
61
+ system() {
62
+ const s = this.spec;
63
+ return [
64
+ `You are "${s.title}", an autonomous agent that works on its own, without a person watching.`,
65
+ `Your goal:\n${s.goal}`,
66
+ s.instructions ? `How to do the job:\n${s.instructions}` : '',
67
+ s.memory && this.memory() ? `What you learned in earlier runs (keep using it):\n${this.memory().slice(-6000)}` : '',
68
+ 'Work in your folder with your tools. Never invent results: if you could not do something, say so plainly.',
69
+ ]
70
+ .filter(Boolean)
71
+ .join('\n\n');
72
+ }
73
+
74
+ /** Queue a run; resolves with its result. `conversation` continues an engine session. */
75
+ run(input, { source = 'api', conversation = null } = {}) {
76
+ const job = this.queue.then(() => this.doRun(String(input ?? ''), { source, conversation }));
77
+ this.queue = job.catch(() => {});
78
+ return job;
79
+ }
80
+
81
+ async doRun(input, { source, conversation }) {
82
+ const id = `${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
83
+ const file = path.join(this.paths.runs, `${id}.jsonl`);
84
+ const t0 = Date.now();
85
+ let cost = 0;
86
+ const log = (rec) => {
87
+ const r = { t: now(), ...rec };
88
+ fs.appendFileSync(file, JSON.stringify(r) + '\n');
89
+ this.emit('step', { run: id, ...r });
90
+ };
91
+ this.current = { id, source, startedAt: now() };
92
+ log({ type: 'start', input, source, conversation });
93
+ let session = conversation ? this.sessions[conversation] ?? null : null;
94
+ if (this.asks && !this.approverReady) {
95
+ const url = await this.approvals.address();
96
+ this.mcpFile = writeMcpConfig(this.spec, this.paths.mcp, { [APPROVER]: approverServer({ url, token: this.approvals.token }) });
97
+ this.approverReady = true;
98
+ }
99
+ this.approvals.run = id;
100
+ const ask = async (step, prompt) => {
101
+ const res = await this.engineTurn(this.spec, { prompt, system: this.system(), session, mcpFile: this.mcpFile, approver: this.asks, env: this.env });
102
+ session = res.session ?? session;
103
+ if (res.costUsd) cost += res.costUsd;
104
+ log({ type: step, text: res.text });
105
+ if (this.spec.loop.budgetUsd && cost > this.spec.loop.budgetUsd) throw new Error(`Over the run budget ($${cost.toFixed(2)} of $${this.spec.loop.budgetUsd})`);
106
+ return res.text;
107
+ };
108
+ let result = '';
109
+ let status = 'done';
110
+ try {
111
+ // Someone asked (chat, API, A2A, ACP, MCP, the command line), or a trigger fired ("every 10m", …).
112
+ const from = ['chat', 'api', 'a2a', 'acp', 'mcp', 'cli'].includes(source) ? 'a request' : `a trigger (${source})`;
113
+ // The goal goes in every step, not only the system prompt: the engine's own framing ("a coding
114
+ // agent") must not turn an input into a different job than this agent's.
115
+ const goal = `Your goal (this is your whole job): ${this.spec.goal}`;
116
+ const given = `New input, from ${from}:\n${input || '(none: do your scheduled work)'}`;
117
+ if (this.spec.loop.plan) await ask('plan', `${goal}\n\n${given}\n\nWrite a short numbered plan to achieve your goal for this input. Treat the input as material for your goal, not as a new assignment. Don't act yet.`);
118
+ result = await ask('act', this.spec.loop.plan ? `Carry out the plan now. ${goal}\nWhen you are done, reply with the result your goal calls for.` : `${goal}\n\n${given}\n\nTreat the input as material for your goal, not as a new assignment. Do what your goal calls for and reply with the result.`);
119
+ for (let step = 1; this.spec.loop.check && step < this.spec.loop.maxSteps; step++) {
120
+ const v = verdictOf(await ask('check', `Check your last result against your goal. ${goal}\nReply with JSON only: {"done": true|false, "remaining": "<what is still missing for the goal, if anything>"}.`));
121
+ if (v.done) break;
122
+ result = await ask('act', `Not done yet. Continue with what is left: ${v.remaining || 'finish the remaining work'}. Then reply with the full result.`);
123
+ if (step === this.spec.loop.maxSteps - 1) status = 'partial';
124
+ }
125
+ if (this.spec.memory) {
126
+ const lesson = (await ask('reflect', 'In at most two short bullet points, what should you remember for next time (about this kind of input, the tools, or the people)? Reply "nothing" if there is nothing new.')).trim();
127
+ if (lesson && !/^nothing\.?$/i.test(lesson)) fs.appendFileSync(this.paths.memory, `\n## ${now().slice(0, 10)}\n${lesson}\n`);
128
+ }
129
+ } catch (err) {
130
+ status = 'failed';
131
+ result = result || `The run failed: ${err.message}`;
132
+ log({ type: 'error', text: err.message });
133
+ }
134
+ if (conversation && session) {
135
+ this.sessions[conversation] = session;
136
+ fs.writeFileSync(this.paths.sessions, JSON.stringify(this.sessions, null, 2));
137
+ }
138
+ const summary = { id, status, result, source, conversation, costUsd: cost ? Math.round(cost * 1e6) / 1e6 : null, ms: Date.now() - t0 };
139
+ log({ type: 'end', ...summary });
140
+ this.current = null;
141
+ this.approvals.run = null;
142
+ this.emit('run', summary);
143
+ return summary;
144
+ }
145
+
146
+ close() {
147
+ this.approvals.close();
148
+ }
149
+
150
+ /** Recent runs, newest first: id, status, source, start time, result (from each log). */
151
+ runs(limit = 50) {
152
+ let names = [];
153
+ try {
154
+ names = fs.readdirSync(this.paths.runs).filter((n) => n.endsWith('.jsonl')).sort().reverse().slice(0, limit);
155
+ } catch {}
156
+ return names.map((n) => this.runLog(n.slice(0, -6))).filter(Boolean).map((steps) => {
157
+ const start = steps.find((s) => s.type === 'start');
158
+ const end = steps.find((s) => s.type === 'end');
159
+ return { id: start?.id ?? end?.id, startedAt: start?.t, source: start?.source, input: start?.input, status: end?.status ?? 'running', result: end?.result ?? null, costUsd: end?.costUsd ?? null };
160
+ }).map((r, i) => ({ ...r, id: r.id ?? names[i].slice(0, -6) }));
161
+ }
162
+
163
+ runLog(id) {
164
+ if (!/^[\w-]+$/.test(id)) return null;
165
+ try {
166
+ return fs.readFileSync(path.join(this.paths.runs, `${id}.jsonl`), 'utf8').trim().split('\n').map((l) => JSON.parse(l));
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+ }
@@ -0,0 +1,97 @@
1
+ // Approvals: a tool on the agent's `approve` list runs only after a person says yes. Claude asks
2
+ // the approver (an MCP tool, --permission-prompt-tool) before any tool it isn't allowed outright;
3
+ // the approver asks this object over a private local address; this asks the people reachable
4
+ // (the chat page, AI Agent Manager) and answers with the first decision, or no after a while.
5
+ import crypto from 'node:crypto';
6
+ import { EventEmitter } from 'node:events';
7
+ import http from 'node:http';
8
+
9
+ /** Does a tool name match a pattern ("mcp__jira__*", "Bash", "Bash(git push:*)" → Bash)? */
10
+ export function toolMatches(pattern, tool) {
11
+ const base = String(pattern).replace(/\(.*\)$/, '');
12
+ const re = new RegExp(`^${base.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`);
13
+ return re.test(String(tool));
14
+ }
15
+
16
+ /** Allow patterns Claude may apply on its own: not ones that also cover a tool that needs approval. */
17
+ export function allowedOutright(spec) {
18
+ return spec.allow.filter((p) => !spec.approve.some((q) => toolMatches(p, q.replace(/\(.*\)$/, ''))));
19
+ }
20
+
21
+ export class Approvals extends EventEmitter {
22
+ constructor(spec, { timeoutMs = 30 * 60_000 } = {}) {
23
+ super();
24
+ this.spec = spec;
25
+ this.timeoutMs = timeoutMs;
26
+ this.open = new Map(); // id -> { id, tool, input, run, at, finish }
27
+ this.decided = []; // the latest decisions, for the views
28
+ this.channels = new Set(); // who can answer: 'chat', 'manager'
29
+ this.server = null;
30
+ this.token = crypto.randomBytes(24).toString('hex');
31
+ }
32
+
33
+ /** The private address the approver calls (started on first use). */
34
+ async address() {
35
+ if (this.server) return this.url;
36
+ this.server = http.createServer((req, res) => {
37
+ let body = '';
38
+ req.on('data', (d) => (body += d));
39
+ req.on('end', async () => {
40
+ const ok = req.method === 'POST' && req.headers.authorization === `Bearer ${this.token}`;
41
+ if (!ok) return res.writeHead(401).end();
42
+ let ask = {};
43
+ try {
44
+ ask = JSON.parse(body);
45
+ } catch {}
46
+ const d = await this.request({ tool: String(ask.tool ?? ''), input: ask.input ?? {} });
47
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(d));
48
+ });
49
+ });
50
+ await new Promise((r) => this.server.listen(0, '127.0.0.1', r));
51
+ this.server.unref();
52
+ this.url = `http://127.0.0.1:${this.server.address().port}/`;
53
+ return this.url;
54
+ }
55
+
56
+ /** A tool wants to run: yes now if allowed, no if not on the approve list, else ask and wait. */
57
+ request({ tool, input = {} }) {
58
+ const ask = this.spec.approve.some((p) => toolMatches(p, tool));
59
+ if (!ask && this.spec.allow.some((p) => toolMatches(p, tool))) return Promise.resolve({ allow: true });
60
+ if (!ask) return Promise.resolve({ allow: false, message: `${tool} is not in this agent's allow list` });
61
+ if (!this.channels.size) return Promise.resolve({ allow: false, message: `${tool} needs a person's approval and no one can be asked (no chat page, not connected to AI Agent Manager)` });
62
+ const id = `ap-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
63
+ return new Promise((resolve) => {
64
+ const timer = setTimeout(() => this.decide(id, false, { by: 'timeout', note: `No answer in ${Math.round(this.timeoutMs / 60_000)} min` }), this.timeoutMs);
65
+ const a = { id, tool, input, run: this.run ?? null, at: new Date().toISOString(), finish: (d) => (clearTimeout(timer), resolve(d)) };
66
+ this.open.set(id, a);
67
+ this.emit('request', this.view(a));
68
+ this.emit('change');
69
+ });
70
+ }
71
+
72
+ /** A person's (or the timeout's) answer. Returns false if it was already decided. */
73
+ decide(id, allow, { by = 'someone', note = '' } = {}) {
74
+ const a = this.open.get(id);
75
+ if (!a) return false;
76
+ this.open.delete(id);
77
+ const d = { ...this.view(a), allow: Boolean(allow), by, note, decidedAt: new Date().toISOString() };
78
+ this.decided = [d, ...this.decided].slice(0, 20);
79
+ a.finish({ allow: Boolean(allow), message: allow ? '' : `Declined by ${by}${note ? `: ${note}` : ''}` });
80
+ this.emit('decided', d);
81
+ this.emit('change');
82
+ return true;
83
+ }
84
+
85
+ view(a) {
86
+ return { id: a.id, tool: a.tool, input: a.input, run: a.run, at: a.at };
87
+ }
88
+
89
+ pending() {
90
+ return [...this.open.values()].map((a) => this.view(a));
91
+ }
92
+
93
+ close() {
94
+ for (const id of [...this.open.keys()]) this.decide(id, false, { by: 'shutdown' });
95
+ this.server?.close();
96
+ }
97
+ }
@@ -0,0 +1,153 @@
1
+ // The agentkit command line (bin/agentkit.js), also inside a packaged agent (sea-main.js), where
2
+ // the agent's own folder is the default and `run` is the default command.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { Command } from 'commander';
6
+ import { Agent } from './agent.js';
7
+ import { startHttp } from './http.js';
8
+ import { loadAgent } from './spec.js';
9
+ import { install as serviceInstall, serviceSpec, status as serviceStatus, uninstall as serviceUninstall } from './service.js';
10
+ import { startReporting } from './report.js';
11
+ import { serveAcp, serveApprover, serveMcp } from './stdio.js';
12
+ import { startTriggers } from './triggers.js';
13
+
14
+ // Status goes to stderr: stdout belongs to the protocol (acp, mcp) or the result (--once).
15
+ const say = (m) => process.stderr.write(`[agentkit] ${m}\n`);
16
+ const load = (ref) => {
17
+ const spec = loadAgent(ref);
18
+ if (spec.missingEnv.length) say(`warning: not set in the environment: ${spec.missingEnv.join(', ')}`);
19
+ return spec;
20
+ };
21
+
22
+ /** Run the command line; `defaultRef` is the agent folder when none is given. */
23
+ export async function main(argv, { name = 'agentkit', defaultRef = '.', packaged = false } = {}) {
24
+ const program = new Command().name(name).description(packaged ? `${name}: an agent built with agentkit` : 'Run agents described by agent.yaml');
25
+
26
+ program
27
+ .command('run')
28
+ .argument('[agent]', 'the agent folder (or its agent.yaml)', defaultRef)
29
+ .option('--once <input>', 'do one run with this input, print the result and exit')
30
+ .option('-p, --port <port>', 'HTTP port (default: agent.yaml interfaces.http.port, or 7700)')
31
+ .action(async (ref, opts) => {
32
+ const spec = load(ref);
33
+ const agent = new Agent(spec);
34
+ agent.on('step', (s) => s.type !== 'start' && s.type !== 'end' && say(`${s.run} ${s.type}${s.text ? `: ${String(s.text).split('\n')[0].slice(0, 120)}` : ''}`));
35
+ agent.approvals.on('request', (a) => say(`approval needed: ${a.tool} (${a.id}); approve it ${[agent.approvals.channels.has('chat') && 'on the chat page', agent.approvals.channels.has('manager') && 'in AI Agent Manager'].filter(Boolean).join(' or ')}`));
36
+ agent.approvals.on('decided', (d) => say(`${d.tool}: ${d.allow ? 'approved' : 'declined'} by ${d.by}`));
37
+ let web = null;
38
+ const reporting = spec.report ? startReporting(agent, { url: spec.report.url, token: spec.report.token, cert: spec.report.cert, chat: () => web?.url ?? null, log: say }) : null;
39
+ if (opts.once !== undefined) {
40
+ const r = await agent.run(opts.once, { source: 'cli' });
41
+ await reporting?.flush();
42
+ process.stdout.write(`${r.result}\n`);
43
+ process.exit(r.status === 'failed' ? 1 : 0);
44
+ }
45
+ say(`${spec.title} (${spec.engine}) · working in ${spec.workdir}`);
46
+ const stop = startTriggers(agent, { log: say });
47
+ web = spec.interfaces.http ? await startHttp(agent, { port: opts.port ? Number(opts.port) : spec.interfaces.http.port, host: spec.interfaces.http.host, log: say }) : null;
48
+ reporting?.push(); // with its chat address now
49
+ agent.on('run', (r) => say(`run ${r.id} ${r.status} (${r.source}, ${Math.round(r.ms / 1000)}s${r.costUsd ? `, $${r.costUsd.toFixed(3)}` : ''})`));
50
+ const quit = async () => {
51
+ stop();
52
+ reporting?.stop();
53
+ agent.close();
54
+ await web?.close();
55
+ process.exit(0);
56
+ };
57
+ process.on('SIGINT', quit);
58
+ process.on('SIGTERM', quit);
59
+ });
60
+
61
+ program
62
+ .command('acp')
63
+ .argument('[agent]', 'the agent folder', defaultRef)
64
+ .action((ref) => serveAcp(new Agent(load(ref))));
65
+
66
+ program
67
+ .command('mcp')
68
+ .argument('[agent]', 'the agent folder', defaultRef)
69
+ .action((ref) => serveMcp(new Agent(load(ref))));
70
+
71
+ // Started by Claude as an MCP server (see approvals.js), not by people.
72
+ program
73
+ .command('approver', { hidden: true })
74
+ .action(() => serveApprover({ url: process.env.AGENTKIT_APPROVAL_URL, token: process.env.AGENTKIT_APPROVAL_TOKEN }));
75
+
76
+ program
77
+ .command('check')
78
+ .argument('[agent]', 'the agent folder', defaultRef)
79
+ .action((ref) => {
80
+ const s = load(ref);
81
+ console.log(`${s.title} (${s.name}): engine ${s.engine}${s.model ? ` (${s.model})` : ''}, ${Object.keys(s.tools).length} MCP server(s), triggers: ${s.triggers.map((t) => t.label).join(', ') || 'none'}; interfaces: ${['http', 'a2a', 'acp', 'mcp'].filter((k) => s.interfaces[k]).join(', ')}`);
82
+ });
83
+
84
+ if (!packaged)
85
+ program
86
+ .command('package')
87
+ .description('Make the agent one executable to run anywhere (macOS or Linux; macOS ones are built on a Mac)')
88
+ .argument('[agent]', 'the agent folder', defaultRef)
89
+ .option('--platform <platform>', 'macos-arm64, macos-x64, linux-x64 or linux-arm64 (default: this computer)')
90
+ .option('-o, --out <folder>', 'where to write it (default: <agent>/dist)')
91
+ .action(async (ref, opts) => {
92
+ const { packageAgent, HOST } = await import('./package.js');
93
+ const spec = load(ref);
94
+ const r = await packageAgent(spec.dir, { outDir: path.resolve(opts.out ?? path.join(spec.dir, 'dist')), platform: opts.platform ?? HOST, log: say });
95
+ console.log(`${r.file} (${(r.size / 1048576).toFixed(0)} MB, ${r.platform}). Run it: ${r.file} (needs ${spec.engine === 'claude' ? 'Claude Code' : spec.engine} installed and signed in)`);
96
+ });
97
+ if (!packaged)
98
+ program
99
+ .command('init')
100
+ .argument('<folder>', 'where to create the agent')
101
+ .option('-n, --name <name>', 'its name (default: the folder name)')
102
+ .action((folder, opts) => {
103
+ const dir = path.resolve(folder);
104
+ if (fs.existsSync(path.join(dir, 'agent.yaml'))) throw new Error(`${dir} already has an agent.yaml`);
105
+ const name = (opts.name ?? path.basename(dir)).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'my-agent';
106
+ fs.mkdirSync(dir, { recursive: true });
107
+ fs.writeFileSync(
108
+ path.join(dir, 'agent.yaml'),
109
+ `name: ${name}\ntitle: ${name}\ndescription: What this agent does, in one line.\ngoal: |\n What is true when the agent has done its job.\ninstructions: instructions.md\nengine: claude\n\ntools: {} # MCP servers, e.g. jira: { command: npx, args: [-y, "@acme/jira-mcp"] }\nallow: [Read, Grep, Glob]\n\ntriggers: [] # e.g. - every: 30m - cron: "0 9 * * 1-5" - webhook: /hooks/new - watch: ./inbox\n\nloop: { plan: true, check: true, maxSteps: 4 }\nmemory: true\ninterfaces: { http: { port: 7700 }, a2a: true, acp: true, mcp: true }\n`,
110
+ );
111
+ fs.writeFileSync(path.join(dir, 'instructions.md'), '# How to do the job\n\n1. …\n2. …\n\nWhen something is unclear, say what you need rather than guessing.\n');
112
+ console.log(`Created ${path.join(dir, 'agent.yaml')}. Edit it, then: agentkit run ${folder}`);
113
+ });
114
+
115
+ // In the background: started at login, restarted after a crash, output in <agent>/agent.log.
116
+ const service = program.command('service').description('Keep it running in the background (launchd on macOS, systemd on Linux)');
117
+ const serviceOf = (ref) => {
118
+ const spec = load(ref);
119
+ // A packaged agent runs itself; otherwise this agentkit runs the folder.
120
+ const argv = packaged ? [process.execPath, 'run'] : [process.execPath, path.resolve(process.argv[1]), 'run', spec.dir];
121
+ return { spec, s: serviceSpec({ name: spec.name, dir: spec.dir, argv }) };
122
+ };
123
+ service
124
+ .command('install')
125
+ .argument('[agent]', 'the agent folder', defaultRef)
126
+ .action(async (ref) => {
127
+ const { spec, s } = serviceOf(ref);
128
+ const r = await serviceInstall(s);
129
+ console.log(`${spec.title} now runs in the background (${r.manager}: ${r.file}); output in ${s.log}.${packaged ? ` Keep ${process.execPath} where it is.` : ''}${r.note ? `\n${r.note}` : ''}`);
130
+ });
131
+ service
132
+ .command('uninstall')
133
+ .argument('[agent]', 'the agent folder', defaultRef)
134
+ .action(async (ref) => {
135
+ const r = await serviceUninstall(load(ref).name);
136
+ console.log(`Stopped and removed ${r.file}`);
137
+ });
138
+ service
139
+ .command('status')
140
+ .argument('[agent]', 'the agent folder', defaultRef)
141
+ .action(async (ref) => {
142
+ const r = await serviceStatus(load(ref).name);
143
+ console.log(r.installed ? `installed (${r.file}); ${r.running ? 'running' : 'not running'}` : 'not installed');
144
+ });
145
+
146
+ // A packaged agent: `<agent>` runs it, `<agent> --once …` does one run, `<agent> acp|mcp|check` the rest.
147
+ if (packaged && !['run', 'acp', 'mcp', 'check', 'service', 'approver', 'help', '--help', '-h', '--version'].includes(argv[0])) argv = ['run', ...argv];
148
+ return program.parseAsync(argv, { from: 'user' }).catch((err) => {
149
+ console.error(`${name}: ${err.message}`);
150
+ process.exit(1);
151
+ });
152
+ }
153
+