ai-agentmanager 1.12.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +4 -1
- package/docs/agentkit.md +15 -1
- package/package.json +1 -1
- package/public/app.js +66 -7
- package/public/built.js +147 -22
- package/public/index.html +9 -3
- package/public/menu.js +18 -0
- package/public/style.css +12 -0
- package/src/agentkit/engines.js +14 -1
- package/src/builder.js +207 -82
- package/src/cli.js +33 -0
- package/src/manager.js +80 -4
- package/src/rules.js +161 -0
- package/src/server.js +27 -5
- package/src/stuck.js +9 -2
package/src/manager.js
CHANGED
|
@@ -112,7 +112,9 @@ import { Plugins } from './plugins.js';
|
|
|
112
112
|
import { leadPrompt, subagents, teamDir, teamPrompt } from './teams.js';
|
|
113
113
|
import { assistantDir, assistantPrompt, assistantSettings } from './assistant.js';
|
|
114
114
|
import { learnCommand, memoryKey, memoryPrompt, memoryText } from './memory.js';
|
|
115
|
+
import { Rules, describeTool } from './rules.js';
|
|
115
116
|
import { runningClaude, scanClaudeSessions } from './sessions.js';
|
|
117
|
+
import { builderAllowedTools, builderPrompt } from './builder.js';
|
|
116
118
|
import {
|
|
117
119
|
bugFromRef,
|
|
118
120
|
extractRefs,
|
|
@@ -148,6 +150,8 @@ const ANSI = /\x1b(\[[0-9;?<>=]*[ -\/]*[@-~]|\][^\x07\x1b]*(\x07|\x1b\\)|[()][0-
|
|
|
148
150
|
// When a link is found from several sources, the higher-ranked source is kept.
|
|
149
151
|
const SOURCE_RANK = { manual: 5, intent: 4, session: 3, branch: 2, mentioned: 1 };
|
|
150
152
|
const BUSY_IDLE_MS = 3000;
|
|
153
|
+
// The attention label engines give a tool permission menu (what rules can answer).
|
|
154
|
+
const PERMISSION = 'Permission prompt';
|
|
151
155
|
/** Looser stuck thresholds: candidates the judge looks at before anyone is bothered. */
|
|
152
156
|
const loosen = (o) => ({
|
|
153
157
|
repeatFailures: Math.max(2, Math.ceil(o.repeatFailures / 2)),
|
|
@@ -281,6 +285,7 @@ export class AgentManager extends EventEmitter {
|
|
|
281
285
|
this.stuck = new Map(); // agent id -> [finding]
|
|
282
286
|
this.outbox = new Map(); // agent id -> [message] waiting to be typed into its session
|
|
283
287
|
// The AI judge: rules decide when to look, a model decides what is going on (see judge.js).
|
|
288
|
+
this.rules = new Rules({ suggestAfter: config.rules?.suggestAfter });
|
|
284
289
|
this.judge = new Judge({ config, call: (agent, prompt, { timeoutMs }) => this.oneShot(agent, prompt, { ...this.judgeModel(agent), timeoutMs }) });
|
|
285
290
|
this.stuckVerdicts = new Map(); // agent id -> Map(finding key -> { at, since, data })
|
|
286
291
|
this.signIns = new Map(); // agent id -> { url, port, callback, terminal, at, finished } from a CLI's sign-in link
|
|
@@ -372,7 +377,7 @@ export class AgentManager extends EventEmitter {
|
|
|
372
377
|
shell: this.shellNumbers(agent.id).length > 0,
|
|
373
378
|
shells: this.shellNumbers(agent.id),
|
|
374
379
|
lastOutputAt: rt?.lastOutputAt ? new Date(rt.lastOutputAt).toISOString() : null,
|
|
375
|
-
prompt: rt?.pty && !rt.busy && rt.attention ?
|
|
380
|
+
prompt: rt?.pty && !rt.busy && rt.attention ? this.promptView(agent, rt) : null,
|
|
376
381
|
},
|
|
377
382
|
};
|
|
378
383
|
}
|
|
@@ -596,6 +601,7 @@ export class AgentManager extends EventEmitter {
|
|
|
596
601
|
trigger,
|
|
597
602
|
attachments,
|
|
598
603
|
isolation,
|
|
604
|
+
builds,
|
|
599
605
|
}) {
|
|
600
606
|
intent = (intent ?? '').trim();
|
|
601
607
|
const staged = stagedFiles(attachments);
|
|
@@ -648,6 +654,8 @@ export class AgentManager extends EventEmitter {
|
|
|
648
654
|
...(startedBy ? { startedBy } : {}),
|
|
649
655
|
...(trigger ? { trigger } : {}),
|
|
650
656
|
...(isolation ? { isolation } : {}),
|
|
657
|
+
// A session that develops an agent built here (builder.js): { name, engine }.
|
|
658
|
+
...(builds ? { builds: { name: String(builds.name), engine: String(builds.engine ?? 'claude') } } : {}),
|
|
651
659
|
name: name?.trim() || jiraKey || slug,
|
|
652
660
|
slug,
|
|
653
661
|
intent,
|
|
@@ -664,7 +672,7 @@ export class AgentManager extends EventEmitter {
|
|
|
664
672
|
model: model || null,
|
|
665
673
|
// Review the implementation plan before the agent implements it: null = no review.
|
|
666
674
|
// Team members get their review from the team instead.
|
|
667
|
-
planReview: !general && !team && (planReview ?? this.config.planReview ?? false) ? { status: 'writing' } : null,
|
|
675
|
+
planReview: !general && !team && !builds && (planReview ?? this.config.planReview ?? false) ? { status: 'writing' } : null,
|
|
668
676
|
status: 'preparing',
|
|
669
677
|
desired: start ? 'running' : 'stopped',
|
|
670
678
|
error: null,
|
|
@@ -1042,7 +1050,8 @@ export class AgentManager extends EventEmitter {
|
|
|
1042
1050
|
if (this.config.inbox !== false) prompts.push(questionsPrompt(path.join(dir, path.basename(questionsPath(agent.id)))));
|
|
1043
1051
|
}
|
|
1044
1052
|
// SPEC.md before changing code, and PLAN.md kept as it goes (unless the plan review asks for the plan).
|
|
1045
|
-
|
|
1053
|
+
// A session building an agent keeps its analysis in the agent's folder instead.
|
|
1054
|
+
if (task && this.config.specs !== false && !agent.builds) {
|
|
1046
1055
|
fs.mkdirSync(briefDir(agent.id), { recursive: true });
|
|
1047
1056
|
const dir = fs.realpathSync(briefDir(agent.id));
|
|
1048
1057
|
writableDir ??= dir;
|
|
@@ -1061,6 +1070,14 @@ export class AgentManager extends EventEmitter {
|
|
|
1061
1070
|
prompts.push(memoryPrompt(memoryText(memoryKey(agent))));
|
|
1062
1071
|
allowedTools = [`Bash(${learnCommand()}:*)`];
|
|
1063
1072
|
}
|
|
1073
|
+
// Commands you chose to always allow in this repo.
|
|
1074
|
+
const ruled = agent.workdir ? this.rules.allowed(memoryKey(agent)) : [];
|
|
1075
|
+
if (ruled.length) allowedTools = [...(allowedTools ?? []), ...ruled];
|
|
1076
|
+
if (agent.builds && agent.workdir) {
|
|
1077
|
+
prompts.push(builderPrompt({ dir: agent.workdir, name: agent.builds.name, engine: agent.builds.engine }));
|
|
1078
|
+
// Checking the agent loads and reading the reference need no permission prompt.
|
|
1079
|
+
allowedTools = [...(allowedTools ?? []), ...builderAllowedTools(agent.workdir)];
|
|
1080
|
+
}
|
|
1064
1081
|
if (agent.kind === 'assistant') {
|
|
1065
1082
|
fs.mkdirSync(assistantDir(), { recursive: true });
|
|
1066
1083
|
const dir = fs.realpathSync(assistantDir());
|
|
@@ -1153,6 +1170,14 @@ export class AgentManager extends EventEmitter {
|
|
|
1153
1170
|
this.runtimes.set(agent.id, rt);
|
|
1154
1171
|
|
|
1155
1172
|
proc.onData((data) => {
|
|
1173
|
+
// Newer Claude Code blinks a ⏺ beside a pending tool call (while its permission prompt is
|
|
1174
|
+
// up): drawn, not work. It goes to the terminal but doesn't keep the agent busy or fill the
|
|
1175
|
+
// screen text the prompt is read from.
|
|
1176
|
+
if (!data.replace(ANSI, '').replace(/[\s⏺]/g, '')) {
|
|
1177
|
+
this.appendOutput(rt, data);
|
|
1178
|
+
this.broadcast(rt, { t: 'o', d: data });
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1156
1181
|
rt.linkScan = true;
|
|
1157
1182
|
rt.lastOutputAt = Date.now();
|
|
1158
1183
|
rt.tail = (rt.tail + data).slice(-4000);
|
|
@@ -2848,16 +2873,66 @@ export class AgentManager extends EventEmitter {
|
|
|
2848
2873
|
items.push({ ...base, kind: 'conflict', key: `conflict:${agent.id}:${g.agent.id}`, title: `Overlaps with ${g.agent.name}`, conflict: g });
|
|
2849
2874
|
}
|
|
2850
2875
|
}
|
|
2851
|
-
|
|
2876
|
+
// Commands you keep allowing in a repo: offer to always allow them there.
|
|
2877
|
+
for (const r of this.rules.view().suggestions) items.push({ kind: 'rule', key: `rule:${r.repo}:${r.rule}`, title: `Always allow ${r.label}?`, priority: 2, ...r });
|
|
2878
|
+
const order = { prompt: 0, waiting: 1, question: 1, rule: 2, review: 2, stuck: 3, conflict: 4 };
|
|
2852
2879
|
return items.sort((a, b) => order[a.kind] - order[b.kind] || a.priority - b.priority);
|
|
2853
2880
|
}
|
|
2854
2881
|
|
|
2882
|
+
/** A live prompt's question and choices, and the tool call it asks about when the transcript says. */
|
|
2883
|
+
promptView(agent, rt) {
|
|
2884
|
+
const p = engineOf(agent).promptChoices(rt.tail);
|
|
2885
|
+
const call = rt.attention === PERMISSION ? this.pendingCall(agent) : null;
|
|
2886
|
+
return call ? { ...p, tool: describeTool(call) } : p;
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
/** The one tool call a live prompt is waiting on, from the transcript (Claude Code); null when unsure. */
|
|
2890
|
+
pendingCall(agent) {
|
|
2891
|
+
const engine = engineOf(agent);
|
|
2892
|
+
if (engine.id !== 'claude' || !agent.workdir || !agent.sessionId) return null;
|
|
2893
|
+
const key = `${agent.sessionId}:${agent.workdir}`;
|
|
2894
|
+
const state = this.activity.get(key) ?? {};
|
|
2895
|
+
this.activity.set(key, engine.activity(engine.sessionFile(agent.workdir, agent.sessionId), state));
|
|
2896
|
+
return state.calls?.size === 1 ? [...state.calls.values()][0] : null;
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
/** A permission prompt for a call one of the repo's allowed rules covers is answered yes by itself. */
|
|
2900
|
+
autoAllow(agent, rt) {
|
|
2901
|
+
if (this.config.rules?.auto === false) return false;
|
|
2902
|
+
const call = this.pendingCall(agent);
|
|
2903
|
+
const repo = call ? memoryKey(agent) : null;
|
|
2904
|
+
const rule = call && this.rules.allows(repo, call);
|
|
2905
|
+
if (!rule) return false;
|
|
2906
|
+
const first = engineOf(agent).promptChoices(rt.tail)?.choices?.[0];
|
|
2907
|
+
if (first?.key !== '1' || !/^yes\b/i.test(first.label)) return false;
|
|
2908
|
+
this.write(agent.id, '1');
|
|
2909
|
+
rt.attention = null;
|
|
2910
|
+
this.rules.used(repo, rule);
|
|
2911
|
+
this.plugins.emit('agent.prompt.allowed', { agent: this.auditView(agent), rule, tool: describeTool(call) });
|
|
2912
|
+
return true;
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
/** After a rule is allowed: prompts already waiting on a call it covers are answered too. */
|
|
2916
|
+
applyRules() {
|
|
2917
|
+
for (const [id, rt] of this.runtimes) {
|
|
2918
|
+
const agent = this.store.get(id);
|
|
2919
|
+
if (agent && rt.pty && !rt.busy && rt.attention === PERMISSION && this.autoAllow(agent, rt)) this.changed(agent);
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2855
2923
|
/** Send keys to a live prompt (e.g. "1" to pick the first option). */
|
|
2856
2924
|
answerPrompt(ref, key) {
|
|
2857
2925
|
const agent = this.mustGet(ref);
|
|
2858
2926
|
const keys = { esc: '\x1b', enter: '\r', up: '\x1b[A', down: '\x1b[B' };
|
|
2859
2927
|
const data = keys[key] ?? (/^\d$/.test(key) ? key : null);
|
|
2860
2928
|
if (!data) throw Object.assign(new Error('Unsupported key'), { status: 400 });
|
|
2929
|
+
// A yes to a permission prompt counts toward offering a rule for it in this repo.
|
|
2930
|
+
const rt = this.runtimes.get(agent.id);
|
|
2931
|
+
if (rt?.pty && !rt.busy && rt.attention === PERMISSION) {
|
|
2932
|
+
const choice = engineOf(agent).promptChoices(rt.tail)?.choices?.find((c) => c.key === key);
|
|
2933
|
+
const call = /^yes\b/i.test(choice?.label ?? '') ? this.pendingCall(agent) : null;
|
|
2934
|
+
if (call) this.rules.approved(memoryKey(agent), call);
|
|
2935
|
+
}
|
|
2861
2936
|
// Menus that read a line (the ACP client's) take Enter after the choice.
|
|
2862
2937
|
this.write(agent.id, engineOf(agent).promptKeyEnter && key !== 'enter' ? `${data}\r` : data);
|
|
2863
2938
|
return { ok: true };
|
|
@@ -2967,6 +3042,7 @@ export class AgentManager extends EventEmitter {
|
|
|
2967
3042
|
const screen = rt.tail.slice(-1500).replace(ANSI, '').replace(/\s+/g, '');
|
|
2968
3043
|
const a0 = this.store.get(id);
|
|
2969
3044
|
rt.attention = (a0 ? engineOf(a0).attention : []).find(([re]) => re.test(screen))?.[1] ?? null;
|
|
3045
|
+
if (a0 && rt.attention === PERMISSION) this.autoAllow(a0, rt);
|
|
2970
3046
|
const a = this.store.get(id);
|
|
2971
3047
|
if (a) this.changed(a);
|
|
2972
3048
|
// A turn just ended: remember the code and conversation at this point.
|
package/src/rules.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Approval rules learned from your answers: when you keep saying yes to the same command in a repo
|
|
2
|
+
// ("npm test", three times), the inbox offers to always allow it there. Allowed rules go to every
|
|
3
|
+
// agent in that repo at launch (Claude Code's --allowedTools) and answer its live prompts, so the
|
|
4
|
+
// same question isn't asked again. Kept in ~/.agentmanager/rules.json, per repo (the repo-memory key).
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { paths } from './config.js';
|
|
9
|
+
|
|
10
|
+
export const SUGGEST_AFTER = 3;
|
|
11
|
+
// Commands whose first words name a subcommand: "npm test" is a rule, "npm" alone would allow anything npm does.
|
|
12
|
+
const SUBCOMMANDS = new Set(['npm', 'pnpm', 'yarn', 'bun', 'npx', 'node', 'deno', 'git', 'gh', 'cargo', 'go', 'make', 'python', 'python3', 'uv', 'pip', 'poetry', 'pytest', 'mvn', 'gradle', './gradlew', 'dotnet', 'swift', 'xcodebuild', 'bundle', 'rake', 'rails', 'mix', 'composer', 'php', 'docker', 'kubectl', 'terraform', 'just', 'task']);
|
|
13
|
+
// Never offered as a standing rule, however often approved: they delete, escalate, publish or reach out.
|
|
14
|
+
const NEVER = /^(rm|sudo|su|chmod|chown|dd|mkfs|kill|killall|pkill|shutdown|reboot|curl|wget|ssh|scp|rsync|eval|exec|sh|bash|zsh|env|xargs|find|git (push|reset|clean|rebase|checkout|branch|tag)|gh (pr merge|release|repo|api|secret)|npm (publish|unpublish|login|adduser|token)|docker (rm|rmi|system|push|login)|kubectl (delete|apply)|terraform (apply|destroy))$/;
|
|
15
|
+
// Anything that chains, redirects or substitutes isn't one command, so no rule covers it.
|
|
16
|
+
const COMPOUND = /[;&|<>`$(){}\n\\]/;
|
|
17
|
+
|
|
18
|
+
/** The words of a plain command, or null when it chains, redirects, substitutes or sets variables. */
|
|
19
|
+
function words(command) {
|
|
20
|
+
const cmd = String(command ?? '').trim();
|
|
21
|
+
if (!cmd || COMPOUND.test(cmd)) return null;
|
|
22
|
+
const w = cmd.split(/\s+/);
|
|
23
|
+
return /^\w+=/.test(w[0]) ? null : w;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The rule that would allow a tool call, as Claude Code writes it, with a label for people:
|
|
28
|
+
* Bash → "Bash(npm test:*)", WebFetch → "WebFetch(domain:docs.x.com)", an MCP tool → its name.
|
|
29
|
+
* Null for calls that shouldn't become a standing rule (file edits: use Accept edits instead).
|
|
30
|
+
*/
|
|
31
|
+
export function ruleFor(tool) {
|
|
32
|
+
const input = tool?.input ?? {};
|
|
33
|
+
if (tool?.name === 'Bash') {
|
|
34
|
+
const w = words(input.command);
|
|
35
|
+
if (!w) return null;
|
|
36
|
+
const prefix = (SUBCOMMANDS.has(w[0]) && w[1] && !w[1].startsWith('-') ? w.slice(0, 2) : w.slice(0, 1)).join(' ');
|
|
37
|
+
if (NEVER.test(prefix) || NEVER.test(w[0])) return null;
|
|
38
|
+
return { rule: `Bash(${prefix}:*)`, label: prefix };
|
|
39
|
+
}
|
|
40
|
+
if (tool?.name === 'WebFetch') {
|
|
41
|
+
try {
|
|
42
|
+
const host = new URL(input.url).hostname;
|
|
43
|
+
return host ? { rule: `WebFetch(domain:${host})`, label: `fetch from ${host}` } : null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (/^mcp__[\w-]+__[\w-]+$/.test(tool?.name ?? '')) return { rule: tool.name, label: tool.name.replace(/^mcp__/, '').replace('__', ': ') };
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether an allowed rule covers this tool call. */
|
|
53
|
+
export function covers(rule, tool) {
|
|
54
|
+
const m = /^Bash\((.+):\*\)$/.exec(rule);
|
|
55
|
+
if (m) {
|
|
56
|
+
const w = words(tool?.input?.command);
|
|
57
|
+
return tool?.name === 'Bash' && Boolean(w) && (w.join(' ') === m[1] || w.join(' ').startsWith(`${m[1]} `));
|
|
58
|
+
}
|
|
59
|
+
return ruleFor(tool)?.rule === rule;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What a pending tool call is, in a few words for an inbox card. */
|
|
63
|
+
export function describeTool(tool) {
|
|
64
|
+
const input = tool?.input ?? {};
|
|
65
|
+
if (tool?.name === 'Bash') return { name: 'Bash', text: String(input.command ?? '').slice(0, 300) };
|
|
66
|
+
if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Read'].includes(tool?.name)) return { name: tool.name, text: String(input.file_path ?? input.notebook_path ?? '') };
|
|
67
|
+
if (tool?.name === 'WebFetch') return { name: 'WebFetch', text: String(input.url ?? '') };
|
|
68
|
+
return { name: String(tool?.name ?? ''), text: JSON.stringify(input).slice(0, 300) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class Rules extends EventEmitter {
|
|
72
|
+
constructor({ file = path.join(paths.home, 'rules.json'), suggestAfter = SUGGEST_AFTER } = {}) {
|
|
73
|
+
super();
|
|
74
|
+
this.file = file;
|
|
75
|
+
this.suggestAfter = suggestAfter;
|
|
76
|
+
try {
|
|
77
|
+
this.data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
78
|
+
} catch {
|
|
79
|
+
this.data = {};
|
|
80
|
+
}
|
|
81
|
+
this.data.repos ??= {};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
save() {
|
|
85
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
86
|
+
fs.writeFileSync(this.file, JSON.stringify(this.data, null, 2));
|
|
87
|
+
this.emit('change', this.view());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
repo(key) {
|
|
91
|
+
return (this.data.repos[key] ??= { allow: [], dismissed: [], seen: {} });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The rules allowed in a repo, as Claude Code allow rules. */
|
|
95
|
+
allowed(key) {
|
|
96
|
+
return (this.data.repos[key]?.allow ?? []).map((r) => r.rule);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The allowed rule that covers this call in this repo, if any. */
|
|
100
|
+
allows(key, tool) {
|
|
101
|
+
return this.allowed(key).find((r) => covers(r, tool)) ?? null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** An allowed rule answered a prompt by itself: count it, so the list shows what it saves. */
|
|
105
|
+
used(key, rule) {
|
|
106
|
+
const a = this.data.repos[key]?.allow.find((x) => x.rule === rule);
|
|
107
|
+
if (!a) return;
|
|
108
|
+
a.uses = (a.uses ?? 0) + 1;
|
|
109
|
+
a.lastUsedAt = new Date().toISOString();
|
|
110
|
+
this.save();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** You said yes to this call in this repo; count it toward a suggestion. */
|
|
114
|
+
approved(key, tool) {
|
|
115
|
+
const r = ruleFor(tool);
|
|
116
|
+
if (!r) return null;
|
|
117
|
+
const repo = this.repo(key);
|
|
118
|
+
if (repo.allow.some((x) => x.rule === r.rule)) return null;
|
|
119
|
+
const seen = (repo.seen[r.rule] ??= { label: r.label, n: 0 });
|
|
120
|
+
seen.n++;
|
|
121
|
+
seen.at = new Date().toISOString();
|
|
122
|
+
this.save();
|
|
123
|
+
return { ...r, n: seen.n };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
accept(key, rule) {
|
|
127
|
+
const repo = this.data.repos[key];
|
|
128
|
+
const seen = repo?.seen[rule];
|
|
129
|
+
if (!seen && !repo?.allow.some((x) => x.rule === rule)) throw Object.assign(new Error('No such suggestion'), { status: 404 });
|
|
130
|
+
if (!repo.allow.some((x) => x.rule === rule)) repo.allow.push({ rule, label: seen.label, at: new Date().toISOString() });
|
|
131
|
+
delete repo.seen[rule];
|
|
132
|
+
this.save();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** "Not now": stop suggesting it (it can still be allowed later from the list). */
|
|
136
|
+
dismiss(key, rule) {
|
|
137
|
+
const repo = this.data.repos[key];
|
|
138
|
+
if (!repo?.seen[rule]) throw Object.assign(new Error('No such suggestion'), { status: 404 });
|
|
139
|
+
if (!repo.dismissed.includes(rule)) repo.dismissed.push(rule);
|
|
140
|
+
this.save();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
remove(key, rule) {
|
|
144
|
+
const repo = this.data.repos[key];
|
|
145
|
+
if (!repo?.allow.some((x) => x.rule === rule)) throw Object.assign(new Error('No such rule'), { status: 404 });
|
|
146
|
+
repo.allow = repo.allow.filter((x) => x.rule !== rule);
|
|
147
|
+
repo.dismissed = repo.dismissed.filter((x) => x !== rule);
|
|
148
|
+
this.save();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Every repo's allowed rules and the suggestions waiting for an answer. */
|
|
152
|
+
view() {
|
|
153
|
+
const allowed = [];
|
|
154
|
+
const suggestions = [];
|
|
155
|
+
for (const [repo, r] of Object.entries(this.data.repos)) {
|
|
156
|
+
for (const a of r.allow) allowed.push({ repo, ...a });
|
|
157
|
+
for (const [rule, s] of Object.entries(r.seen)) if (s.n >= this.suggestAfter && !r.dismissed.includes(rule)) suggestions.push({ repo, rule, label: s.label, n: s.n, at: s.at });
|
|
158
|
+
}
|
|
159
|
+
return { allowed, suggestions, suggestAfter: this.suggestAfter };
|
|
160
|
+
}
|
|
161
|
+
}
|
package/src/server.js
CHANGED
|
@@ -448,6 +448,16 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
448
448
|
);
|
|
449
449
|
// Everything waiting on the user: live prompts, agents' questions, stuck agents, overlaps.
|
|
450
450
|
api.get('/inbox', wrap(() => manager.inbox()));
|
|
451
|
+
// Approval rules learned from your answers (src/rules.js): allowed per repo, and suggestions.
|
|
452
|
+
const ruleArgs = (req) => {
|
|
453
|
+
const { repo, rule } = req.body ?? {};
|
|
454
|
+
if (typeof repo !== 'string' || typeof rule !== 'string') throw Object.assign(new Error('repo and rule are required'), { status: 400 });
|
|
455
|
+
return [repo, rule];
|
|
456
|
+
};
|
|
457
|
+
api.get('/rules', wrap(() => manager.rules.view()));
|
|
458
|
+
api.post('/rules/accept', wrap((req) => (manager.rules.accept(...ruleArgs(req)), manager.applyRules(), manager.rules.view())));
|
|
459
|
+
api.post('/rules/dismiss', wrap((req) => (manager.rules.dismiss(...ruleArgs(req)), manager.rules.view())));
|
|
460
|
+
api.post('/rules/remove', wrap((req) => (manager.rules.remove(...ruleArgs(req)), manager.rules.view())));
|
|
451
461
|
api.post('/agents/:id/tell', wrap((req) => manager.tell(req.params.id, req.body?.text)));
|
|
452
462
|
api.post('/agents/:id/answer', wrap((req) => manager.answerQuestion(req.params.id, req.body?.question, req.body?.text)));
|
|
453
463
|
api.post('/agents/:id/prompt-key', wrap((req) => manager.answerPrompt(req.params.id, String(req.body?.key ?? ''))));
|
|
@@ -525,9 +535,19 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
525
535
|
api.get('/agents/:id/memory', wrap((req) => memory.read(req.params.id)));
|
|
526
536
|
api.put('/agents/:id/memory', wrap((req) => memory.write(req.params.id, req.body?.text)));
|
|
527
537
|
api.post('/agents/:id/learn', wrap((req) => memory.learn(req.params.id, req.body?.text, { by: req.headers['x-agentmgr-agent'] ? undefined : 'you' })));
|
|
528
|
-
// The agent builder (agentkit agents
|
|
529
|
-
//
|
|
530
|
-
|
|
538
|
+
// The agent builder (agentkit agents built from a task, each developed in an agent session
|
|
539
|
+
// working in its folder): build, change, test, run, package, download. Agents started here
|
|
540
|
+
// report to this daemon (runs, cost, approvals).
|
|
541
|
+
const sessions = {
|
|
542
|
+
create: (opts) => manager.create({ ...opts, worktree: false }),
|
|
543
|
+
get: (id) => {
|
|
544
|
+
const a = manager.get(id);
|
|
545
|
+
return a ? manager.view(a) : null;
|
|
546
|
+
},
|
|
547
|
+
tell: (id, text) => manager.tell(id, text),
|
|
548
|
+
stop: (id) => manager.stop(id).catch(() => {}),
|
|
549
|
+
};
|
|
550
|
+
const builder = new Builder({ sessions, oneShot: (prompt) => manager.oneShot(null, prompt, { ...manager.writerFor({}), timeoutMs: 300_000 }), reportEnv: () => ({ AGENTKIT_REPORT_URL: `http://127.0.0.1:${port}`, AGENTKIT_REPORT_TOKEN: reports.reportToken() }) });
|
|
531
551
|
api.post('/agentkit/report', wrap((req) => reports.report(req.body ?? {})));
|
|
532
552
|
api.get('/agentkit/agents', wrap(() => reports.list()));
|
|
533
553
|
api.post('/agentkit/agents/:key/approvals/:id', wrap((req) => reports.decide(req.params.key, req.params.id, req.body?.allow === true, req.device ? `${req.device.name ?? 'a signed-in device'}` : 'you')));
|
|
@@ -535,7 +555,8 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
535
555
|
// What an agent elsewhere needs to report here (this computer only: it shows the token).
|
|
536
556
|
api.get('/agentkit/connect', wrap(() => ({ token: reports.reportToken(), local: `http://127.0.0.1:${port}`, remote: remoteServer ? remote.remoteUrls(remotePort).map((u) => u.url) : [], cert: remoteServer ? remote.certificateFingerprint() : null })));
|
|
537
557
|
api.get('/builder', wrap(() => builder.list()));
|
|
538
|
-
api.post('/builder', wrap((req) => builder.design({ task: req.body?.task, engine: req.body?.engine || 'claude', attachments: req.body?.attachments
|
|
558
|
+
api.post('/builder', wrap((req) => builder.design({ task: req.body?.task, engine: req.body?.engine || 'claude', name: req.body?.name || undefined, attachments: req.body?.attachments, permissionMode: req.body?.permissionMode || undefined, model: req.body?.model || undefined })));
|
|
559
|
+
api.post('/builder/:name/change', wrap((req) => builder.change(req.params.name, req.body?.text)));
|
|
539
560
|
api.get('/builder/:name', wrap((req) => builder.view(req.params.name)));
|
|
540
561
|
api.put('/builder/:name', wrap((req) => builder.update(req.params.name, req.body ?? {})));
|
|
541
562
|
api.post('/builder/:name/test', wrap((req) => {
|
|
@@ -901,6 +922,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
901
922
|
};
|
|
902
923
|
manager.on('change', (agent) => broadcast({ t: 'agent', agent }));
|
|
903
924
|
reports.on('change', () => broadcast({ t: 'built', reports: reports.list() }));
|
|
925
|
+
manager.rules.on('change', (rules) => broadcast({ t: 'rules', rules }));
|
|
904
926
|
manager.on('removed', (id) => {
|
|
905
927
|
broadcast({ t: 'removed', id });
|
|
906
928
|
live.closeAgent(id).catch(() => {});
|
|
@@ -937,7 +959,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
937
959
|
const url = new URL(req.url, 'http://x');
|
|
938
960
|
if (url.pathname === '/ws/events') {
|
|
939
961
|
eventClients.add(ws);
|
|
940
|
-
ws.send(JSON.stringify({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list(), teams: teams.list(), built: reports.list() }));
|
|
962
|
+
ws.send(JSON.stringify({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list(), teams: teams.list(), built: reports.list(), rules: manager.rules.view() }));
|
|
941
963
|
ws.on('close', () => eventClients.delete(ws));
|
|
942
964
|
return;
|
|
943
965
|
}
|
package/src/stuck.js
CHANGED
|
@@ -20,12 +20,14 @@ const textOf = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map((b)
|
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Incrementally read new transcript lines into `state.events` (tool calls with their results).
|
|
23
|
-
* state: { offset, events: [{ t, kind: 'cmd'|'edit'|'other', ... }], pending: Map(id -> event)
|
|
23
|
+
* state: { offset, events: [{ t, kind: 'cmd'|'edit'|'other', ... }], pending: Map(id -> event),
|
|
24
|
+
* calls: Map(id -> { name, input }) for the tool calls still waiting for a result (what a prompt asks about) }.
|
|
24
25
|
*/
|
|
25
26
|
export function scanActivity(file, state) {
|
|
26
27
|
state.offset ??= 0;
|
|
27
28
|
state.events ??= [];
|
|
28
29
|
state.pending ??= new Map();
|
|
30
|
+
state.calls ??= new Map();
|
|
29
31
|
let fd;
|
|
30
32
|
try {
|
|
31
33
|
fd = fs.openSync(file, 'r');
|
|
@@ -34,7 +36,7 @@ export function scanActivity(file, state) {
|
|
|
34
36
|
}
|
|
35
37
|
try {
|
|
36
38
|
const { size } = fs.fstatSync(fd);
|
|
37
|
-
if (size < state.offset) Object.assign(state, { offset: 0, events: [], pending: new Map() });
|
|
39
|
+
if (size < state.offset) Object.assign(state, { offset: 0, events: [], pending: new Map(), calls: new Map() });
|
|
38
40
|
if (size === state.offset) return state;
|
|
39
41
|
const buf = Buffer.alloc(size - state.offset);
|
|
40
42
|
fs.readSync(fd, buf, 0, buf.length, state.offset);
|
|
@@ -56,7 +58,12 @@ export function scanActivity(file, state) {
|
|
|
56
58
|
const ev = eventFor(b, t);
|
|
57
59
|
state.events.push(ev);
|
|
58
60
|
state.pending.set(b.id, ev);
|
|
61
|
+
// Calls left without a result by an earlier response (interrupted) aren't what's asked now.
|
|
62
|
+
if (rec.message?.id && rec.message.id !== state.callsMsg) state.calls.clear();
|
|
63
|
+
state.callsMsg = rec.message?.id;
|
|
64
|
+
state.calls.set(b.id, { name: b.name, input: b.input ?? {} });
|
|
59
65
|
} else if (rec.type === 'user' && b.type === 'tool_result') {
|
|
66
|
+
state.calls.delete(b.tool_use_id);
|
|
60
67
|
const ev = state.pending.get(b.tool_use_id);
|
|
61
68
|
if (!ev) continue;
|
|
62
69
|
state.pending.delete(b.tool_use_id);
|