ai-agentmanager 1.5.0 → 1.7.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 +12 -0
- package/README.md +9 -6
- package/package.json +1 -1
- package/public/app.js +76 -12
- package/public/attach.js +100 -0
- package/public/index.html +66 -34
- package/public/menu.js +11 -0
- package/public/review.js +85 -2
- package/public/setup.js +1 -0
- package/public/side.js +191 -0
- package/public/style.css +44 -0
- package/public/walkthrough.js +36 -0
- package/src/backlog.js +4 -1
- package/src/brief.js +75 -0
- package/src/cli.js +32 -6
- package/src/manager.js +110 -5
- package/src/review.js +3 -0
- package/src/server.js +33 -3
package/src/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
3
4
|
import { Command, Option } from 'commander';
|
|
4
5
|
import WebSocket from 'ws';
|
|
5
6
|
import { HOST, PORT } from './config.js';
|
|
@@ -192,6 +193,20 @@ async function attach(ref, { resume = true, kind = 'claude' } = {}) {
|
|
|
192
193
|
});
|
|
193
194
|
}
|
|
194
195
|
|
|
196
|
+
/** Upload local files for a request (a screenshot, a log); returns the staged paths the daemon gets. */
|
|
197
|
+
async function uploadFiles(files = []) {
|
|
198
|
+
const out = [];
|
|
199
|
+
for (const f of files) {
|
|
200
|
+
const data = fs.readFileSync(f);
|
|
201
|
+
const res = await fetch(`${BASE}/api/uploads?name=${encodeURIComponent(path.basename(f))}`, { method: 'POST', headers: { 'content-type': 'application/octet-stream', 'x-agentmgr': '1' }, body: data });
|
|
202
|
+
const body = await res.json().catch(() => ({}));
|
|
203
|
+
if (!res.ok) throw new Error(`Could not attach ${f}: ${body.error || `HTTP ${res.status}`}`);
|
|
204
|
+
out.push(body.path);
|
|
205
|
+
}
|
|
206
|
+
return out.length ? out : undefined;
|
|
207
|
+
}
|
|
208
|
+
const collect = (v, list = []) => [...list, v];
|
|
209
|
+
|
|
195
210
|
function readIntentsFile(file) {
|
|
196
211
|
const text = file === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(file, 'utf8');
|
|
197
212
|
return text
|
|
@@ -295,6 +310,11 @@ export async function main(argv) {
|
|
|
295
310
|
const st = await api('GET', '/remote').catch(() => null);
|
|
296
311
|
const phone = st?.running ? st.urls.find((u) => !u.vpn) : null;
|
|
297
312
|
if (phone) console.log(dim(`phone: ${phone.url} (sign-in QR: agentmgr remote status)`));
|
|
313
|
+
// Without the login service, nothing starts the daemon after a reboot.
|
|
314
|
+
if (['darwin', 'linux'].includes(process.platform)) {
|
|
315
|
+
const svc = await import('./service.js');
|
|
316
|
+
if (!(await svc.status().catch(() => ({ installed: true }))).installed) console.log(dim('tip: it won\'t start by itself after a restart; turn on Settings → Start at login, or run: agentmgr service install'));
|
|
317
|
+
}
|
|
298
318
|
execFile(process.platform === 'darwin' ? 'open' : 'xdg-open', [BASE], () => {});
|
|
299
319
|
});
|
|
300
320
|
|
|
@@ -321,7 +341,8 @@ export async function main(argv) {
|
|
|
321
341
|
]),
|
|
322
342
|
)
|
|
323
343
|
.option('-m, --model <model>', 'model (for the chosen engine)')
|
|
324
|
-
.option('-e, --engine <engine>', 'agent engine: claude (default) or
|
|
344
|
+
.option('-e, --engine <engine>', 'agent engine: claude (default), codex, antigravity or muse')
|
|
345
|
+
.option('--file-attach <path>', 'attach a file to the request (a screenshot, a log); repeat for more', collect)
|
|
325
346
|
.option('--plan', 'while a repo URL clones, plan the task with Claude (moves into the repo when ready)')
|
|
326
347
|
.option('--no-start', 'create without launching')
|
|
327
348
|
.option('-a, --attach', 'attach to the agent after launch (single intent only)')
|
|
@@ -332,8 +353,10 @@ export async function main(argv) {
|
|
|
332
353
|
await daemon();
|
|
333
354
|
// commander sets worktree=true for --no-worktree's default; only send it when the user chose.
|
|
334
355
|
const worktreeSource = cmd.getOptionValueSource('worktree');
|
|
356
|
+
const attachments = await uploadFiles(opts.fileAttach);
|
|
335
357
|
const created = await api('POST', '/agents', {
|
|
336
358
|
intents,
|
|
359
|
+
attachments,
|
|
337
360
|
repo: opts.repo,
|
|
338
361
|
dir: opts.dir,
|
|
339
362
|
name: opts.name,
|
|
@@ -362,14 +385,17 @@ export async function main(argv) {
|
|
|
362
385
|
|
|
363
386
|
program
|
|
364
387
|
.command('ask')
|
|
365
|
-
.description('Open a general
|
|
388
|
+
.description('Open a general session (no repo or folder) on any engine and attach to it')
|
|
366
389
|
.argument('[question...]', 'optional first question')
|
|
367
390
|
.option('-n, --name <name>', 'display name')
|
|
368
|
-
.option('-m, --model <model>', '
|
|
391
|
+
.option('-m, --model <model>', 'model (for the chosen engine)')
|
|
392
|
+
.option('-e, --engine <engine>', 'agent engine: claude (default), codex, antigravity or muse')
|
|
393
|
+
.option('--file-attach <path>', 'attach a file to the question (a screenshot, a log); repeat for more', collect)
|
|
369
394
|
.option('--no-attach', 'start it in the background only')
|
|
370
395
|
.action(async (words, opts) => {
|
|
371
396
|
await daemon();
|
|
372
|
-
|
|
397
|
+
const attachments = await uploadFiles(opts.fileAttach);
|
|
398
|
+
let a = await api('POST', '/general', { question: words.join(' '), name: opts.name, model: opts.model, engine: opts.engine, attachments });
|
|
373
399
|
console.log(`${green('+')} ${bold(a.name)} ${dim(a.id)} session ${cyan(a.sessionId)}`);
|
|
374
400
|
for (let i = 0; i < 100 && a.status === 'preparing'; i++) {
|
|
375
401
|
await new Promise((r) => setTimeout(r, 100));
|
|
@@ -404,9 +430,9 @@ export async function main(argv) {
|
|
|
404
430
|
|
|
405
431
|
program
|
|
406
432
|
.command('doc')
|
|
407
|
-
.description('Write a stakeholder document from the agent work: analysis, design, flow, proposal, humanai (Human vs AI)
|
|
433
|
+
.description('Write a stakeholder document from the agent work: analysis, design, flow, proposal, humanai (Human vs AI), diagrams (diagrams only) or walkthrough (its changes explained one by one)')
|
|
408
434
|
.argument('<agent>')
|
|
409
|
-
.argument('<kind>', 'analysis, design, flow, proposal, humanai, diagrams, or all (background; diagrams not included)')
|
|
435
|
+
.argument('<kind>', 'analysis, design, flow, proposal, humanai, diagrams, walkthrough, or all (background; diagrams and walkthrough not included)')
|
|
410
436
|
.option('-o, --output <file>', 'also save the Markdown to this file')
|
|
411
437
|
.option('--cached', 'print the last version instead of writing a new one')
|
|
412
438
|
.option('-b, --background', 'start writing and return at once (kind "all" writes all five)')
|
package/src/manager.js
CHANGED
|
@@ -10,6 +10,10 @@ import {
|
|
|
10
10
|
DOC_KINDS,
|
|
11
11
|
diagramsOnly,
|
|
12
12
|
diagramsPrompt,
|
|
13
|
+
walkthroughFrom,
|
|
14
|
+
walkthroughHunks,
|
|
15
|
+
walkthroughMarkdown,
|
|
16
|
+
walkthroughPrompt,
|
|
13
17
|
briefDir,
|
|
14
18
|
briefPath,
|
|
15
19
|
briefPrompt,
|
|
@@ -36,12 +40,45 @@ const SUMMARIES = {
|
|
|
36
40
|
...Object.fromEntries(Object.keys(DOC_KINDS).map((k) => [k, { doc: k, prompt: (ctx) => docPrompt(k, ctx) }])),
|
|
37
41
|
humanai: { doc: 'humanai', run: 'runHumanAi' },
|
|
38
42
|
diagrams: { doc: 'diagrams', run: 'runDiagrams' },
|
|
43
|
+
walkthrough: { doc: 'walkthrough', run: 'runWalkthrough' },
|
|
39
44
|
};
|
|
40
45
|
/** Read / write where a summary of `kind` is kept on the agent record. */
|
|
41
46
|
const getSummary = (agent, kind) => (SUMMARIES[kind].doc ? agent.docs?.[kind] : agent[SUMMARIES[kind].field]);
|
|
42
47
|
const summaryPatch = (agent, kind, value) =>
|
|
43
48
|
SUMMARIES[kind].doc ? { docs: { ...agent.docs, [kind]: value } } : { [SUMMARIES[kind].field]: value };
|
|
44
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
|
+
|
|
45
82
|
/** The folders of a repo with how many files each holds, two levels deep: a map for drawing it. */
|
|
46
83
|
async function repoLayout(dir) {
|
|
47
84
|
const files = (await git.run('git', ['ls-files'], { cwd: dir, timeout: 20_000 })).split('\n').filter(Boolean);
|
|
@@ -55,6 +92,7 @@ async function repoLayout(dir) {
|
|
|
55
92
|
}
|
|
56
93
|
import { spawn } from 'node:child_process';
|
|
57
94
|
import * as git from './git.js';
|
|
95
|
+
import { parseDiff } from '../public/review.js';
|
|
58
96
|
import { conflictMessage, findConflicts, related, repoKey, summarize as summarizeConflicts } from './conflicts.js';
|
|
59
97
|
import { answerMessage, loadQuestions, questionsPath, questionsPrompt } from './inbox.js';
|
|
60
98
|
import { STUCK_DEFAULTS, detectStuck } from './stuck.js';
|
|
@@ -516,9 +554,9 @@ export class AgentManager extends EventEmitter {
|
|
|
516
554
|
);
|
|
517
555
|
}
|
|
518
556
|
|
|
519
|
-
/** A general
|
|
520
|
-
createGeneral({ question, name, permissionMode, model } = {}) {
|
|
521
|
-
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 });
|
|
522
560
|
}
|
|
523
561
|
|
|
524
562
|
create({
|
|
@@ -544,8 +582,10 @@ export class AgentManager extends EventEmitter {
|
|
|
544
582
|
imported,
|
|
545
583
|
startedBy,
|
|
546
584
|
trigger,
|
|
585
|
+
attachments,
|
|
547
586
|
}) {
|
|
548
587
|
intent = (intent ?? '').trim();
|
|
588
|
+
const staged = stagedFiles(attachments);
|
|
549
589
|
const engineDef = engineOf(engine || this.config.engine || DEFAULT_ENGINE);
|
|
550
590
|
const engineId = engineDef.id;
|
|
551
591
|
this.plugins.check('agent.create', {
|
|
@@ -569,7 +609,7 @@ export class AgentManager extends EventEmitter {
|
|
|
569
609
|
base ??= entry.base ?? undefined;
|
|
570
610
|
project = entry.key.startsWith('custom:') ? entry.name : null;
|
|
571
611
|
}
|
|
572
|
-
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 });
|
|
573
613
|
const id = crypto.randomBytes(3).toString('hex');
|
|
574
614
|
// An imported session keeps its ID, so the first start resumes it.
|
|
575
615
|
const sessionId = adopt || crypto.randomUUID();
|
|
@@ -577,6 +617,8 @@ export class AgentManager extends EventEmitter {
|
|
|
577
617
|
const jiraKey = !name?.trim() && intent ? leadingJiraKey(intent) : null;
|
|
578
618
|
const slug = name || intent ? slugify(name || jiraKey || intent) : `general-${id}`;
|
|
579
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');
|
|
580
622
|
|
|
581
623
|
const agent = {
|
|
582
624
|
id,
|
|
@@ -942,6 +984,12 @@ export class AgentManager extends EventEmitter {
|
|
|
942
984
|
}
|
|
943
985
|
}
|
|
944
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
|
+
}
|
|
945
993
|
return { extra: engine.extraArgs(this.config), permissionMode: agent.permissionMode, model: agent.model, writableDir, instructions: prompts.join('\n\n') || null, agents, allowedTools };
|
|
946
994
|
}
|
|
947
995
|
|
|
@@ -1629,9 +1677,35 @@ export class AgentManager extends EventEmitter {
|
|
|
1629
1677
|
}
|
|
1630
1678
|
|
|
1631
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
|
+
|
|
1632
1706
|
saveUpload(ref, name, data) {
|
|
1633
1707
|
const agent = this.mustGet(ref);
|
|
1634
|
-
const safe =
|
|
1708
|
+
const safe = safeName(name);
|
|
1635
1709
|
const dir = path.join(paths.uploads, agent.id);
|
|
1636
1710
|
fs.mkdirSync(dir, { recursive: true });
|
|
1637
1711
|
const file = path.join(dir, `${new Date().toISOString().replace(/[:.]/g, '-')}-${safe}`);
|
|
@@ -1963,6 +2037,37 @@ export class AgentManager extends EventEmitter {
|
|
|
1963
2037
|
});
|
|
1964
2038
|
}
|
|
1965
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
|
+
|
|
1966
2071
|
/** Diagrams only: the problem and its solution drawn from the documents already written and the live context. */
|
|
1967
2072
|
async runDiagrams(agent, ctx) {
|
|
1968
2073
|
const cur0 = this.store.get(agent.id) ?? 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 ?? {})));
|