ai-agentmanager 1.5.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 +7 -0
- package/README.md +7 -6
- package/package.json +1 -1
- package/public/app.js +39 -10
- package/public/attach.js +100 -0
- package/public/index.html +20 -6
- package/public/menu.js +11 -0
- package/public/setup.js +1 -0
- package/public/style.css +8 -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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.6.0 (2026-10-31)
|
|
4
|
+
|
|
5
|
+
- **Changes explained one by one:** `agentmgr doc <agent> walkthrough` explains each hunk of an agent's changes (what, why, risk, what to check) in the order a reviewer should read them, from the same diff the review screen shows. The review screen and a split view for it come next.
|
|
6
|
+
- **Start at login:** a switch in ☰ → Settings installs (or removes) the login service, so the daemon and running agents come back by themselves after a restart, without a terminal command. Getting started lists it until it's on, and `agentmgr ui` mentions it when it's off. Same service as `agentmgr service install` (launchd on macOS, systemd on Linux).
|
|
7
|
+
- **Attachments:** **📎 Attach files** in New agents and Ask Agent: pick, drop or paste screenshots, logs and documents. Each agent gets its own copy, listed in its request, in a folder Claude Code and Codex can read without a prompt (a team or race shares one copy). Backlog items keep theirs until they start. CLI: `--file-attach <path>` on `agentmgr add` and `agentmgr ask`.
|
|
8
|
+
- **Ask Agent:** **Ask Claude** is now **Ask Agent**, with an Engine choice (Claude Code, Codex, Antigravity, Muse Code) and the engine's notice, if any. `agentmgr ask --engine <engine>`.
|
|
9
|
+
|
|
3
10
|
## 1.5.0 (2026-10-31)
|
|
4
11
|
|
|
5
12
|
- **Muse Code engine:** Meta's Muse Code CLI (`muse`) as a fourth engine: `--engine muse` or **Muse Code** in the Engine picker. Session adoption, resume (messages typed once idle), conversation and usage, trust and approval questions in the inbox, a clean stop, and Explain. The picker warns when Muse is in contributor mode. Stuck detection from commands and edits isn't there yet for Muse.
|
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ Run one background [Claude Code](https://claude.com/claude-code) agent per inten
|
|
|
42
42
|
- **Follows your conventions.** Before changing anything, agents review `CLAUDE.md` / `AGENTS.md`, skills, hooks and settings (repo and `~/.claude`), and follow them.
|
|
43
43
|
- **Tokens and cost.** Input, output and cache tokens are tracked per session, subagents included, alongside the cost Claude Code reports.
|
|
44
44
|
- **No trust prompt.** Folders agents run in are pre-trusted, so Claude's "trust this folder" prompt doesn't block them.
|
|
45
|
-
- **Survives restarts and reboots.** Agents that were running when the daemon stopped (restart, crash or reboot) are resumed with the same session IDs when it starts again. `agentmgr service install`
|
|
45
|
+
- **Survives restarts and reboots.** Agents that were running when the daemon stopped (restart, crash or reboot) are resumed with the same session IDs when it starts again. For that after a reboot, turn on **Start at login** in ☰ → Settings (or run `agentmgr service install`); without it, the daemon starts only when you run `agentmgr`.
|
|
46
46
|
- **Shells next to each agent.** **Open shell** gives a terminal in the agent's folder for running commands by hand. **+ Shell** opens as many more as you need, each in its own tab.
|
|
47
47
|
- **Browser tabs.** **+ Browser** opens a page as this machine sees it, in a tab next to the agent's terminals. That can be the agent's dev server on `localhost:5173`, a site on its network or VPN, or any address. Local addresses the agent prints are offered as suggestions. It works from your phone or laptop too (see below).
|
|
48
48
|
- **Live browser.** Browser tabs run a real Chrome on this machine and stream it into the tab: you see its screen and your clicks, scrolling, typing and paste go back to it, from the laptop or the phone. Because the page runs here, `localhost` is this machine, sites that refuse to be framed load normally, and popups (a sign-in window) open as tabs of their own and come back when they close. Chrome (or Chromium, Brave, Edge; `browserBin` in config.json picks one) runs headless with its own profile in `~/.agentmanager/browser-profile`, so sign-ins are remembered; it starts on the first view and stops after 15 minutes with nobody watching. Downloads go to `~/Downloads/agentmanager`. A site that still refuses it as a bot may accept `"browserHeadless": false`, which runs Chrome with a real window on this machine's screen. On Linux where Chrome's sandbox can't start (containers, Ubuntu 23.10+ user-namespace restrictions) the tab says so; `"browserNoSandbox": true` runs it without the sandbox, at your own risk. **Lite** on the bar switches a tab to the frame mode (the page loads in your own browser; quicker for a dev server).
|
|
@@ -51,7 +51,8 @@ Run one background [Claude Code](https://claude.com/claude-code) agent per inten
|
|
|
51
51
|
- **Your layout.** Drag the edge of the agent list, or the line between the brief and the terminal, to resize them; double-click a line to reset it. **Expand** (Ctrl+Shift+E) fills the window with the terminal, shell or browser tab; **Full screen** fills the screen. Agents active in the last day sort to the top; idle ones are dimmed.
|
|
52
52
|
- **As an app.** In Chrome or Edge, click the install icon in the address bar (or ⋮ → Cast, save and share → Install page as app). AI Agent Manager then opens in its own window with its own Dock or taskbar icon, and the daemon keeps running as before.
|
|
53
53
|
- **About.** The version next to the title opens the About box: release date, author, license, links, and how this daemon runs (data folder, port, Node.js, which `claude`). **Check for updates** asks npm for the newest version. In a terminal: `agentmgr about` (`--check` for updates).
|
|
54
|
-
- **General sessions.** **Ask
|
|
54
|
+
- **General sessions.** **Ask Agent** starts a session with no repo, folder or intent, for questions, on Claude Code or any other engine you have (Codex, Antigravity, Muse Code). It is tracked and resumable like any other agent.
|
|
55
|
+
- **Attachments.** **📎 Attach files** in New agents and Ask Agent (or drop files on the dialog, or paste a screenshot into the text box) adds a screenshot, a log or a document to the request. Each agent gets its own copy in `~/.agentmanager/uploads/<agent>/`, listed in its request, and Claude Code and Codex can read that folder without asking. From the CLI: `agentmgr add … --file-attach error.log` and `agentmgr ask … --file-attach shot.png`.
|
|
55
56
|
|
|
56
57
|
New in 0.2, for running many agents at once:
|
|
57
58
|
|
|
@@ -83,13 +84,13 @@ Runs on macOS and Linux; on Windows, use WSL2. Requires Node 20+, `git`, and the
|
|
|
83
84
|
|
|
84
85
|
```sh
|
|
85
86
|
npm install -g ai-agentmanager # the latest release
|
|
86
|
-
npm install -g ai-agentmanager@1.
|
|
87
|
+
npm install -g ai-agentmanager@1.6.0 # or a specific one
|
|
87
88
|
```
|
|
88
89
|
|
|
89
90
|
Or from the repository, at a release tag:
|
|
90
91
|
|
|
91
92
|
```sh
|
|
92
|
-
git clone --branch v1.
|
|
93
|
+
git clone --branch v1.6.0 --depth 1 https://github.com/jdmunta/ai-agentmanager-core.git
|
|
93
94
|
cd ai-agentmanager-core
|
|
94
95
|
npm install
|
|
95
96
|
npm link # optional: puts `agentmgr` on your PATH
|
|
@@ -109,7 +110,7 @@ npm link
|
|
|
109
110
|
| Installed with | Update |
|
|
110
111
|
| --- | --- |
|
|
111
112
|
| npm | `npm install -g ai-agentmanager@latest` |
|
|
112
|
-
| A clone at a release tag | `git fetch --tags && git checkout v1.
|
|
113
|
+
| A clone at a release tag | `git fetch --tags && git checkout v1.6.0 && npm install` (the tag you want) |
|
|
113
114
|
| A clone of `main` | `git pull && npm install` |
|
|
114
115
|
|
|
115
116
|
**Check your setup.** `agentmgr doctor` checks Node.js, Claude Code and its sign-in, git, `gh`, the data folder, the daemon's port and Chrome, and says how to fix anything missing.
|
|
@@ -920,7 +921,7 @@ Usage comes from the session transcripts and is refreshed every few seconds whil
|
|
|
920
921
|
- **From another device** (remote access), AI Agent Manager gives each site its own HTTPS port. That port relays to the site as the machine sees it, so you can check from your phone what the agent built or what the machine can reach. Absolute links, redirects and hot-reload WebSockets on the same site keep working, and the site's "don't show me in a frame" headers are dropped. A link or redirect to a different site leaves the relay.
|
|
921
922
|
- **Safety:** the relay serves only signed-in devices. It never relays to AI Agent Manager's own ports on any of the machine's addresses, and it removes AI Agent Manager's sign-in cookie before forwarding. Sites other than localhost must present a valid certificate. The relays close when remote access is turned off.
|
|
922
923
|
- If a tab stays blank on a device, open it once with **↗** and accept the certificate there.
|
|
923
|
-
- **Ask
|
|
924
|
+
- **Ask Agent** (or `agentmgr ask`, with `--engine` for another engine) creates a `general` agent. All general sessions share `~/.agentmanager/general`, so Claude's folder-trust prompt only appears the first time.
|
|
924
925
|
|
|
925
926
|
## Configuration
|
|
926
927
|
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -4,6 +4,7 @@ import { marked } from '/vendor/marked/marked.esm.js';
|
|
|
4
4
|
import DOMPurify from '/vendor/dompurify/purify.es.mjs';
|
|
5
5
|
import { touchScroll } from '/touchscroll.js';
|
|
6
6
|
import { openStream, post, REMOTE } from '/transport.js';
|
|
7
|
+
import { attachBox } from '/attach.js';
|
|
7
8
|
import { initReview } from '/review.js';
|
|
8
9
|
import { initLand, prChips } from '/land.js';
|
|
9
10
|
import { initTriggers } from '/triggers.js';
|
|
@@ -3147,6 +3148,7 @@ $('#recent-bar').addEventListener('click', (e) => {
|
|
|
3147
3148
|
// ------------------------------------------------------------------ new agents dialog
|
|
3148
3149
|
|
|
3149
3150
|
const dialog = $('#new-dialog');
|
|
3151
|
+
const newFiles = attachBox({ slot: $('#new-attach'), form: $('#new-form'), paste: $('#new-form').intents, esc, toast });
|
|
3150
3152
|
function renderProjectOptions() {
|
|
3151
3153
|
$('#project-options').innerHTML = projectsState
|
|
3152
3154
|
.map((p) => `<option value="${esc(p.shortcut)}">${esc(p.name)}${p.repo ? ` · ${esc(p.repo)}` : p.dir ? ` · ${esc(p.dir)}` : ''}</option>`)
|
|
@@ -3224,6 +3226,12 @@ $('#new-form').addEventListener('submit', async (e) => {
|
|
|
3224
3226
|
e.preventDefault();
|
|
3225
3227
|
const f = new FormData(e.target);
|
|
3226
3228
|
const intents = formIntents(e.target);
|
|
3229
|
+
if (newFiles.pending()) {
|
|
3230
|
+
$('#new-error').textContent = 'Still uploading the attached files; try again in a moment';
|
|
3231
|
+
$('#new-error').hidden = false;
|
|
3232
|
+
return;
|
|
3233
|
+
}
|
|
3234
|
+
const attachments = newFiles.paths().length ? newFiles.paths() : undefined;
|
|
3227
3235
|
if (f.get('when') === 'team') {
|
|
3228
3236
|
// "analyst=opus reviewer=opus"
|
|
3229
3237
|
const models = Object.fromEntries(
|
|
@@ -3234,6 +3242,7 @@ $('#new-form').addEventListener('submit', async (e) => {
|
|
|
3234
3242
|
);
|
|
3235
3243
|
try {
|
|
3236
3244
|
const team = await api('POST', '/teams', {
|
|
3245
|
+
attachments,
|
|
3237
3246
|
intent: intents.join('\n'),
|
|
3238
3247
|
critics: Number(f.get('critics')),
|
|
3239
3248
|
mode: f.get('teamMode') || undefined,
|
|
@@ -3253,6 +3262,7 @@ $('#new-form').addEventListener('submit', async (e) => {
|
|
|
3253
3262
|
teams.set(team.id, team);
|
|
3254
3263
|
dialog.close();
|
|
3255
3264
|
e.target.reset();
|
|
3265
|
+
newFiles.clear();
|
|
3256
3266
|
syncWhen();
|
|
3257
3267
|
openTeam(team.id);
|
|
3258
3268
|
toast(team.mode === 'lead' ? 'Team started: one lead session with sub-agents' : `Team started: ${team.members.length} agents`);
|
|
@@ -3274,6 +3284,7 @@ $('#new-form').addEventListener('submit', async (e) => {
|
|
|
3274
3284
|
});
|
|
3275
3285
|
try {
|
|
3276
3286
|
const race = await api('POST', '/races', {
|
|
3287
|
+
attachments,
|
|
3277
3288
|
intent: intents.join('\n'),
|
|
3278
3289
|
variants,
|
|
3279
3290
|
testCommand: f.get('testCommand') || undefined,
|
|
@@ -3288,6 +3299,7 @@ $('#new-form').addEventListener('submit', async (e) => {
|
|
|
3288
3299
|
races.set(race.id, race);
|
|
3289
3300
|
dialog.close();
|
|
3290
3301
|
e.target.reset();
|
|
3302
|
+
newFiles.clear();
|
|
3291
3303
|
syncWhen();
|
|
3292
3304
|
openRace(race.id);
|
|
3293
3305
|
toast(`Race started: ${race.rows.length} attempts`);
|
|
@@ -3300,6 +3312,7 @@ $('#new-form').addEventListener('submit', async (e) => {
|
|
|
3300
3312
|
if (f.get('when') === 'backlog') {
|
|
3301
3313
|
try {
|
|
3302
3314
|
const added = await api('POST', '/backlog', {
|
|
3315
|
+
attachments,
|
|
3303
3316
|
items: intents,
|
|
3304
3317
|
repo: f.get('repo') || undefined,
|
|
3305
3318
|
dir: f.get('dir') || undefined,
|
|
@@ -3315,6 +3328,7 @@ $('#new-form').addEventListener('submit', async (e) => {
|
|
|
3315
3328
|
});
|
|
3316
3329
|
dialog.close();
|
|
3317
3330
|
e.target.reset();
|
|
3331
|
+
newFiles.clear();
|
|
3318
3332
|
showTab('backlog');
|
|
3319
3333
|
toast(`Queued ${added.length} item${added.length > 1 ? 's' : ''}`);
|
|
3320
3334
|
} catch (err) {
|
|
@@ -3325,6 +3339,7 @@ $('#new-form').addEventListener('submit', async (e) => {
|
|
|
3325
3339
|
}
|
|
3326
3340
|
try {
|
|
3327
3341
|
const created = await api('POST', '/agents', {
|
|
3342
|
+
attachments,
|
|
3328
3343
|
priority: Number(f.get('priority')),
|
|
3329
3344
|
intents,
|
|
3330
3345
|
repo: f.get('repo') || undefined,
|
|
@@ -3342,6 +3357,7 @@ $('#new-form').addEventListener('submit', async (e) => {
|
|
|
3342
3357
|
created.forEach((a) => agents.set(a.id, a));
|
|
3343
3358
|
dialog.close();
|
|
3344
3359
|
e.target.reset();
|
|
3360
|
+
newFiles.clear();
|
|
3345
3361
|
select(created[0].id);
|
|
3346
3362
|
toast(`Created ${created.length} agent${created.length > 1 ? 's' : ''}`);
|
|
3347
3363
|
if (created.length === 1 && created[0].runtime.planning) term.open(created[0].id);
|
|
@@ -3418,17 +3434,29 @@ $('#ask-btn').onclick = () => {
|
|
|
3418
3434
|
askDialog.querySelector('textarea').focus();
|
|
3419
3435
|
};
|
|
3420
3436
|
$('#ask-cancel').onclick = () => askDialog.close();
|
|
3437
|
+
const askFiles = attachBox({ slot: $('#ask-attach'), form: $('#ask-form'), paste: $('#ask-form textarea'), esc, toast });
|
|
3421
3438
|
$('#ask-form').addEventListener('submit', async (e) => {
|
|
3422
3439
|
e.preventDefault();
|
|
3423
3440
|
const f = new FormData(e.target);
|
|
3441
|
+
if (askFiles.pending()) {
|
|
3442
|
+
$('#ask-error').textContent = 'Still uploading the attached files; try again in a moment';
|
|
3443
|
+
$('#ask-error').hidden = false;
|
|
3444
|
+
return;
|
|
3445
|
+
}
|
|
3424
3446
|
try {
|
|
3425
3447
|
const a = await api('POST', '/general', {
|
|
3426
3448
|
question: String(f.get('question') || '').trim() || undefined,
|
|
3427
3449
|
name: String(f.get('name') || '').trim() || undefined,
|
|
3450
|
+
engine: f.get('engine') || undefined,
|
|
3451
|
+
attachments: askFiles.paths().length ? askFiles.paths() : undefined,
|
|
3428
3452
|
});
|
|
3429
3453
|
agents.set(a.id, a);
|
|
3430
3454
|
askDialog.close();
|
|
3455
|
+
// Keep the engine for the next question; clear the rest.
|
|
3456
|
+
const engine = e.target.engine.value;
|
|
3431
3457
|
e.target.reset();
|
|
3458
|
+
e.target.engine.value = engine;
|
|
3459
|
+
askFiles.clear();
|
|
3432
3460
|
select(a.id);
|
|
3433
3461
|
// The shared general folder is ready almost immediately; open the terminal once it runs.
|
|
3434
3462
|
const t0 = Date.now();
|
|
@@ -4011,18 +4039,19 @@ const ENGINE_LABEL = { claude: 'Claude', codex: 'Codex', antigravity: 'Antigravi
|
|
|
4011
4039
|
async function loadEngines() {
|
|
4012
4040
|
try {
|
|
4013
4041
|
const list = (await api('GET', '/engines')).filter((e) => e.installed && e.id !== 'claude');
|
|
4014
|
-
const sel = document.querySelector('#engine-field select');
|
|
4015
|
-
for (const e of list) sel.append(Object.assign(document.createElement('option'), { value: e.id, textContent: e.label }));
|
|
4016
|
-
$('#engine-field').hidden = !list.length;
|
|
4017
4042
|
// Something to know before choosing an engine (Muse Code in contributor mode, say).
|
|
4018
4043
|
const notices = Object.fromEntries(list.filter((e) => e.notice).map((e) => [e.id, e.notice]));
|
|
4019
|
-
const
|
|
4020
|
-
const
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4044
|
+
for (const [field, notice] of [['#engine-field', '#engine-notice'], ['#ask-engine-field', '#ask-engine-notice']]) {
|
|
4045
|
+
const sel = document.querySelector(`${field} select`);
|
|
4046
|
+
for (const e of list) sel.append(Object.assign(document.createElement('option'), { value: e.id, textContent: e.label }));
|
|
4047
|
+
$(field).hidden = !list.length;
|
|
4048
|
+
const show = () => {
|
|
4049
|
+
$(notice).textContent = notices[sel.value] ?? '';
|
|
4050
|
+
$(notice).hidden = !notices[sel.value];
|
|
4051
|
+
};
|
|
4052
|
+
sel.addEventListener('change', show);
|
|
4053
|
+
show();
|
|
4054
|
+
}
|
|
4026
4055
|
} catch {}
|
|
4027
4056
|
}
|
|
4028
4057
|
|
package/public/attach.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Attachments for a request that has no agent yet (New agents, Ask): pick, drop or paste files
|
|
2
|
+
// (a screenshot, a log). Each uploads to the daemon right away and shows as a chip; the form sends
|
|
3
|
+
// the uploaded paths, and the daemon copies them to each agent it creates.
|
|
4
|
+
|
|
5
|
+
const fmtSize = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1048576).toFixed(1)} MB`);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `slot` is an element to fill with the button and chips; `form` takes drops; `paste` (a textarea)
|
|
9
|
+
* takes pasted files. Returns { paths(), pending(), clear() }.
|
|
10
|
+
*/
|
|
11
|
+
export function attachBox({ slot, form, paste, esc, toast }) {
|
|
12
|
+
const items = []; // { id, name, size, path?, error?, uploading }
|
|
13
|
+
let seq = 0;
|
|
14
|
+
slot.classList.add('attach-box');
|
|
15
|
+
slot.innerHTML = `<button type="button" class="small ghost attach-pick" title="Attach files: a screenshot, a log, a document. You can also drop them here or paste into the text box">📎 Attach files</button>
|
|
16
|
+
<input type="file" multiple hidden />
|
|
17
|
+
<span class="attach-chips"></span>`;
|
|
18
|
+
const input = slot.querySelector('input[type=file]');
|
|
19
|
+
const chips = slot.querySelector('.attach-chips');
|
|
20
|
+
|
|
21
|
+
function render() {
|
|
22
|
+
chips.innerHTML = items
|
|
23
|
+
.map(
|
|
24
|
+
(it) => `<span class="attach-chip ${it.error ? 'fail' : ''}" title="${esc(it.error ?? it.path ?? 'Uploading…')}">${esc(it.name)} <span class="hint">${it.uploading ? 'uploading…' : it.error ? 'failed' : fmtSize(it.size)}</span><button type="button" class="link" data-drop="${it.id}" title="Remove">×</button></span>`,
|
|
25
|
+
)
|
|
26
|
+
.join('');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function add(files) {
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
const name = file.name || `pasted-${Date.now()}.${(file.type.split('/')[1] || 'bin').replace(/\W/g, '')}`;
|
|
32
|
+
const it = { id: ++seq, name, size: file.size, uploading: true };
|
|
33
|
+
items.push(it);
|
|
34
|
+
render();
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(`/api/uploads?name=${encodeURIComponent(name)}`, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
credentials: 'same-origin',
|
|
39
|
+
headers: { 'content-type': 'application/octet-stream', 'x-agentmgr': '1' },
|
|
40
|
+
body: file,
|
|
41
|
+
});
|
|
42
|
+
const data = await res.json().catch(() => ({}));
|
|
43
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
44
|
+
Object.assign(it, { path: data.path, size: data.size });
|
|
45
|
+
} catch (err) {
|
|
46
|
+
it.error = err.message;
|
|
47
|
+
toast(`Could not attach ${name}: ${err.message}`, 5000);
|
|
48
|
+
}
|
|
49
|
+
it.uploading = false;
|
|
50
|
+
render();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
slot.querySelector('.attach-pick').onclick = () => input.click();
|
|
55
|
+
input.onchange = () => {
|
|
56
|
+
add([...input.files]);
|
|
57
|
+
input.value = '';
|
|
58
|
+
};
|
|
59
|
+
chips.addEventListener('click', (e) => {
|
|
60
|
+
const id = Number(e.target.closest('[data-drop]')?.dataset.drop);
|
|
61
|
+
if (!id) return;
|
|
62
|
+
items.splice(
|
|
63
|
+
items.findIndex((it) => it.id === id),
|
|
64
|
+
1,
|
|
65
|
+
);
|
|
66
|
+
render();
|
|
67
|
+
});
|
|
68
|
+
form.addEventListener('dragover', (e) => {
|
|
69
|
+
if ([...(e.dataTransfer?.types ?? [])].includes('Files')) {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
form.classList.add('dropping');
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
form.addEventListener('dragleave', (e) => {
|
|
75
|
+
if (!form.contains(e.relatedTarget)) form.classList.remove('dropping');
|
|
76
|
+
});
|
|
77
|
+
form.addEventListener('drop', (e) => {
|
|
78
|
+
form.classList.remove('dropping');
|
|
79
|
+
const files = [...(e.dataTransfer?.files ?? [])];
|
|
80
|
+
if (!files.length) return;
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
add(files);
|
|
83
|
+
});
|
|
84
|
+
// A pasted screenshot (or copied files) attaches; pasted text stays text.
|
|
85
|
+
paste?.addEventListener('paste', (e) => {
|
|
86
|
+
const files = [...(e.clipboardData?.files ?? [])];
|
|
87
|
+
if (!files.length) return;
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
add(files);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
paths: () => items.filter((it) => it.path).map((it) => it.path),
|
|
94
|
+
pending: () => items.some((it) => it.uploading),
|
|
95
|
+
clear() {
|
|
96
|
+
items.length = 0;
|
|
97
|
+
render();
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
package/public/index.html
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
<button id="assistant-btn" title="An always-on session that finds new work and starts agents for it">Assistant <span id="assistant-dot" class="assistant-dot" hidden></span></button>
|
|
26
26
|
<button id="spend-btn" title="Results across agents (merge rate, time to PR, cost per merged PR), what they cost, and spending limits">Results <span id="spend-today" class="spend-today"></span></button>
|
|
27
27
|
<button id="machine-btn" title="A terminal on this computer (your home folder), from any device you're signed in on">Terminal</button>
|
|
28
|
-
<button id="ask-btn" title="A general
|
|
28
|
+
<button id="ask-btn" title="A general session with no repo or folder, on Claude Code or another engine you have (Codex, Muse Code, …)">Ask Agent</button>
|
|
29
29
|
<button id="new-btn" class="primary">+ New agents</button>
|
|
30
30
|
<div class="menu-wrap">
|
|
31
31
|
<button id="menu-btn" class="menu-btn" type="button" aria-haspopup="menu" aria-expanded="false" title="Settings and preferences">
|
|
@@ -173,6 +173,7 @@
|
|
|
173
173
|
<span class="sr-only">Intents</span>
|
|
174
174
|
<textarea name="intents" rows="6" required placeholder="Add a dark mode toggle that remembers the user's choice Make search results show up as you type Find out why the dashboard takes 5 seconds to load and speed it up Add a one-click CSV export to the reports page"></textarea>
|
|
175
175
|
</label>
|
|
176
|
+
<div id="new-attach"></div>
|
|
176
177
|
<div class="row">
|
|
177
178
|
<label>
|
|
178
179
|
Git repository URL
|
|
@@ -307,16 +308,24 @@
|
|
|
307
308
|
|
|
308
309
|
<dialog id="ask-dialog">
|
|
309
310
|
<form id="ask-form" method="dialog">
|
|
310
|
-
<h2>Ask
|
|
311
|
+
<h2>Ask an agent</h2>
|
|
311
312
|
<p class="hint">A general session with no repo or folder. It is tracked and resumable like any other agent.</p>
|
|
312
313
|
<label>
|
|
313
314
|
Question <span class="hint">optional; you can also just type in the terminal</span>
|
|
314
315
|
<textarea name="question" rows="4" placeholder="How do I tune GC for a JVM with a 32 GB heap?"></textarea>
|
|
315
316
|
</label>
|
|
316
|
-
<
|
|
317
|
-
|
|
318
|
-
<
|
|
319
|
-
|
|
317
|
+
<div id="ask-attach"></div>
|
|
318
|
+
<div class="row">
|
|
319
|
+
<label>
|
|
320
|
+
Name <span class="hint">optional</span>
|
|
321
|
+
<input name="name" placeholder="jvm-gc-questions" />
|
|
322
|
+
</label>
|
|
323
|
+
<label id="ask-engine-field">
|
|
324
|
+
Engine
|
|
325
|
+
<select name="engine"><option value="">Claude Code</option></select>
|
|
326
|
+
<span class="hint warn" id="ask-engine-notice" hidden></span>
|
|
327
|
+
</label>
|
|
328
|
+
</div>
|
|
320
329
|
<p id="ask-error" class="error" hidden></p>
|
|
321
330
|
<div class="dialog-actions">
|
|
322
331
|
<button type="button" id="ask-cancel" class="ghost">Cancel</button>
|
|
@@ -679,6 +688,11 @@
|
|
|
679
688
|
<label class="check">Keep at most <input type="number" name="maxRunning" min="1" max="500" placeholder="no limit" class="num" /> agents open at once</label>
|
|
680
689
|
<p class="hint">Above it, the agents idle longest are closed (they resume when you open them), so the machine stays responsive. Never closed: agents working, waiting for your answer, active in the last 5 minutes, marked Keep, or in a team that is still going. <span id="settings-running"></span></p>
|
|
681
690
|
</fieldset>
|
|
691
|
+
<fieldset class="local-only" id="settings-service">
|
|
692
|
+
<legend>Start at login</legend>
|
|
693
|
+
<label class="check"><input type="checkbox" name="startAtLogin" /> Start AI Agent Manager when I log in</label>
|
|
694
|
+
<p class="hint">After a restart or a crash, the daemon comes back by itself and running agents resume; without it, it starts only when you run <code>agentmgr</code>. <span id="settings-service-state"></span></p>
|
|
695
|
+
</fieldset>
|
|
682
696
|
<fieldset class="local-only">
|
|
683
697
|
<legend>Updates</legend>
|
|
684
698
|
<label class="check"><input type="checkbox" name="updateCheck" /> Check for a new release twice a day</label>
|
package/public/menu.js
CHANGED
|
@@ -52,15 +52,21 @@ export function initMenu({ api, esc, toast, $ }) {
|
|
|
52
52
|
// ------------------------------------------------------------ Settings dialog
|
|
53
53
|
const dialog = $('#settings-dialog');
|
|
54
54
|
const form = $('#settings-form');
|
|
55
|
+
let service = null; // the login service as the dialog opened
|
|
55
56
|
$('#settings-btn').onclick = async () => {
|
|
56
57
|
$('#settings-error').hidden = true;
|
|
57
58
|
try {
|
|
58
59
|
const c = await api('GET', '/cleanup');
|
|
59
60
|
form.maxRunning.value = c.maxRunning ?? '';
|
|
60
61
|
$('#settings-running').textContent = `Open now: ${c.running}.`;
|
|
62
|
+
service = null;
|
|
61
63
|
if (!remote()) {
|
|
62
64
|
const u = await api('GET', '/update').catch(() => null);
|
|
63
65
|
form.updateCheck.checked = u?.check !== false;
|
|
66
|
+
service = await api('GET', '/service').catch(() => null);
|
|
67
|
+
$('#settings-service').hidden = !service?.supported;
|
|
68
|
+
form.startAtLogin.checked = Boolean(service?.installed);
|
|
69
|
+
$('#settings-service-state').textContent = service?.installed ? (service.loaded ? 'On: the login service is loaded.' : 'On; loads at the next login.') : 'Off now.';
|
|
64
70
|
}
|
|
65
71
|
dialog.showModal();
|
|
66
72
|
} catch (err) {
|
|
@@ -79,6 +85,11 @@ export function initMenu({ api, esc, toast, $ }) {
|
|
|
79
85
|
try {
|
|
80
86
|
const c = await api('PUT', '/cleanup', { maxRunning: form.maxRunning.value.trim() || null });
|
|
81
87
|
if (!remote()) await api('PUT', '/update', { check: form.updateCheck.checked }).catch(() => {});
|
|
88
|
+
// Only when it changed: installing touches launchd / systemd.
|
|
89
|
+
if (!remote() && service?.supported && form.startAtLogin.checked !== Boolean(service.installed)) {
|
|
90
|
+
const s = await api('PUT', '/service', { enabled: form.startAtLogin.checked });
|
|
91
|
+
toast(s.installed ? `Starts at login from now on${s.note ? `. ${s.note}` : ''}` : 'No longer starts at login', 6000);
|
|
92
|
+
}
|
|
82
93
|
dialog.close();
|
|
83
94
|
toast(c.maxRunning ? `At most ${c.maxRunning} agents open; the ones idle longest close above that` : 'No limit on open agents');
|
|
84
95
|
} catch (err) {
|
package/public/setup.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
const STEPS = [
|
|
5
5
|
['agent', 'Start your first agent', 'Give it a request and a repo or folder; it works in its own branch.', '#new-btn', 'New agents'],
|
|
6
|
+
['service', 'Start at login', 'So AI Agent Manager and your agents come back by themselves after a restart.', '#settings-btn', 'Settings'],
|
|
6
7
|
['alerts', 'Get Slack alerts', 'Hear when an agent needs you, is stuck or finished.', '#alerts-btn', 'Alerts'],
|
|
7
8
|
['assistant', 'Let work come to you', 'The assistant, or triggers (a GitHub label, a webhook), start agents for new work.', '#assistant-btn', 'Assistant'],
|
|
8
9
|
['remote', 'Use it from your phone', 'Answer agents from anywhere on your network.', '#remote-btn', 'Remote access'],
|
package/public/style.css
CHANGED
|
@@ -829,3 +829,11 @@ input.inline { display: inline-block; width: 80px; padding: 1px 4px; }
|
|
|
829
829
|
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
830
830
|
.hint.warn { display: block; margin-top: 4px; color: var(--yellow); }
|
|
831
831
|
.hint.warn[hidden] { display: none; }
|
|
832
|
+
|
|
833
|
+
/* Attachments in New agents and Ask: a button, then a chip per file. */
|
|
834
|
+
.attach-box { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: -4px 0 10px; }
|
|
835
|
+
.attach-chips { display: contents; }
|
|
836
|
+
.attach-chip { display: inline-flex; align-items: center; gap: 6px; padding: 2px 4px 2px 8px; border: 1px solid var(--border); border-radius: 999px; background: var(--panel-2); font-size: 12px; max-width: 100%; }
|
|
837
|
+
.attach-chip.fail { border-color: var(--red); }
|
|
838
|
+
.attach-chip .link { padding: 0 4px; }
|
|
839
|
+
form.dropping { outline: 2px dashed var(--accent, #6b8cff); outline-offset: 4px; }
|
package/src/backlog.js
CHANGED
|
@@ -89,7 +89,7 @@ export class Backlog extends EventEmitter {
|
|
|
89
89
|
// ------------------------------------------------------------ editing
|
|
90
90
|
|
|
91
91
|
/** Queue one item per title. Options are what an agent would take (repo, dir, base, project, ...). */
|
|
92
|
-
add(titles, { repo, dir, base, project, worktree, permissionMode, model, plan, planReview, engine, priority = 2 } = {}) {
|
|
92
|
+
add(titles, { repo, dir, base, project, worktree, permissionMode, model, plan, planReview, engine, attachments, priority = 2 } = {}) {
|
|
93
93
|
const list = titles.map((t) => String(t ?? '').trim()).filter(Boolean);
|
|
94
94
|
if (!list.length) throw err(400, 'At least one item is required');
|
|
95
95
|
const pr = parsePriority(priority);
|
|
@@ -120,6 +120,8 @@ export class Backlog extends EventEmitter {
|
|
|
120
120
|
permissionMode: permissionMode || null,
|
|
121
121
|
model: model || null,
|
|
122
122
|
engine: engine || null,
|
|
123
|
+
// Staged uploads; each item's agent gets its own copies when it starts.
|
|
124
|
+
attachments: Array.isArray(attachments) && attachments.length ? attachments.slice(0, 20).map(String) : undefined,
|
|
123
125
|
plan: Boolean(plan),
|
|
124
126
|
planReview: planReview ?? null,
|
|
125
127
|
priority: pr,
|
|
@@ -219,6 +221,7 @@ export class Backlog extends EventEmitter {
|
|
|
219
221
|
permissionMode: it.permissionMode,
|
|
220
222
|
model: it.model,
|
|
221
223
|
engine: it.engine ?? undefined,
|
|
224
|
+
attachments: it.attachments,
|
|
222
225
|
plan: it.plan,
|
|
223
226
|
planReview: it.planReview ?? undefined,
|
|
224
227
|
priority: it.priority,
|
package/src/brief.js
CHANGED
|
@@ -411,6 +411,81 @@ export function diagramsOnly(markdown) {
|
|
|
411
411
|
.trim();
|
|
412
412
|
}
|
|
413
413
|
|
|
414
|
+
// ------------------------------------------------------------------ Change walkthrough
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The agent's changes explained one by one, for review: each hunk of its diff gets a title, what
|
|
418
|
+
* changed, why, a risk and what to check, in the order a reviewer should read them.
|
|
419
|
+
* `hunks`: [{ id, file, head, text }].
|
|
420
|
+
*/
|
|
421
|
+
export function walkthroughPrompt({ intent, status, digest, briefMarkdown, hunks }) {
|
|
422
|
+
return [
|
|
423
|
+
"You help a person review an AI coding agent's changes, one change at a time.",
|
|
424
|
+
'Below are its changes as numbered hunks (H1, H2, ...), with the request and its conversation for the reasons behind them.',
|
|
425
|
+
'',
|
|
426
|
+
'Reply with one JSON object only, no prose around it:',
|
|
427
|
+
'{"summary": "<two sentences: what the changes do overall>", "changes": [{"id": "H3", "title": "<under 10 words>", "what": "<what this hunk changes, concretely>", "why": "<why, from the request or conversation; \"Not stated\" if unknown>", "risk": "low|medium|high", "check": "<what the reviewer should verify here>"}]}',
|
|
428
|
+
'',
|
|
429
|
+
'Rules:',
|
|
430
|
+
'- One entry per hunk, every hunk exactly once. Order them the way a reviewer should read them: the core change first, then what depends on it, then tests, docs and small edits.',
|
|
431
|
+
'- Be specific: name the functions, values and behavior. Base it only on the hunks and the context; do not invent intent.',
|
|
432
|
+
'- risk is high for security, data loss, concurrency, money or public API changes; medium for behavior changes; low for refactors, tests, docs and formatting.',
|
|
433
|
+
'- Keep what, why and check to one or two sentences each.',
|
|
434
|
+
'',
|
|
435
|
+
...contextBlock({ intent, status, digest, repo: '', briefMarkdown }),
|
|
436
|
+
'<hunks>',
|
|
437
|
+
...hunks.map((h) => `<hunk id="${h.id}" file="${h.file}">\n${h.head}\n${h.text}\n</hunk>`),
|
|
438
|
+
'</hunks>',
|
|
439
|
+
].join('\n');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** The hunks a walkthrough explains, from a parsed diff: ids, and the text sent (capped). */
|
|
443
|
+
export function walkthroughHunks(files, { maxLines = 80, maxChars = 60_000 } = {}) {
|
|
444
|
+
const out = [];
|
|
445
|
+
let used = 0;
|
|
446
|
+
for (const f of files) {
|
|
447
|
+
if (f.binary) continue;
|
|
448
|
+
for (const h of f.hunks) {
|
|
449
|
+
const lines = h.lines.map((l) => `${l.t === 'add' ? '+' : l.t === 'del' ? '-' : ' '}${l.text}`);
|
|
450
|
+
const text = lines.length > maxLines ? [...lines.slice(0, maxLines), `… (${lines.length - maxLines} more lines)`].join('\n') : lines.join('\n');
|
|
451
|
+
if (used + text.length > maxChars) return { hunks: out, cut: true };
|
|
452
|
+
used += text.length;
|
|
453
|
+
out.push({ id: `H${out.length + 1}`, file: f.path, head: h.head, text });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return { hunks: out, cut: false };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** The model's reply mapped back to hunks (file + header), in its reading order; unknown ids dropped. */
|
|
460
|
+
export function walkthroughFrom(reply, hunks) {
|
|
461
|
+
const byId = new Map(hunks.map((h) => [h.id, h]));
|
|
462
|
+
const seen = new Set();
|
|
463
|
+
const risk = (r) => (['low', 'medium', 'high'].includes(r) ? r : 'medium');
|
|
464
|
+
const changes = [];
|
|
465
|
+
for (const c of Array.isArray(reply?.changes) ? reply.changes : []) {
|
|
466
|
+
const h = byId.get(String(c?.id ?? '').trim());
|
|
467
|
+
if (!h || seen.has(h.id)) continue;
|
|
468
|
+
seen.add(h.id);
|
|
469
|
+
changes.push({ file: h.file, head: h.head, title: String(c.title ?? '').slice(0, 120), what: String(c.what ?? '').slice(0, 800), why: String(c.why ?? '').slice(0, 800), risk: risk(c.risk), check: String(c.check ?? '').slice(0, 600) });
|
|
470
|
+
}
|
|
471
|
+
// Hunks the model skipped still get a place (unexplained), so stepping through covers everything.
|
|
472
|
+
for (const h of hunks) if (!seen.has(h.id)) changes.push({ file: h.file, head: h.head, title: '', what: '', why: '', risk: 'low', check: '', unexplained: true });
|
|
473
|
+
return { summary: String(reply?.summary ?? '').slice(0, 600), changes };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Markdown version, for Copy / Download and the CLI. */
|
|
477
|
+
export function walkthroughMarkdown({ summary, changes }, title = '') {
|
|
478
|
+
const out = [`# Changes explained${title ? `: ${title}` : ''}`, '', summary, ''];
|
|
479
|
+
changes.forEach((c, i) => {
|
|
480
|
+
out.push(`## ${i + 1}. ${c.title || '(not explained)'}`, '', `\`${c.file}\` · \`${c.head.replace(/^(@@[^@]*@@).*/, '$1')}\` · risk: ${c.risk}`, '');
|
|
481
|
+
if (c.what) out.push(`- **What:** ${c.what}`);
|
|
482
|
+
if (c.why) out.push(`- **Why:** ${c.why}`);
|
|
483
|
+
if (c.check) out.push(`- **Check:** ${c.check}`);
|
|
484
|
+
out.push('');
|
|
485
|
+
});
|
|
486
|
+
return out.join('\n').trim() + '\n';
|
|
487
|
+
}
|
|
488
|
+
|
|
414
489
|
// ------------------------------------------------------------------ Human vs AI
|
|
415
490
|
|
|
416
491
|
export const contextBlock = ({ intent, status, digest, repo, briefMarkdown }) => [
|
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 ?? {})));
|