ai-agentmanager 0.0.0-stage → 1.0.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/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +767 -2
- package/SECURITY.md +44 -0
- package/bin/agentmgr-hub.js +16 -0
- package/bin/agentmgr.js +4 -0
- package/docs/architecture.md +67 -0
- package/docs/distributed.md +147 -0
- package/docs/engines.md +177 -0
- package/docs/images/agents.jpg +0 -0
- package/docs/images/conflict-radar.jpg +0 -0
- package/docs/plugins.md +81 -0
- package/package.json +56 -4
- package/public/app.js +3083 -0
- package/public/hub/hub.css +230 -0
- package/public/hub/hub.js +766 -0
- package/public/hub/index.html +146 -0
- package/public/hub/term.js +147 -0
- package/public/index.html +342 -0
- package/public/share.html +34 -0
- package/public/share.js +41 -0
- package/public/style.css +541 -0
- package/public/touchscroll.js +73 -0
- package/public/transport.js +78 -0
- package/scripts/build-release.sh +58 -0
- package/scripts/fix-pty.js +15 -0
- package/src/backlog.js +413 -0
- package/src/brief.js +512 -0
- package/src/cli.js +1447 -0
- package/src/client.js +49 -0
- package/src/config.js +109 -0
- package/src/conflicts.js +105 -0
- package/src/engines/antigravity.js +296 -0
- package/src/engines/claude.js +98 -0
- package/src/engines/codex.js +331 -0
- package/src/engines/index.js +37 -0
- package/src/fleet.js +176 -0
- package/src/git.js +340 -0
- package/src/hub.js +857 -0
- package/src/hubactions.js +107 -0
- package/src/hubbacklog.js +373 -0
- package/src/hublink.js +437 -0
- package/src/inbox.js +94 -0
- package/src/judge.js +278 -0
- package/src/livebrowser.js +637 -0
- package/src/manager.js +2554 -0
- package/src/notify.js +284 -0
- package/src/plugins.js +119 -0
- package/src/preview.js +334 -0
- package/src/races.js +192 -0
- package/src/remote.js +284 -0
- package/src/server.js +759 -0
- package/src/service.js +150 -0
- package/src/sessions.js +160 -0
- package/src/store.js +104 -0
- package/src/stuck.js +160 -0
- package/src/tracker.js +311 -0
- package/src/trust.js +44 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Shared by the main UI and the hub's terminal.
|
|
2
|
+
/**
|
|
3
|
+
* Swipe to scroll on phones and tablets: xterm.js doesn't scroll on touch. In the normal
|
|
4
|
+
* buffer this moves through the scrollback; in the alternate screen (full-screen apps) it
|
|
5
|
+
* sends wheel events so the app scrolls itself. A flick keeps scrolling and slows down.
|
|
6
|
+
*/
|
|
7
|
+
export function touchScroll(el, xterm) {
|
|
8
|
+
let lastY = null;
|
|
9
|
+
let lastT = 0;
|
|
10
|
+
let carry = 0; // fraction of a line not scrolled yet
|
|
11
|
+
let velocity = 0; // px per ms
|
|
12
|
+
let glide = 0;
|
|
13
|
+
const lineHeight = () => el.querySelector('.xterm-screen')?.clientHeight / xterm.rows || 17;
|
|
14
|
+
const scrollPx = (dy) => {
|
|
15
|
+
carry += dy / lineHeight();
|
|
16
|
+
const lines = Math.trunc(carry);
|
|
17
|
+
if (!lines) return;
|
|
18
|
+
carry -= lines;
|
|
19
|
+
if (xterm.buffer.active.type === 'normal') {
|
|
20
|
+
xterm.scrollLines(lines);
|
|
21
|
+
} else {
|
|
22
|
+
const target = el.querySelector('.xterm-screen');
|
|
23
|
+
const r = target.getBoundingClientRect();
|
|
24
|
+
for (let i = 0; i < Math.abs(lines); i++) {
|
|
25
|
+
target.dispatchEvent(
|
|
26
|
+
new WheelEvent('wheel', { deltaY: Math.sign(lines) * lineHeight(), deltaMode: 0, clientX: r.left + 10, clientY: r.top + 10, bubbles: true, cancelable: true }),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
el.addEventListener(
|
|
32
|
+
'touchstart',
|
|
33
|
+
(e) => {
|
|
34
|
+
cancelAnimationFrame(glide);
|
|
35
|
+
if (e.touches.length !== 1) return (lastY = null);
|
|
36
|
+
lastY = e.touches[0].clientY;
|
|
37
|
+
lastT = e.timeStamp;
|
|
38
|
+
velocity = 0;
|
|
39
|
+
carry = 0;
|
|
40
|
+
},
|
|
41
|
+
{ passive: true },
|
|
42
|
+
);
|
|
43
|
+
el.addEventListener(
|
|
44
|
+
'touchmove',
|
|
45
|
+
(e) => {
|
|
46
|
+
if (lastY === null || e.touches.length !== 1) return;
|
|
47
|
+
const y = e.touches[0].clientY;
|
|
48
|
+
const dy = lastY - y; // finger up = later content
|
|
49
|
+
const dt = Math.max(1, e.timeStamp - lastT);
|
|
50
|
+
velocity = 0.8 * (dy / dt) + 0.2 * velocity;
|
|
51
|
+
lastY = y;
|
|
52
|
+
lastT = e.timeStamp;
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
scrollPx(dy);
|
|
55
|
+
},
|
|
56
|
+
{ passive: false },
|
|
57
|
+
);
|
|
58
|
+
el.addEventListener('touchend', (e) => {
|
|
59
|
+
if (lastY === null) return;
|
|
60
|
+
lastY = null;
|
|
61
|
+
if (e.timeStamp - lastT > 80 || Math.abs(velocity) < 0.2) return; // finger paused: no flick
|
|
62
|
+
let prev = performance.now();
|
|
63
|
+
const step = (now) => {
|
|
64
|
+
const dt = now - prev;
|
|
65
|
+
prev = now;
|
|
66
|
+
velocity *= Math.pow(0.995, dt);
|
|
67
|
+
if (Math.abs(velocity) < 0.03) return;
|
|
68
|
+
scrollPx(velocity * dt);
|
|
69
|
+
glide = requestAnimationFrame(step);
|
|
70
|
+
};
|
|
71
|
+
glide = requestAnimationFrame(step);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// How the page talks to the daemon. Locally: WebSockets. Over remote HTTPS:
|
|
2
|
+
// Server-Sent Events plus POSTs, because iOS Safari applies an accepted
|
|
3
|
+
// self-signed certificate to pages and fetches but not to WebSockets.
|
|
4
|
+
|
|
5
|
+
export const REMOTE = location.protocol === 'https:';
|
|
6
|
+
|
|
7
|
+
export const post = (url, body) =>
|
|
8
|
+
fetch(url, {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
credentials: 'same-origin',
|
|
11
|
+
headers: { 'content-type': 'application/json', 'x-agentmgr': '1' },
|
|
12
|
+
body: JSON.stringify(body ?? {}),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Open the event stream (`{ events: true }`) or a terminal (`{ agentId, kind }`).
|
|
17
|
+
* Handlers: onOpen(), onMessage(msg), onClose({ code, reason }) when it ends for good.
|
|
18
|
+
* Returns { send(msg), close() } where msg is { t: 'i', d } (input) or { t: 'r', cols, rows } (resize).
|
|
19
|
+
*/
|
|
20
|
+
export function openStream({ events = false, agentId, kind = 'claude' }, h) {
|
|
21
|
+
if (!REMOTE) {
|
|
22
|
+
const path = events ? '/ws/events' : `/ws/term/${agentId}${kind === 'claude' ? '' : `?kind=${encodeURIComponent(kind)}`}`;
|
|
23
|
+
const ws = new WebSocket(`ws://${location.host}${path}`);
|
|
24
|
+
ws.onopen = () => h.onOpen?.();
|
|
25
|
+
ws.onmessage = (ev) => h.onMessage(JSON.parse(ev.data));
|
|
26
|
+
ws.onclose = (ev) => h.onClose?.({ code: ev.code, reason: ev.reason });
|
|
27
|
+
return {
|
|
28
|
+
send: (msg) => ws.readyState === WebSocket.OPEN && ws.send(JSON.stringify(msg)),
|
|
29
|
+
close: () => ws.close(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const url = events ? '/api/events/stream' : `/api/agents/${agentId}/term/stream?kind=${encodeURIComponent(kind)}`;
|
|
34
|
+
const es = new EventSource(url);
|
|
35
|
+
let closed = false;
|
|
36
|
+
es.onopen = () => h.onOpen?.();
|
|
37
|
+
es.onmessage = (ev) => h.onMessage(JSON.parse(ev.data));
|
|
38
|
+
es.onerror = () => {
|
|
39
|
+
// CONNECTING means the browser is retrying by itself (network blip); CLOSED is final (e.g. 401).
|
|
40
|
+
if (es.readyState === EventSource.CLOSED && !closed) {
|
|
41
|
+
closed = true;
|
|
42
|
+
h.onClose?.({ code: 0, reason: 'stream closed' });
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Keystrokes: coalesced and sent strictly in order, one request at a time.
|
|
47
|
+
let queue = '';
|
|
48
|
+
let inflight = false;
|
|
49
|
+
const pump = async () => {
|
|
50
|
+
if (inflight || !queue) return;
|
|
51
|
+
inflight = true;
|
|
52
|
+
const d = queue;
|
|
53
|
+
queue = '';
|
|
54
|
+
try {
|
|
55
|
+
await post(`/api/agents/${agentId}/term/input`, { d, kind });
|
|
56
|
+
} finally {
|
|
57
|
+
inflight = false;
|
|
58
|
+
if (queue) pump();
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
let resizeTimer;
|
|
62
|
+
return {
|
|
63
|
+
send(msg) {
|
|
64
|
+
if (closed) return;
|
|
65
|
+
if (msg.t === 'i') {
|
|
66
|
+
queue += msg.d;
|
|
67
|
+
setTimeout(pump, 8);
|
|
68
|
+
} else if (msg.t === 'r') {
|
|
69
|
+
clearTimeout(resizeTimer);
|
|
70
|
+
resizeTimer = setTimeout(() => post(`/api/agents/${agentId}/term/resize`, { cols: msg.cols, rows: msg.rows, kind }), 150);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
close() {
|
|
74
|
+
closed = true;
|
|
75
|
+
es.close();
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Builds self-contained agentmgr bundles (Node runtime + app + node_modules).
|
|
3
|
+
# Usage: scripts/build-release.sh <out-dir> [target...]
|
|
4
|
+
# targets: darwin-arm64 darwin-x64 linux-x64 (linux-x64 must be built on linux-x64)
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
|
8
|
+
OUT=$(mkdir -p "${1:?out dir}" && cd "$1" && pwd); shift
|
|
9
|
+
TARGETS=("${@:-darwin-arm64 darwin-x64}")
|
|
10
|
+
NODE_VERSION=${NODE_VERSION:-22.22.0}
|
|
11
|
+
VERSION=$(node -p "require('$ROOT/package.json').version")
|
|
12
|
+
WORK=$(mktemp -d)
|
|
13
|
+
trap 'rm -rf "$WORK"' EXIT
|
|
14
|
+
|
|
15
|
+
# Install production deps once from the app source.
|
|
16
|
+
mkdir -p "$WORK/app"
|
|
17
|
+
cp -R "$ROOT/bin" "$ROOT/src" "$ROOT/public" "$ROOT/scripts" "$ROOT/package.json" "$ROOT/package-lock.json" "$ROOT/README.md" "$WORK/app/"
|
|
18
|
+
(cd "$WORK/app" && npm ci --omit=dev --ignore-scripts --no-audit --no-fund >/dev/null)
|
|
19
|
+
|
|
20
|
+
for t in ${TARGETS[@]}; do
|
|
21
|
+
echo "==> $t"
|
|
22
|
+
name="agentmanager-$VERSION-$t"
|
|
23
|
+
dest="$WORK/$name"
|
|
24
|
+
mkdir -p "$dest/runtime" "$dest/bin"
|
|
25
|
+
cp -R "$WORK/app" "$dest/app"
|
|
26
|
+
|
|
27
|
+
pty="$dest/app/node_modules/node-pty"
|
|
28
|
+
if [[ $t == linux-* ]]; then
|
|
29
|
+
# No linux prebuilds ship with node-pty; compile against the bundled Node.
|
|
30
|
+
(cd "$pty" && npx --yes node-gyp@11 rebuild --target="$NODE_VERSION" >/dev/null)
|
|
31
|
+
rm -rf "$pty/prebuilds"
|
|
32
|
+
else
|
|
33
|
+
find "$pty/prebuilds" -mindepth 1 -maxdepth 1 ! -name "$t" -exec rm -rf {} +
|
|
34
|
+
chmod 755 "$pty/prebuilds/$t/spawn-helper"
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
curl -fsSL "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-$t.tar.gz" \
|
|
38
|
+
| tar -xz -C "$WORK" "node-v$NODE_VERSION-$t/bin/node"
|
|
39
|
+
mv "$WORK/node-v$NODE_VERSION-$t/bin/node" "$dest/runtime/node"
|
|
40
|
+
rm -rf "$WORK/node-v$NODE_VERSION-$t"
|
|
41
|
+
|
|
42
|
+
cat > "$dest/bin/agentmgr" <<'SH'
|
|
43
|
+
#!/bin/sh
|
|
44
|
+
# Resolve symlinks so `ln -s .../bin/agentmgr ~/.local/bin/agentmgr` works.
|
|
45
|
+
SELF=$0
|
|
46
|
+
while [ -L "$SELF" ]; do
|
|
47
|
+
LINK=$(readlink "$SELF")
|
|
48
|
+
case $LINK in /*) SELF=$LINK ;; *) SELF=$(dirname "$SELF")/$LINK ;; esac
|
|
49
|
+
done
|
|
50
|
+
DIR=$(cd "$(dirname "$SELF")/.." && pwd)
|
|
51
|
+
exec "$DIR/runtime/node" "$DIR/app/bin/agentmgr.js" "$@"
|
|
52
|
+
SH
|
|
53
|
+
chmod 755 "$dest/bin/agentmgr"
|
|
54
|
+
|
|
55
|
+
tar -czf "$OUT/$name.tar.gz" -C "$WORK" "$name"
|
|
56
|
+
rm -rf "$dest"
|
|
57
|
+
echo " $OUT/$name.tar.gz ($(du -h "$OUT/$name.tar.gz" | cut -f1))"
|
|
58
|
+
done
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// node-pty's macOS prebuilds ship spawn-helper without the execute bit,
|
|
2
|
+
// which makes every spawn fail with "posix_spawnp failed".
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
const root = path.dirname(createRequire(import.meta.url).resolve('node-pty/package.json'));
|
|
9
|
+
for (const dir of ['prebuilds/darwin-arm64', 'prebuilds/darwin-x64', 'build/Release']) {
|
|
10
|
+
const helper = path.join(root, dir, 'spawn-helper');
|
|
11
|
+
if (fs.existsSync(helper)) fs.chmodSync(helper, 0o755);
|
|
12
|
+
}
|
|
13
|
+
} catch {
|
|
14
|
+
// node-pty not installed yet; nothing to fix
|
|
15
|
+
}
|
package/src/backlog.js
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// The backlog: work orders that agents pick up automatically, one item per
|
|
2
|
+
// agent, up to a parallel limit per project, until each item is complete.
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { EventEmitter } from 'node:events';
|
|
5
|
+
import { parsePriority, projectOf } from './manager.js';
|
|
6
|
+
import { dispatchPrompt, hash } from './judge.js';
|
|
7
|
+
|
|
8
|
+
const now = () => new Date().toISOString();
|
|
9
|
+
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
10
|
+
|
|
11
|
+
// queued → active → review → done; blocked / cancelled on the side.
|
|
12
|
+
const OPEN = new Set(['queued', 'active', 'review', 'blocked']);
|
|
13
|
+
|
|
14
|
+
export class Backlog extends EventEmitter {
|
|
15
|
+
constructor({ manager, store, config }) {
|
|
16
|
+
super();
|
|
17
|
+
this.manager = manager;
|
|
18
|
+
this.store = store;
|
|
19
|
+
this.config = config;
|
|
20
|
+
store.data.backlog ??= { items: {}, limits: {}, autoRun: true };
|
|
21
|
+
this.data = store.data.backlog;
|
|
22
|
+
this.dispatching = false;
|
|
23
|
+
this.plan = null; // the AI judge's dispatch plan: { hash, data, pending }
|
|
24
|
+
|
|
25
|
+
manager.on('change', (agent) => this.onAgent(agent));
|
|
26
|
+
manager.on('removed', (id) => this.onAgentRemoved(id));
|
|
27
|
+
this.timer = setInterval(() => this.dispatch().catch(() => {}), 2000);
|
|
28
|
+
this.timer.unref();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ------------------------------------------------------------ queries
|
|
32
|
+
|
|
33
|
+
defaultLimit() {
|
|
34
|
+
return this.config.backlogParallel ?? 2;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
limitFor(projectKey) {
|
|
38
|
+
return this.data.limits[projectKey] ?? this.defaultLimit();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
items() {
|
|
42
|
+
return Object.values(this.data.items).sort(
|
|
43
|
+
(a, b) => a.priority - b.priority || a.order - b.order || a.createdAt.localeCompare(b.createdAt),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
view() {
|
|
48
|
+
const items = this.items().map((it) => {
|
|
49
|
+
const agent = it.agentId ? this.store.get(it.agentId) : null;
|
|
50
|
+
return {
|
|
51
|
+
...it,
|
|
52
|
+
agent: agent
|
|
53
|
+
? {
|
|
54
|
+
id: agent.id,
|
|
55
|
+
name: agent.name,
|
|
56
|
+
status: agent.status,
|
|
57
|
+
briefStatus: agent.brief?.status ?? null,
|
|
58
|
+
attention: this.manager.view(agent).runtime.attention,
|
|
59
|
+
}
|
|
60
|
+
: null,
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
const projects = {};
|
|
64
|
+
for (const it of items) {
|
|
65
|
+
const shortcut = Object.values(this.store.data.projects ?? {}).find((x) => x.key === it.projectKey)?.shortcut ?? null;
|
|
66
|
+
const p = (projects[it.projectKey] ??= { key: it.projectKey, name: it.projectName, shortcut, limit: this.limitFor(it.projectKey), counts: {} });
|
|
67
|
+
p.counts[it.status] = (p.counts[it.status] ?? 0) + 1;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
autoRun: this.data.autoRun,
|
|
71
|
+
defaultLimit: this.defaultLimit(),
|
|
72
|
+
maxActive: this.config.backlogMaxActive ?? 4,
|
|
73
|
+
projects: Object.values(projects),
|
|
74
|
+
items,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
changed() {
|
|
79
|
+
this.store.save();
|
|
80
|
+
this.emit('change', this.view());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get(id) {
|
|
84
|
+
const it = this.data.items[id];
|
|
85
|
+
if (!it) throw err(404, `No backlog item "${id}"`);
|
|
86
|
+
return it;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ------------------------------------------------------------ editing
|
|
90
|
+
|
|
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 } = {}) {
|
|
93
|
+
const list = titles.map((t) => String(t ?? '').trim()).filter(Boolean);
|
|
94
|
+
if (!list.length) throw err(400, 'At least one item is required');
|
|
95
|
+
const pr = parsePriority(priority);
|
|
96
|
+
// A project shortcut fills in the repo / folder / base branch.
|
|
97
|
+
const entry = project ? this.manager.resolveProject(project) : null;
|
|
98
|
+
if (entry) {
|
|
99
|
+
if (!repo && !dir) {
|
|
100
|
+
repo = entry.repo ?? undefined;
|
|
101
|
+
dir = entry.dir ?? undefined;
|
|
102
|
+
}
|
|
103
|
+
base ??= entry.base ?? undefined;
|
|
104
|
+
project = entry.key.startsWith('custom:') ? entry.name : undefined;
|
|
105
|
+
}
|
|
106
|
+
const p = projectOf({ project: project?.trim() || null, repo: repo?.trim() || null, requestedDir: dir?.trim() || null });
|
|
107
|
+
const order = Math.max(0, ...Object.values(this.data.items).map((i) => i.order ?? 0)) + 1;
|
|
108
|
+
const added = list.map((title, i) => {
|
|
109
|
+
const it = {
|
|
110
|
+
id: crypto.randomBytes(3).toString('hex'),
|
|
111
|
+
title,
|
|
112
|
+
projectKey: p.key,
|
|
113
|
+
projectName: p.name,
|
|
114
|
+
project: project?.trim() || null,
|
|
115
|
+
repo: repo?.trim() || null,
|
|
116
|
+
dir: dir?.trim() || null,
|
|
117
|
+
base: base?.trim() || null,
|
|
118
|
+
// Parallel agents on one folder must not trample each other.
|
|
119
|
+
worktree: worktree ?? true,
|
|
120
|
+
permissionMode: permissionMode || null,
|
|
121
|
+
model: model || null,
|
|
122
|
+
engine: engine || null,
|
|
123
|
+
plan: Boolean(plan),
|
|
124
|
+
planReview: planReview ?? null,
|
|
125
|
+
priority: pr,
|
|
126
|
+
order: order + i,
|
|
127
|
+
status: 'queued',
|
|
128
|
+
agentId: null,
|
|
129
|
+
notes: '',
|
|
130
|
+
createdAt: now(),
|
|
131
|
+
startedAt: null,
|
|
132
|
+
reviewAt: null,
|
|
133
|
+
doneAt: null,
|
|
134
|
+
};
|
|
135
|
+
this.data.items[it.id] = it;
|
|
136
|
+
return it;
|
|
137
|
+
});
|
|
138
|
+
this.changed();
|
|
139
|
+
this.dispatch().catch(() => {});
|
|
140
|
+
return added;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
update(id, fields) {
|
|
144
|
+
const it = this.get(id);
|
|
145
|
+
if ('title' in fields && it.status === 'queued') it.title = String(fields.title).trim() || it.title;
|
|
146
|
+
if ('priority' in fields) {
|
|
147
|
+
it.priority = parsePriority(fields.priority, it.priority);
|
|
148
|
+
if (it.agentId && this.store.get(it.agentId)) this.manager.update(it.agentId, { priority: it.priority });
|
|
149
|
+
}
|
|
150
|
+
if ('notes' in fields) it.notes = String(fields.notes ?? '');
|
|
151
|
+
this.changed();
|
|
152
|
+
return it;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Reorder within the queue: "up", "down", "top" or "bottom" among items of the same priority. */
|
|
156
|
+
move(id, where) {
|
|
157
|
+
const it = this.get(id);
|
|
158
|
+
const peers = this.items().filter((x) => x.priority === it.priority && x.status === 'queued');
|
|
159
|
+
const i = peers.findIndex((x) => x.id === id);
|
|
160
|
+
if (i < 0) throw err(409, 'Only queued items can be reordered');
|
|
161
|
+
const j = { up: i - 1, down: i + 1, top: 0, bottom: peers.length - 1 }[where];
|
|
162
|
+
if (j === undefined) throw err(400, 'where must be up, down, top or bottom');
|
|
163
|
+
const target = Math.max(0, Math.min(peers.length - 1, j));
|
|
164
|
+
peers.splice(target, 0, ...peers.splice(i, 1));
|
|
165
|
+
const base = Math.min(...peers.map((x) => x.order));
|
|
166
|
+
peers.forEach((x, k) => (x.order = base + k));
|
|
167
|
+
this.changed();
|
|
168
|
+
return it;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
remove(id) {
|
|
172
|
+
const it = this.get(id);
|
|
173
|
+
if (it.status === 'active') throw err(409, 'Cancel the item first (its agent is working on it)');
|
|
174
|
+
delete this.data.items[id];
|
|
175
|
+
this.changed();
|
|
176
|
+
return { id };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
settings({ autoRun, limits, project, limit } = {}) {
|
|
180
|
+
if (typeof autoRun === 'boolean') this.data.autoRun = autoRun;
|
|
181
|
+
if (limits && typeof limits === 'object') Object.assign(this.data.limits, limits);
|
|
182
|
+
if (project !== undefined && limit !== undefined) {
|
|
183
|
+
const key =
|
|
184
|
+
this.manager.resolveProject(project)?.key ??
|
|
185
|
+
this.items().find((x) => x.projectKey === project || x.projectName === project)?.projectKey ??
|
|
186
|
+
project;
|
|
187
|
+
const n = Number(limit);
|
|
188
|
+
if (!(n >= 0 && n <= 20)) throw err(400, 'limit must be 0-20');
|
|
189
|
+
this.data.limits[key] = n;
|
|
190
|
+
}
|
|
191
|
+
this.changed();
|
|
192
|
+
this.dispatch().catch(() => {});
|
|
193
|
+
return this.view();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ------------------------------------------------------------ lifecycle
|
|
197
|
+
|
|
198
|
+
/** Start an item now, even if its project is at its limit or the backlog is paused. */
|
|
199
|
+
start(id) {
|
|
200
|
+
const it = this.get(id);
|
|
201
|
+
if (it.status !== 'queued' && it.status !== 'blocked') throw err(409, `Item is ${it.status}`);
|
|
202
|
+
if (it.status === 'blocked' && it.agentId && this.store.get(it.agentId)) {
|
|
203
|
+
this.manager.start(it.agentId); // retries a failed setup
|
|
204
|
+
Object.assign(it, { status: 'active', activeSince: now() });
|
|
205
|
+
this.changed();
|
|
206
|
+
return it;
|
|
207
|
+
}
|
|
208
|
+
return this.launch(it);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
launch(it) {
|
|
212
|
+
const agent = this.manager.create({
|
|
213
|
+
intent: it.title,
|
|
214
|
+
repo: it.repo,
|
|
215
|
+
dir: it.dir,
|
|
216
|
+
base: it.base,
|
|
217
|
+
worktree: it.worktree,
|
|
218
|
+
project: it.project,
|
|
219
|
+
permissionMode: it.permissionMode,
|
|
220
|
+
model: it.model,
|
|
221
|
+
engine: it.engine ?? undefined,
|
|
222
|
+
plan: it.plan,
|
|
223
|
+
planReview: it.planReview ?? undefined,
|
|
224
|
+
priority: it.priority,
|
|
225
|
+
name: undefined,
|
|
226
|
+
});
|
|
227
|
+
Object.assign(it, { status: 'active', agentId: agent.id, startedAt: now(), activeSince: now() });
|
|
228
|
+
this.store.patch(agent.id, { backlogItem: it.id });
|
|
229
|
+
this.changed();
|
|
230
|
+
return it;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Accept the agent's work: the item is done. */
|
|
234
|
+
accept(id) {
|
|
235
|
+
const it = this.get(id);
|
|
236
|
+
if (!['review', 'active', 'blocked'].includes(it.status)) throw err(409, `Item is ${it.status}`);
|
|
237
|
+
Object.assign(it, { status: 'done', doneAt: now() });
|
|
238
|
+
this.changed();
|
|
239
|
+
return it;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Send it back: the same agent session continues, with the reviewer's note. */
|
|
243
|
+
reopen(id, { note } = {}) {
|
|
244
|
+
const it = this.get(id);
|
|
245
|
+
if (!['review', 'done', 'blocked'].includes(it.status)) throw err(409, `Item is ${it.status}`);
|
|
246
|
+
if (!it.agentId || !this.store.get(it.agentId)) {
|
|
247
|
+
Object.assign(it, { status: 'queued', agentId: null, reviewAt: null, doneAt: null });
|
|
248
|
+
this.changed();
|
|
249
|
+
return it;
|
|
250
|
+
}
|
|
251
|
+
// Only a brief update after this point counts as "done" again.
|
|
252
|
+
Object.assign(it, { status: 'active', reviewAt: null, doneAt: null, activeSince: now() });
|
|
253
|
+
this.changed();
|
|
254
|
+
const agentId = it.agentId;
|
|
255
|
+
const message = `Reopened from review${note ? `: ${note}` : '.'} Please continue until the task is complete, and set the brief's Status back to done when it is.`;
|
|
256
|
+
// Resume if closed, then type the note into its session.
|
|
257
|
+
Promise.resolve(this.manager.start(agentId))
|
|
258
|
+
.then(() => new Promise((r) => setTimeout(r, 1500)))
|
|
259
|
+
.then(() => {
|
|
260
|
+
this.manager.write(agentId, message);
|
|
261
|
+
setTimeout(() => this.manager.write(agentId, '\r'), 200);
|
|
262
|
+
})
|
|
263
|
+
.catch(() => {});
|
|
264
|
+
return it;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
cancel(id) {
|
|
268
|
+
const it = this.get(id);
|
|
269
|
+
if (!OPEN.has(it.status)) throw err(409, `Item is ${it.status}`);
|
|
270
|
+
it.status = 'cancelled';
|
|
271
|
+
this.changed();
|
|
272
|
+
return it;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ------------------------------------------------------------ automation
|
|
276
|
+
|
|
277
|
+
/** Follow the agent: its brief saying "done", or the agent ending, sends the item to review. */
|
|
278
|
+
onAgent(agent) {
|
|
279
|
+
const it = agent.backlogItem && this.data.items[agent.backlogItem];
|
|
280
|
+
if (!it) return;
|
|
281
|
+
let next = it.status;
|
|
282
|
+
if (it.status === 'active') {
|
|
283
|
+
if (agent.status === 'error') next = 'blocked';
|
|
284
|
+
else if (agent.brief?.status === 'done' && agent.brief.updatedAt > (it.activeSince ?? '')) next = 'review';
|
|
285
|
+
// The AI judge read the agent saying it finished (it may not have updated its brief).
|
|
286
|
+
else if (agent.aiReview?.verdict && agent.aiReview.trigger === 'screen' && agent.aiReview.at > (it.activeSince ?? '')) next = 'review';
|
|
287
|
+
else if (agent.status === 'exited' || (agent.status === 'stopped' && agent.desired === 'stopped')) next = 'review';
|
|
288
|
+
} else if (it.status === 'blocked' && agent.status === 'running') {
|
|
289
|
+
next = 'active';
|
|
290
|
+
it.activeSince = now();
|
|
291
|
+
}
|
|
292
|
+
if (next !== it.status) {
|
|
293
|
+
it.status = next;
|
|
294
|
+
if (next === 'review') it.reviewAt = now();
|
|
295
|
+
this.changed();
|
|
296
|
+
if (next !== 'active') this.dispatch().catch(() => {});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
onAgentRemoved(id) {
|
|
301
|
+
for (const it of Object.values(this.data.items)) {
|
|
302
|
+
if (it.agentId === id && OPEN.has(it.status) && it.status !== 'queued') {
|
|
303
|
+
it.status = 'cancelled';
|
|
304
|
+
this.changed();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The order to consider queued items in. Priority always wins; within a priority the AI judge
|
|
311
|
+
* may move up what others depend on, hold back an item that waits for another open item or
|
|
312
|
+
* would collide with work in progress, and suggest splitting one that bundles several tasks.
|
|
313
|
+
* Resolves to null while the judge is thinking (dispatch tries again shortly).
|
|
314
|
+
*/
|
|
315
|
+
planned(items) {
|
|
316
|
+
const queued = items.filter((x) => x.status === 'queued');
|
|
317
|
+
const active = items.filter((x) => x.status === 'active');
|
|
318
|
+
const judge = this.manager.judge;
|
|
319
|
+
if (!judge?.enabled || !queued.length || (queued.length < 2 && !active.length)) return items;
|
|
320
|
+
const h = hash(JSON.stringify([queued.map((x) => [x.id, x.title, x.priority]), active.map((x) => x.id)]));
|
|
321
|
+
if (this.plan?.hash !== h) {
|
|
322
|
+
this.plan = { hash: h, data: null, pending: true };
|
|
323
|
+
const changes = (x) => (x.agentId && this.manager.changeSets.get(x.agentId)) || null;
|
|
324
|
+
const prompt = dispatchPrompt(
|
|
325
|
+
queued.map((x) => ({ id: x.id, title: x.title, priority: x.priority, project: x.projectName })),
|
|
326
|
+
active.map((x) => ({ id: x.id, title: x.title, project: x.projectName, changes: changes(x) })),
|
|
327
|
+
judge.examples('dispatch'),
|
|
328
|
+
);
|
|
329
|
+
judge.ask(`dispatch:${h}`, null, prompt, (d) => Array.isArray(d.order)).then((data) => {
|
|
330
|
+
if (this.plan?.hash !== h) return;
|
|
331
|
+
this.plan = { hash: h, data, pending: false };
|
|
332
|
+
this.applyPlan(data);
|
|
333
|
+
this.dispatch().catch(() => {});
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
if (this.plan.pending) return null;
|
|
337
|
+
const d = this.plan.data;
|
|
338
|
+
if (!d) return items;
|
|
339
|
+
const rank = new Map(d.order.map((id, i) => [id, i]));
|
|
340
|
+
const r = (x) => rank.get(x.id) ?? Infinity;
|
|
341
|
+
const held = new Set((d.hold ?? []).filter((x) => this.data.items[x.after] && OPEN.has(this.data.items[x.after].status) && x.after !== x.id).map((x) => x.id));
|
|
342
|
+
return [...items].filter((x) => !held.has(x.id)).sort((a, b) => a.priority - b.priority || r(a) - r(b) || a.order - b.order);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Keep the judge's holds and split suggestions on the items, for the backlog view. */
|
|
346
|
+
applyPlan(d) {
|
|
347
|
+
let changed = false;
|
|
348
|
+
for (const it of Object.values(this.data.items)) {
|
|
349
|
+
const hold = d && it.status === 'queued' ? (d.hold ?? []).find((x) => x.id === it.id && this.data.items[x.after] && x.after !== it.id) : null;
|
|
350
|
+
const split = d && it.status === 'queued' ? (d.split ?? []).find((x) => x.id === it.id && Array.isArray(x.parts) && x.parts.length > 1) : null;
|
|
351
|
+
const next = {
|
|
352
|
+
aiHold: hold ? { after: hold.after, afterTitle: this.data.items[hold.after].title, reason: String(hold.reason ?? '') } : null,
|
|
353
|
+
aiSplit: split && !it.splitDismissed ? { reason: String(split.reason ?? ''), parts: split.parts.map(String).slice(0, 8) } : null,
|
|
354
|
+
};
|
|
355
|
+
if (JSON.stringify([it.aiHold ?? null, it.aiSplit ?? null]) !== JSON.stringify([next.aiHold, next.aiSplit])) {
|
|
356
|
+
Object.assign(it, next);
|
|
357
|
+
changed = true;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (changed) this.changed();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Replace a queued item with several, as the judge suggested (or as edited). */
|
|
364
|
+
split(id, parts) {
|
|
365
|
+
const it = this.get(id);
|
|
366
|
+
if (it.status !== 'queued') throw err(409, 'Only queued items can be split');
|
|
367
|
+
const titles = (Array.isArray(parts) ? parts : it.aiSplit?.parts ?? []).map((t) => String(t).trim()).filter(Boolean);
|
|
368
|
+
if (titles.length < 2) throw err(400, 'Give at least two parts');
|
|
369
|
+
const { repo, dir, base, project, worktree, permissionMode, model, plan, planReview, engine, priority } = it;
|
|
370
|
+
const added = this.add(titles, { repo, dir, base, project, worktree, permissionMode, model, plan, planReview, engine, priority });
|
|
371
|
+
this.manager.judge?.feedback('dispatch', { summary: `split "${it.title}"`, verdict: it.aiSplit ? 'split suggested' : 'not suggested', outcome: 'split it' });
|
|
372
|
+
delete this.data.items[id];
|
|
373
|
+
this.changed();
|
|
374
|
+
return added;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Keep an item whole although the judge suggested splitting it. */
|
|
378
|
+
keepWhole(id) {
|
|
379
|
+
const it = this.get(id);
|
|
380
|
+
if (it.aiSplit) this.manager.judge?.feedback('dispatch', { summary: `split "${it.title}" into ${it.aiSplit.parts.length}`, verdict: 'split suggested', outcome: 'kept it whole' });
|
|
381
|
+
Object.assign(it, { aiSplit: null, splitDismissed: true });
|
|
382
|
+
this.changed();
|
|
383
|
+
return it;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Start queued items wherever a project has a free slot (and the global cap allows). */
|
|
387
|
+
async dispatch() {
|
|
388
|
+
if (!this.data.autoRun || this.dispatching || this.manager.closing) return [];
|
|
389
|
+
this.dispatching = true;
|
|
390
|
+
try {
|
|
391
|
+
const started = [];
|
|
392
|
+
const all = this.items();
|
|
393
|
+
let active = all.filter((x) => x.status === 'active').length;
|
|
394
|
+
const max = this.config.backlogMaxActive ?? 4;
|
|
395
|
+
// Nothing can start: don't spend a judge call on the order.
|
|
396
|
+
if (active >= max || !all.some((x) => x.status === 'queued' && all.filter((y) => y.projectKey === x.projectKey && y.status === 'active').length < this.limitFor(x.projectKey))) return [];
|
|
397
|
+
const items = this.planned(all);
|
|
398
|
+
if (!items) return [];
|
|
399
|
+
for (const it of items) {
|
|
400
|
+
if (active >= max) break;
|
|
401
|
+
if (it.status !== 'queued') continue;
|
|
402
|
+
const busy = items.filter((x) => x.projectKey === it.projectKey && x.status === 'active').length;
|
|
403
|
+
if (busy >= this.limitFor(it.projectKey)) continue;
|
|
404
|
+
this.launch(it);
|
|
405
|
+
started.push(it.id);
|
|
406
|
+
active++;
|
|
407
|
+
}
|
|
408
|
+
return started;
|
|
409
|
+
} finally {
|
|
410
|
+
this.dispatching = false;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|