ai-agentmanager 1.4.0 → 1.6.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 +17 -0
- package/README.md +12 -8
- package/docs/engines.md +2 -1
- package/package.json +1 -1
- package/public/app.js +110 -16
- package/public/attach.js +100 -0
- package/public/hub/hub.js +1 -1
- package/public/index.html +28 -7
- package/public/machine.js +8 -2
- package/public/menu.js +11 -0
- package/public/setup.js +41 -14
- package/public/style.css +30 -2
- package/public/transport.js +19 -3
- package/src/backlog.js +4 -1
- package/src/brief.js +144 -0
- package/src/cli.js +44 -6
- package/src/engines/index.js +10 -2
- package/src/engines/muse.js +303 -0
- package/src/manager.js +171 -17
- package/src/review.js +3 -0
- package/src/server.js +33 -3
package/src/manager.js
CHANGED
|
@@ -8,6 +8,12 @@ import { expandHome, paths } from './config.js';
|
|
|
8
8
|
import { DEFAULT_ENGINE, ENGINES, engineOf, installed, notInstalled } from './engines/index.js';
|
|
9
9
|
import {
|
|
10
10
|
DOC_KINDS,
|
|
11
|
+
diagramsOnly,
|
|
12
|
+
diagramsPrompt,
|
|
13
|
+
walkthroughFrom,
|
|
14
|
+
walkthroughHunks,
|
|
15
|
+
walkthroughMarkdown,
|
|
16
|
+
walkthroughPrompt,
|
|
11
17
|
briefDir,
|
|
12
18
|
briefPath,
|
|
13
19
|
briefPrompt,
|
|
@@ -33,13 +39,60 @@ const SUMMARIES = {
|
|
|
33
39
|
insights: { field: 'insights', prompt: insightsPrompt },
|
|
34
40
|
...Object.fromEntries(Object.keys(DOC_KINDS).map((k) => [k, { doc: k, prompt: (ctx) => docPrompt(k, ctx) }])),
|
|
35
41
|
humanai: { doc: 'humanai', run: 'runHumanAi' },
|
|
42
|
+
diagrams: { doc: 'diagrams', run: 'runDiagrams' },
|
|
43
|
+
walkthrough: { doc: 'walkthrough', run: 'runWalkthrough' },
|
|
36
44
|
};
|
|
37
45
|
/** Read / write where a summary of `kind` is kept on the agent record. */
|
|
38
46
|
const getSummary = (agent, kind) => (SUMMARIES[kind].doc ? agent.docs?.[kind] : agent[SUMMARIES[kind].field]);
|
|
39
47
|
const summaryPatch = (agent, kind, value) =>
|
|
40
48
|
SUMMARIES[kind].doc ? { docs: { ...agent.docs, [kind]: value } } : { [SUMMARIES[kind].field]: value };
|
|
49
|
+
|
|
50
|
+
const stagedDir = () => path.join(paths.uploads, 'staged');
|
|
51
|
+
const safeName = (name) => path.basename(String(name || 'file')).replace(/[^\w.@+-]+/g, '_').slice(-120) || 'file';
|
|
52
|
+
|
|
53
|
+
/** Staged uploads named in a request, checked to be files in the staging folder. */
|
|
54
|
+
function stagedFiles(list) {
|
|
55
|
+
if (list == null) return [];
|
|
56
|
+
if (!Array.isArray(list)) throw Object.assign(new Error('attachments must be a list of uploaded files'), { status: 400 });
|
|
57
|
+
const root = path.resolve(stagedDir()) + path.sep;
|
|
58
|
+
return list.slice(0, 20).map((p) => {
|
|
59
|
+
const file = path.resolve(String(p ?? ''));
|
|
60
|
+
if (!file.startsWith(root) || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
61
|
+
throw Object.assign(new Error(`Not an uploaded file: ${path.basename(file)} (attach it again)`), { status: 400 });
|
|
62
|
+
}
|
|
63
|
+
return file;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Copy staged files into an agent's uploads folder (a copy each: one upload can serve several agents). */
|
|
68
|
+
function copyAttachments(files, agentId) {
|
|
69
|
+
const dir = path.join(paths.uploads, agentId);
|
|
70
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
71
|
+
return files.map((f) => {
|
|
72
|
+
// Drop the staging prefix (time and a random tag) to get the name it was attached with.
|
|
73
|
+
const dest = path.join(dir, path.basename(f).replace(/^\d{4}-\d\d-\d\dT[\d-]+Z-[0-9a-f]{6}-/, ''));
|
|
74
|
+
fs.copyFileSync(f, dest);
|
|
75
|
+
return dest;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The part of the request that lists attached files. */
|
|
80
|
+
export const attachmentNote = (files) => `Attached files (part of this request; read them):\n${files.map((f) => `- ${f}`).join('\n')}`;
|
|
81
|
+
|
|
82
|
+
/** The folders of a repo with how many files each holds, two levels deep: a map for drawing it. */
|
|
83
|
+
async function repoLayout(dir) {
|
|
84
|
+
const files = (await git.run('git', ['ls-files'], { cwd: dir, timeout: 20_000 })).split('\n').filter(Boolean);
|
|
85
|
+
const counts = new Map();
|
|
86
|
+
for (const f of files) {
|
|
87
|
+
const parts = f.split('/');
|
|
88
|
+
const key = parts.length > 2 ? `${parts[0]}/${parts[1]}/` : parts.length === 2 ? `${parts[0]}/` : f;
|
|
89
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
90
|
+
}
|
|
91
|
+
return [...counts].slice(0, 150).map(([k, n]) => (k.endsWith('/') ? `${k} (${n} files)` : k)).join('\n');
|
|
92
|
+
}
|
|
41
93
|
import { spawn } from 'node:child_process';
|
|
42
94
|
import * as git from './git.js';
|
|
95
|
+
import { parseDiff } from '../public/review.js';
|
|
43
96
|
import { conflictMessage, findConflicts, related, repoKey, summarize as summarizeConflicts } from './conflicts.js';
|
|
44
97
|
import { answerMessage, loadQuestions, questionsPath, questionsPrompt } from './inbox.js';
|
|
45
98
|
import { STUCK_DEFAULTS, detectStuck } from './stuck.js';
|
|
@@ -501,9 +554,9 @@ export class AgentManager extends EventEmitter {
|
|
|
501
554
|
);
|
|
502
555
|
}
|
|
503
556
|
|
|
504
|
-
/** A general
|
|
505
|
-
createGeneral({ question, name, permissionMode, model } = {}) {
|
|
506
|
-
return this.create({ kind: 'general', intent: question, name, permissionMode, model });
|
|
557
|
+
/** A general session with no repo or folder, on any engine; the question (if any) is its first prompt. */
|
|
558
|
+
createGeneral({ question, name, permissionMode, model, engine, attachments } = {}) {
|
|
559
|
+
return this.create({ kind: 'general', intent: question, name, permissionMode, model, engine, attachments });
|
|
507
560
|
}
|
|
508
561
|
|
|
509
562
|
create({
|
|
@@ -529,8 +582,10 @@ export class AgentManager extends EventEmitter {
|
|
|
529
582
|
imported,
|
|
530
583
|
startedBy,
|
|
531
584
|
trigger,
|
|
585
|
+
attachments,
|
|
532
586
|
}) {
|
|
533
587
|
intent = (intent ?? '').trim();
|
|
588
|
+
const staged = stagedFiles(attachments);
|
|
534
589
|
const engineDef = engineOf(engine || this.config.engine || DEFAULT_ENGINE);
|
|
535
590
|
const engineId = engineDef.id;
|
|
536
591
|
this.plugins.check('agent.create', {
|
|
@@ -554,7 +609,7 @@ export class AgentManager extends EventEmitter {
|
|
|
554
609
|
base ??= entry.base ?? undefined;
|
|
555
610
|
project = entry.key.startsWith('custom:') ? entry.name : null;
|
|
556
611
|
}
|
|
557
|
-
if (!intent && !general) throw Object.assign(new Error('Intent is required'), { status: 400 });
|
|
612
|
+
if (!intent && !general && !staged.length) throw Object.assign(new Error('Intent is required'), { status: 400 });
|
|
558
613
|
const id = crypto.randomBytes(3).toString('hex');
|
|
559
614
|
// An imported session keeps its ID, so the first start resumes it.
|
|
560
615
|
const sessionId = adopt || crypto.randomUUID();
|
|
@@ -562,6 +617,8 @@ export class AgentManager extends EventEmitter {
|
|
|
562
617
|
const jiraKey = !name?.trim() && intent ? leadingJiraKey(intent) : null;
|
|
563
618
|
const slug = name || intent ? slugify(name || jiraKey || intent) : `general-${id}`;
|
|
564
619
|
const refs = extractRefs(intent, this.config.bugPatterns);
|
|
620
|
+
// Attached files (a screenshot, a log) go in the agent's own uploads folder, listed in its request.
|
|
621
|
+
if (staged.length) intent = [intent, attachmentNote(copyAttachments(staged, id))].filter(Boolean).join('\n\n');
|
|
565
622
|
|
|
566
623
|
const agent = {
|
|
567
624
|
id,
|
|
@@ -847,15 +904,15 @@ export class AgentManager extends EventEmitter {
|
|
|
847
904
|
resume = true;
|
|
848
905
|
}
|
|
849
906
|
}
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
resume,
|
|
854
|
-
name: agent.name,
|
|
855
|
-
// A team member's first message is its first step, not just the problem.
|
|
856
|
-
prompt: resume ? (handoff ? handoffMessage(agent) : null) : (agent.kickoff ?? agent.intent),
|
|
857
|
-
});
|
|
907
|
+
// A team member's first message is its first step, not just the problem.
|
|
908
|
+
const prompt = resume ? (handoff ? handoffMessage(agent) : null) : (agent.kickoff ?? agent.intent);
|
|
909
|
+
const args = engine.launchArgs({ ...this.launchOptions(agent), sessionId: sid, resume, name: agent.name, prompt });
|
|
858
910
|
const proc = this.spawnAgent(agent, agent.workdir, args);
|
|
911
|
+
// Engines whose resume takes no message get it typed once the session is idle.
|
|
912
|
+
if (resume && prompt && engine.supports.resumePrompt === false) {
|
|
913
|
+
if (!this.outbox.has(agent.id)) this.outbox.set(agent.id, []);
|
|
914
|
+
this.outbox.get(agent.id).unshift({ text: prompt, source: 'system', at: now() });
|
|
915
|
+
}
|
|
859
916
|
|
|
860
917
|
const session = agent.sessions.find((s) => s.id === sid);
|
|
861
918
|
if (session) {
|
|
@@ -927,6 +984,12 @@ export class AgentManager extends EventEmitter {
|
|
|
927
984
|
}
|
|
928
985
|
}
|
|
929
986
|
// Appended system prompts last only for the process they're passed to, so they go on every launch.
|
|
987
|
+
// Files attached for it (at creation or in its terminal): readable without a permission prompt.
|
|
988
|
+
const uploads = path.join(paths.uploads, agent.id);
|
|
989
|
+
if (fs.existsSync(uploads)) {
|
|
990
|
+
const dir = fs.realpathSync(uploads);
|
|
991
|
+
writableDir = writableDir ? [writableDir, dir].flat() : dir;
|
|
992
|
+
}
|
|
930
993
|
return { extra: engine.extraArgs(this.config), permissionMode: agent.permissionMode, model: agent.model, writableDir, instructions: prompts.join('\n\n') || null, agents, allowedTools };
|
|
931
994
|
}
|
|
932
995
|
|
|
@@ -1102,7 +1165,8 @@ export class AgentManager extends EventEmitter {
|
|
|
1102
1165
|
done.dispose();
|
|
1103
1166
|
resolve(this.view(this.store.get(agent.id) ?? agent));
|
|
1104
1167
|
});
|
|
1105
|
-
|
|
1168
|
+
// A hangup, as when a terminal closes, unless the engine shuts down cleanly on another signal.
|
|
1169
|
+
rt.pty.kill(engineOf(agent).stopSignal ?? 'SIGHUP');
|
|
1106
1170
|
rt.killTimer = setTimeout(() => rt.pty?.kill('SIGKILL'), timeoutMs);
|
|
1107
1171
|
});
|
|
1108
1172
|
}
|
|
@@ -1168,7 +1232,7 @@ export class AgentManager extends EventEmitter {
|
|
|
1168
1232
|
}
|
|
1169
1233
|
|
|
1170
1234
|
childEnv(agent) {
|
|
1171
|
-
const env = { ...process.env, ...(agent ? { AGENTMGR_AGENT_ID: agent.id } : {}), TERM: 'xterm-256color', COLORTERM: 'truecolor' };
|
|
1235
|
+
const env = { ...process.env, ...(agent ? { AGENTMGR_AGENT_ID: agent.id } : {}), TERM: 'xterm-256color', COLORTERM: 'truecolor', AGENTMGR_TERMINAL: '1' };
|
|
1172
1236
|
for (const key of STRIP_ENV) delete env[key];
|
|
1173
1237
|
return env;
|
|
1174
1238
|
}
|
|
@@ -1613,9 +1677,35 @@ export class AgentManager extends EventEmitter {
|
|
|
1613
1677
|
}
|
|
1614
1678
|
|
|
1615
1679
|
/** Save a file the user attached in the UI, so its path can be handed to Claude. */
|
|
1680
|
+
/**
|
|
1681
|
+
* A request with its attachments listed, for work that starts several agents from one request
|
|
1682
|
+
* (a team, a race): the files are copied once to a shared folder they all read.
|
|
1683
|
+
*/
|
|
1684
|
+
withSharedAttachments(intent, attachments) {
|
|
1685
|
+
const staged = stagedFiles(attachments);
|
|
1686
|
+
if (!staged.length) return intent;
|
|
1687
|
+
const files = copyAttachments(staged, path.join('shared', crypto.randomBytes(4).toString('hex')));
|
|
1688
|
+
return [String(intent ?? '').trim(), attachmentNote(files)].filter(Boolean).join('\n\n');
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
/** A file attached before its agent exists (New agents, Ask); create() copies it to the agent. */
|
|
1692
|
+
stageUpload(name, data) {
|
|
1693
|
+
fs.mkdirSync(stagedDir(), { recursive: true });
|
|
1694
|
+
// Staged files nobody used are dropped after 30 days (a backlog item may wait that long).
|
|
1695
|
+
const old = Date.now() - 30 * 86_400_000;
|
|
1696
|
+
for (const f of fs.readdirSync(stagedDir())) {
|
|
1697
|
+
try {
|
|
1698
|
+
if (fs.statSync(path.join(stagedDir(), f)).mtimeMs < old) fs.rmSync(path.join(stagedDir(), f), { force: true });
|
|
1699
|
+
} catch {}
|
|
1700
|
+
}
|
|
1701
|
+
const file = path.join(stagedDir(), `${new Date().toISOString().replace(/[:.]/g, '-')}-${crypto.randomBytes(3).toString('hex')}-${safeName(name)}`);
|
|
1702
|
+
fs.writeFileSync(file, data);
|
|
1703
|
+
return { path: file, name: safeName(name), size: data.length };
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1616
1706
|
saveUpload(ref, name, data) {
|
|
1617
1707
|
const agent = this.mustGet(ref);
|
|
1618
|
-
const safe =
|
|
1708
|
+
const safe = safeName(name);
|
|
1619
1709
|
const dir = path.join(paths.uploads, agent.id);
|
|
1620
1710
|
fs.mkdirSync(dir, { recursive: true });
|
|
1621
1711
|
const file = path.join(dir, `${new Date().toISOString().replace(/[:.]/g, '-')}-${safe}`);
|
|
@@ -1926,8 +2016,10 @@ export class AgentManager extends EventEmitter {
|
|
|
1926
2016
|
|
|
1927
2017
|
/** One tool-less, read-only call to an engine; resolves to its text output. */
|
|
1928
2018
|
oneShot(agent, text, { engine, model, timeoutMs = 300_000 }) {
|
|
1929
|
-
// Some engines take the prompt as an argument instead of on stdin
|
|
1930
|
-
|
|
2019
|
+
// Some engines take the prompt as an argument instead of on stdin, and some need their extra
|
|
2020
|
+
// arguments after a subcommand (oneShotExtra: they place them).
|
|
2021
|
+
const extra = engine.extraArgs(this.config);
|
|
2022
|
+
const args = engine.oneShotExtra ? engine.oneShotArgs(this.config, { model, prompt: text, extra }) : [...extra, ...engine.oneShotArgs(this.config, { model, prompt: text })];
|
|
1931
2023
|
return new Promise((resolve, reject) => {
|
|
1932
2024
|
const child = spawn(engine.bin(this.config), args, { cwd: paths.home, env: this.childEnv(agent), stdio: ['pipe', 'pipe', 'pipe'] });
|
|
1933
2025
|
let out = '';
|
|
@@ -1945,6 +2037,68 @@ export class AgentManager extends EventEmitter {
|
|
|
1945
2037
|
});
|
|
1946
2038
|
}
|
|
1947
2039
|
|
|
2040
|
+
/**
|
|
2041
|
+
* The agent's changes explained one by one for review. `changesSource` (set by the server) gives
|
|
2042
|
+
* the same diff the review screen shows; the result is keyed by file and hunk header.
|
|
2043
|
+
*/
|
|
2044
|
+
async runWalkthrough(agent, ctx) {
|
|
2045
|
+
if (!this.changesSource) throw new Error('Changes are not available here');
|
|
2046
|
+
const data = await this.changesSource(agent.id);
|
|
2047
|
+
const { hunks, cut } = walkthroughHunks(parseDiff(data.diff));
|
|
2048
|
+
if (!hunks.length) throw Object.assign(new Error('No changes to explain yet'), { status: 400 });
|
|
2049
|
+
const writer = this.writerFor(agent);
|
|
2050
|
+
const result = walkthroughFrom(parseJsonReply(await this.oneShot(agent, walkthroughPrompt({ ...ctx, hunks }), writer)), hunks);
|
|
2051
|
+
const cur = this.store.get(agent.id);
|
|
2052
|
+
if (!cur) return null;
|
|
2053
|
+
return this.view(
|
|
2054
|
+
this.patch(
|
|
2055
|
+
agent.id,
|
|
2056
|
+
summaryPatch(cur, 'walkthrough', {
|
|
2057
|
+
data: result,
|
|
2058
|
+
cut,
|
|
2059
|
+
diffHash: hash(data.diff),
|
|
2060
|
+
markdown: walkthroughMarkdown(result, cur.name),
|
|
2061
|
+
writer: `${writer.engine.label}${writer.model ? ` (${writer.model})` : ''}`,
|
|
2062
|
+
at: now(),
|
|
2063
|
+
briefUpdatedAt: cur.brief?.updatedAt ?? null,
|
|
2064
|
+
pending: false,
|
|
2065
|
+
error: null,
|
|
2066
|
+
}),
|
|
2067
|
+
),
|
|
2068
|
+
);
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
/** Diagrams only: the problem and its solution drawn from the documents already written and the live context. */
|
|
2072
|
+
async runDiagrams(agent, ctx) {
|
|
2073
|
+
const cur0 = this.store.get(agent.id) ?? agent;
|
|
2074
|
+
const written = [
|
|
2075
|
+
...['analysis', 'design', 'flow', 'proposal'].map((k) => [DOC_KINDS[k].title, cur0.docs?.[k]?.markdown]),
|
|
2076
|
+
['Implementation plan', cur0.planReview?.markdown],
|
|
2077
|
+
['Explanation', cur0.explanation?.markdown],
|
|
2078
|
+
].filter(([, md]) => md);
|
|
2079
|
+
const documents = written.map(([title, md]) => `<document title="${title}">\n${md.slice(0, 12_000)}\n</document>`).join('\n');
|
|
2080
|
+
const repoMap = agent.workdir ? await repoLayout(agent.workdir).catch(() => '') : '';
|
|
2081
|
+
const writer = this.writerFor(agent);
|
|
2082
|
+
const markdown = diagramsOnly(await this.oneShot(agent, diagramsPrompt({ ...ctx, documents, repoMap }), writer));
|
|
2083
|
+
const cur = this.store.get(agent.id);
|
|
2084
|
+
if (!cur) return null;
|
|
2085
|
+
const from = written.map(([title]) => title);
|
|
2086
|
+
return this.view(
|
|
2087
|
+
this.patch(
|
|
2088
|
+
agent.id,
|
|
2089
|
+
summaryPatch(cur, 'diagrams', {
|
|
2090
|
+
markdown,
|
|
2091
|
+
from,
|
|
2092
|
+
writer: `${writer.engine.label}${writer.model ? ` (${writer.model})` : ''}`,
|
|
2093
|
+
at: now(),
|
|
2094
|
+
briefUpdatedAt: cur.brief?.updatedAt ?? null,
|
|
2095
|
+
pending: false,
|
|
2096
|
+
error: null,
|
|
2097
|
+
}),
|
|
2098
|
+
),
|
|
2099
|
+
);
|
|
2100
|
+
}
|
|
2101
|
+
|
|
1948
2102
|
/** Human vs AI: the writer splits and answers the open questions, then another model double-checks it. */
|
|
1949
2103
|
async runHumanAi(agent, ctx) {
|
|
1950
2104
|
const writer = this.writerFor(agent);
|
package/src/review.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Review an agent's changes: the diff against where its branch started, line comments sent
|
|
2
2
|
// back to the agent as one message, and commit / push / open a pull request from the UI.
|
|
3
3
|
import * as git from './git.js';
|
|
4
|
+
import { hash } from './judge.js';
|
|
4
5
|
import { NO_REPO } from './manager.js';
|
|
5
6
|
import { ghBin } from './tracker.js';
|
|
6
7
|
|
|
@@ -135,6 +136,8 @@ export class Review {
|
|
|
135
136
|
prs: Object.values(agent.prs ?? {}),
|
|
136
137
|
busy: Boolean(rt?.pty && rt.busy),
|
|
137
138
|
diff,
|
|
139
|
+
// Matches a walkthrough's diffHash while the changes are the ones it explained.
|
|
140
|
+
diffHash: hash(diff),
|
|
138
141
|
truncated: diff.endsWith('… (diff truncated)'),
|
|
139
142
|
};
|
|
140
143
|
}
|
package/src/server.js
CHANGED
|
@@ -32,6 +32,7 @@ import { Notifier, isWorkflowUrl, loadNotify, publicNotify, saveNotify, sendSlac
|
|
|
32
32
|
import { Previews, parseUrl } from './preview.js';
|
|
33
33
|
import { LiveBrowser } from './livebrowser.js';
|
|
34
34
|
import * as remote from './remote.js';
|
|
35
|
+
import * as service from './service.js';
|
|
35
36
|
import { Store } from './store.js';
|
|
36
37
|
import { about, latestRelease } from './about.js';
|
|
37
38
|
|
|
@@ -59,7 +60,7 @@ function isAllowedOrigin(origin, port) {
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
const PUBLIC_REMOTE_API = new Set(['POST /api/login', 'GET /api/session']);
|
|
62
|
-
const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|triggers(\/|$)|update(\/|$)|editors$|agents\/[\w-]+\/open-in$|shutdown$)/;
|
|
63
|
+
const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|triggers(\/|$)|update(\/|$)|service$|editors$|agents\/[\w-]+\/open-in$|shutdown$)/;
|
|
63
64
|
const HOOK_PATH = '/api/hooks/agents';
|
|
64
65
|
const NO_REPO_KINDS = new Set(['general', 'assistant']);
|
|
65
66
|
|
|
@@ -73,6 +74,8 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
73
74
|
const backlog = new Backlog({ manager, store, config });
|
|
74
75
|
const races = new Races({ manager, store });
|
|
75
76
|
const review = new Review({ manager });
|
|
77
|
+
// The walkthrough explains the same diff the review screen shows.
|
|
78
|
+
manager.changesSource = (id) => review.get(id);
|
|
76
79
|
const studio = new Studio({ manager, review });
|
|
77
80
|
const teams = new Teams({ manager, store, config });
|
|
78
81
|
const cleanup = new Cleanup({ manager, store, config, teams });
|
|
@@ -259,6 +262,15 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
259
262
|
api.post('/agents/:id/shell/close', wrap((req) => manager.closeShell(req.params.id, req.body?.n ? Number(req.body.n) : undefined)));
|
|
260
263
|
api.post('/agents/:id/shell/new', wrap((req) => ({ n: manager.nextShellNumber(manager.mustGet(req.params.id).id) })));
|
|
261
264
|
api.post('/agents/:id/shell/drop', wrap((req) => manager.dropShell(req.params.id, Number(req.body?.n) || 1)));
|
|
265
|
+
// A file attached before its agent exists (New agents, Ask). Raw body, name in the query.
|
|
266
|
+
api.post(
|
|
267
|
+
'/uploads',
|
|
268
|
+
express.raw({ type: () => true, limit: '50mb' }),
|
|
269
|
+
wrap((req) => {
|
|
270
|
+
if (!Buffer.isBuffer(req.body) || !req.body.length) throw fail(400, 'Empty file');
|
|
271
|
+
return manager.stageUpload(req.query.name, req.body);
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
262
274
|
// Raw file body; the name comes in the query. Returns the saved path to paste into a terminal.
|
|
263
275
|
api.post(
|
|
264
276
|
'/agents/:id/upload',
|
|
@@ -466,7 +478,9 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
466
478
|
api.post('/agents/:id/fork', wrap((req) => manager.fork(req.params.id, req.body ?? {})));
|
|
467
479
|
|
|
468
480
|
api.get('/races', wrap(() => races.list()));
|
|
469
|
-
|
|
481
|
+
// Attachments: copied once to a shared folder and listed in the request every attempt gets.
|
|
482
|
+
const withFiles = ({ attachments, ...body } = {}) => ({ ...body, intent: manager.withSharedAttachments(body.intent, attachments) });
|
|
483
|
+
api.post('/races', wrap((req) => races.create(withFiles(req.body ?? {}))));
|
|
470
484
|
api.get('/races/:id', wrap((req) => races.view(req.params.id)));
|
|
471
485
|
api.post('/races/:id/tests', wrap((req) => races.runTests(req.params.id, req.body ?? {})));
|
|
472
486
|
api.get('/races/:id/diff/:agent', wrap((req) => races.diff(req.params.id, req.params.agent)));
|
|
@@ -494,6 +508,20 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
494
508
|
api.get('/agents/:id/memory', wrap((req) => memory.read(req.params.id)));
|
|
495
509
|
api.put('/agents/:id/memory', wrap((req) => memory.write(req.params.id, req.body?.text)));
|
|
496
510
|
api.post('/agents/:id/learn', wrap((req) => memory.learn(req.params.id, req.body?.text, { by: req.headers['x-agentmgr-agent'] ? undefined : 'you' })));
|
|
511
|
+
// Start at login: the login service (launchd or systemd) that brings the daemon back after a reboot.
|
|
512
|
+
const serviceSupported = ['darwin', 'linux'].includes(process.platform);
|
|
513
|
+
const serviceView = async () => ({ supported: serviceSupported, ...(serviceSupported ? await service.status().catch(() => ({ installed: false, loaded: false })) : { installed: false, loaded: false }) });
|
|
514
|
+
api.get('/service', wrap(serviceView));
|
|
515
|
+
api.put(
|
|
516
|
+
'/service',
|
|
517
|
+
wrap(async (req) => {
|
|
518
|
+
if (!serviceSupported) throw fail(400, `Start at login isn't supported on ${process.platform}`);
|
|
519
|
+
// Installing loads it; its own copy finds this daemon running and exits cleanly (no restart),
|
|
520
|
+
// so it takes over at the next login.
|
|
521
|
+
const res = req.body?.enabled === true ? await service.install() : await service.uninstall();
|
|
522
|
+
return { ...(await serviceView()), note: res.note ?? null };
|
|
523
|
+
}),
|
|
524
|
+
);
|
|
497
525
|
// The first-run checklist: what is set up, and what this machine is missing.
|
|
498
526
|
api.get('/setup', wrap(async () => {
|
|
499
527
|
const checks = (await doctor().catch(() => [])).filter((c) => c.status === 'fail' || c.status === 'warn').filter((c) => c.name !== 'Daemon');
|
|
@@ -506,6 +534,8 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
506
534
|
assistant: agentsNow.some((a) => a.kind === 'assistant') || Boolean(config.triggers?.github?.enabled || config.triggers?.webhook?.enabled),
|
|
507
535
|
remote: Boolean(remote.loadSettings().enabled),
|
|
508
536
|
budget: Boolean(config.budgets?.perAgentUsd || config.budgets?.perDayUsd),
|
|
537
|
+
// Only where it can be set up (macOS, Linux); elsewhere there's nothing to do.
|
|
538
|
+
service: serviceSupported ? Boolean((await service.status().catch(() => ({}))).installed) : true,
|
|
509
539
|
},
|
|
510
540
|
};
|
|
511
541
|
}));
|
|
@@ -544,7 +574,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
544
574
|
return cleanup.run({ close: b.close !== false, delete: b.delete !== false, ids: Array.isArray(b.ids) ? b.ids : null });
|
|
545
575
|
}));
|
|
546
576
|
api.get('/teams', wrap(() => teams.list()));
|
|
547
|
-
api.post('/teams', wrap((req) => teams.create(req.body ?? {})));
|
|
577
|
+
api.post('/teams', wrap((req) => teams.create(withFiles(req.body ?? {}))));
|
|
548
578
|
api.get('/teams/:id', wrap((req) => teams.view(req.params.id)));
|
|
549
579
|
api.get('/teams/:id/files/:name', wrap((req) => teams.file(req.params.id, req.params.name)));
|
|
550
580
|
api.post('/teams/:id/resume', wrap((req) => teams.resume(req.params.id, req.body ?? {})));
|