ai-agentmanager 1.0.1 → 1.1.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 +22 -0
- package/README.md +76 -4
- package/bin/agentmgr.js +10 -3
- package/docs/architecture.md +1 -0
- package/docs/images/agents.jpg +0 -0
- package/docs/images/conflict-radar.jpg +0 -0
- package/package.json +1 -1
- package/public/app.js +518 -11
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/icon.svg +1 -0
- package/public/index.html +97 -0
- package/public/manifest.webmanifest +16 -0
- package/public/style.css +69 -3
- package/src/cleanup.js +185 -0
- package/src/cli.js +181 -0
- package/src/config.js +4 -0
- package/src/doctor.js +113 -0
- package/src/engines/antigravity.js +1 -1
- package/src/engines/claude.js +22 -5
- package/src/engines/codex.js +1 -1
- package/src/engines/index.js +8 -0
- package/src/judge.js +12 -0
- package/src/manager.js +65 -12
- package/src/server.js +44 -2
- package/src/teams.js +984 -0
- package/src/tracker.js +11 -1
package/src/manager.js
CHANGED
|
@@ -4,7 +4,7 @@ import fs from 'node:fs';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import pty from 'node-pty';
|
|
6
6
|
import { expandHome, paths } from './config.js';
|
|
7
|
-
import { DEFAULT_ENGINE, ENGINES, engineOf, installed } from './engines/index.js';
|
|
7
|
+
import { DEFAULT_ENGINE, ENGINES, engineOf, installed, notInstalled } from './engines/index.js';
|
|
8
8
|
import {
|
|
9
9
|
DOC_KINDS,
|
|
10
10
|
briefDir,
|
|
@@ -42,13 +42,15 @@ import * as git from './git.js';
|
|
|
42
42
|
import { conflictMessage, findConflicts, related, repoKey, summarize as summarizeConflicts } from './conflicts.js';
|
|
43
43
|
import { answerMessage, loadQuestions, questionsPath, questionsPrompt } from './inbox.js';
|
|
44
44
|
import { STUCK_DEFAULTS, detectStuck } from './stuck.js';
|
|
45
|
-
import { Judge, clashPrompt, finishMessage, hash, reviewPrompt, screenPrompt, stuckPrompt } from './judge.js';
|
|
45
|
+
import { Judge, clashPrompt, finishMessage, hash, reviewPrompt, screenPrompt, stripChrome, stuckPrompt } from './judge.js';
|
|
46
46
|
import { findLocalUrls, findSignInLinks, parseCallback, parseUrl } from './preview.js';
|
|
47
47
|
import { Plugins } from './plugins.js';
|
|
48
|
+
import { leadPrompt, subagents, teamDir, teamPrompt } from './teams.js';
|
|
48
49
|
import { runningClaude, scanClaudeSessions } from './sessions.js';
|
|
49
50
|
import {
|
|
50
51
|
bugFromRef,
|
|
51
52
|
extractRefs,
|
|
53
|
+
leadingJiraKey,
|
|
52
54
|
prDetails,
|
|
53
55
|
prFromUrl,
|
|
54
56
|
prsForBranch,
|
|
@@ -191,6 +193,7 @@ export class AgentManager extends EventEmitter {
|
|
|
191
193
|
this.config = config;
|
|
192
194
|
this.plugins = plugins; // policies before actions, listeners after (src/plugins.js)
|
|
193
195
|
this.runtimes = new Map(); // agent id -> { pty, chunks, size, clients, lastOutputAt, busy, stopping, exited }
|
|
196
|
+
this.inputAt = new Map(); // agent id -> when you last typed into its session or a shell
|
|
194
197
|
this.shells = new Map(); // agent id -> ancillary shell runtime in the agent's folder
|
|
195
198
|
this.cursors = new Map(); // session id -> transcript scan cursor
|
|
196
199
|
this.locks = new Map();
|
|
@@ -268,6 +271,7 @@ export class AgentManager extends EventEmitter {
|
|
|
268
271
|
...agent,
|
|
269
272
|
results: this.results(agent, rt, prs),
|
|
270
273
|
priority: agent.priority ?? 2,
|
|
274
|
+
activeAt: this.activeAt(agent, rt),
|
|
271
275
|
projectInfo: this.projectInfo(agent),
|
|
272
276
|
usage,
|
|
273
277
|
// Claude Code's own cost figure from the transcripts (absent until it records one).
|
|
@@ -295,6 +299,19 @@ export class AgentManager extends EventEmitter {
|
|
|
295
299
|
};
|
|
296
300
|
}
|
|
297
301
|
|
|
302
|
+
/**
|
|
303
|
+
* When the agent last did real work or you last worked with it: its conversation,
|
|
304
|
+
* its brief, your typing, or output while busy (not the redraw after a restart).
|
|
305
|
+
*/
|
|
306
|
+
activeAt(agent, rt) {
|
|
307
|
+
const times = [agent.createdAt, agent.brief?.updatedAt, ...(agent.sessions ?? []).map((s) => s.lastMessageAt ?? s.lastActivity)]
|
|
308
|
+
.map((t) => (t ? Date.parse(t) : 0));
|
|
309
|
+
times.push(this.inputAt.get(agent.id) ?? 0);
|
|
310
|
+
if (rt?.pty && rt.busy && rt.lastOutputAt - (rt.startedAt ?? 0) > 60_000) times.push(rt.lastOutputAt);
|
|
311
|
+
const t = Math.max(0, ...times.filter(Number.isFinite));
|
|
312
|
+
return t ? new Date(t).toISOString() : null;
|
|
313
|
+
}
|
|
314
|
+
|
|
298
315
|
/**
|
|
299
316
|
* The end result / status / blockers for an agent: what the agent wrote under
|
|
300
317
|
* "## Results", plus what AI Agent Manager knows (PRs, checks, prompts, errors).
|
|
@@ -491,6 +508,7 @@ export class AgentManager extends EventEmitter {
|
|
|
491
508
|
priority,
|
|
492
509
|
workspace,
|
|
493
510
|
race,
|
|
511
|
+
team,
|
|
494
512
|
engine,
|
|
495
513
|
sessionId: adopt,
|
|
496
514
|
imported,
|
|
@@ -523,7 +541,9 @@ export class AgentManager extends EventEmitter {
|
|
|
523
541
|
const id = crypto.randomBytes(3).toString('hex');
|
|
524
542
|
// An imported session keeps its ID, so the first start resumes it.
|
|
525
543
|
const sessionId = adopt || crypto.randomUUID();
|
|
526
|
-
|
|
544
|
+
// A request that starts with a Jira link is named after the issue ("ABC-123"), not the link.
|
|
545
|
+
const jiraKey = !name?.trim() && intent ? leadingJiraKey(intent) : null;
|
|
546
|
+
const slug = name || intent ? slugify(name || jiraKey || intent) : `general-${id}`;
|
|
527
547
|
const refs = extractRefs(intent, this.config.bugPatterns);
|
|
528
548
|
|
|
529
549
|
const agent = {
|
|
@@ -532,7 +552,7 @@ export class AgentManager extends EventEmitter {
|
|
|
532
552
|
engine: engineId,
|
|
533
553
|
project: project?.trim() || null,
|
|
534
554
|
priority: parsePriority(priority),
|
|
535
|
-
name: name?.trim() || slug,
|
|
555
|
+
name: name?.trim() || jiraKey || slug,
|
|
536
556
|
slug,
|
|
537
557
|
intent,
|
|
538
558
|
repo: general ? null : repo?.trim() || null,
|
|
@@ -547,7 +567,8 @@ export class AgentManager extends EventEmitter {
|
|
|
547
567
|
permissionMode: permissionMode || this.config.permissionMode,
|
|
548
568
|
model: model || null,
|
|
549
569
|
// Review the implementation plan before the agent implements it: null = no review.
|
|
550
|
-
|
|
570
|
+
// Team members get their review from the team instead.
|
|
571
|
+
planReview: !general && !team && (planReview ?? this.config.planReview ?? false) ? { status: 'writing' } : null,
|
|
551
572
|
status: 'preparing',
|
|
552
573
|
desired: start ? 'running' : 'stopped',
|
|
553
574
|
error: null,
|
|
@@ -565,6 +586,7 @@ export class AgentManager extends EventEmitter {
|
|
|
565
586
|
lastStartedAt: null,
|
|
566
587
|
stoppedAt: null,
|
|
567
588
|
...(race ? { raceId: race.id, variant: race.variant } : {}),
|
|
589
|
+
...(team ? { team } : {}),
|
|
568
590
|
...(imported ? { imported } : {}),
|
|
569
591
|
};
|
|
570
592
|
for (const pr of refs.prs) agent.prs[pr.url] = { ...pr, source: 'intent' };
|
|
@@ -805,7 +827,8 @@ export class AgentManager extends EventEmitter {
|
|
|
805
827
|
sessionId: sid,
|
|
806
828
|
resume,
|
|
807
829
|
name: agent.name,
|
|
808
|
-
|
|
830
|
+
// A team member's first message is its first step, not just the problem.
|
|
831
|
+
prompt: resume ? (handoff ? handoffMessage(agent) : null) : (agent.kickoff ?? agent.intent),
|
|
809
832
|
});
|
|
810
833
|
const proc = this.spawnAgent(agent, agent.workdir, args);
|
|
811
834
|
|
|
@@ -823,6 +846,7 @@ export class AgentManager extends EventEmitter {
|
|
|
823
846
|
lastStartedAt: now(),
|
|
824
847
|
sessions: agent.sessions,
|
|
825
848
|
sessionId: sid,
|
|
849
|
+
kickoff: undefined,
|
|
826
850
|
...(handoff ? { planning: { ...agent.planning, ready: false, handedOffAt: now() } } : {}),
|
|
827
851
|
});
|
|
828
852
|
return this.view(this.store.get(agent.id));
|
|
@@ -832,6 +856,7 @@ export class AgentManager extends EventEmitter {
|
|
|
832
856
|
launchOptions(agent, { planning = false } = {}) {
|
|
833
857
|
const engine = engineOf(agent);
|
|
834
858
|
let writableDir = null;
|
|
859
|
+
let agents = null;
|
|
835
860
|
const prompts = [];
|
|
836
861
|
const task = agent.kind !== 'general';
|
|
837
862
|
const withBrief = Boolean(this.config.brief && task);
|
|
@@ -851,8 +876,21 @@ export class AgentManager extends EventEmitter {
|
|
|
851
876
|
prompts.push(agent.planReview.status === 'approved' ? planApprovedPrompt(file) : planReviewPrompt(file));
|
|
852
877
|
}
|
|
853
878
|
if (planning) prompts.push(planningPrompt(agent));
|
|
879
|
+
if (agent.team) {
|
|
880
|
+
// Team members write their handoff files in the team's folder.
|
|
881
|
+
fs.mkdirSync(teamDir(agent.team.id), { recursive: true });
|
|
882
|
+
const dir = fs.realpathSync(teamDir(agent.team.id));
|
|
883
|
+
writableDir = writableDir ? [writableDir, dir] : dir;
|
|
884
|
+
const team = this.store.data.teams?.[agent.team.id];
|
|
885
|
+
if (agent.team.role === 'lead' && team) {
|
|
886
|
+
prompts.push(leadPrompt(team, dir, agent.baseRef));
|
|
887
|
+
agents = subagents(team, dir);
|
|
888
|
+
} else {
|
|
889
|
+
prompts.push(teamPrompt(agent.team.role, dir));
|
|
890
|
+
}
|
|
891
|
+
}
|
|
854
892
|
// Appended system prompts last only for the process they're passed to, so they go on every launch.
|
|
855
|
-
return { extra: engine.extraArgs(this.config), permissionMode: agent.permissionMode, model: agent.model, writableDir, instructions: prompts.join('\n\n') || null };
|
|
893
|
+
return { extra: engine.extraArgs(this.config), permissionMode: agent.permissionMode, model: agent.model, writableDir, instructions: prompts.join('\n\n') || null, agents };
|
|
856
894
|
}
|
|
857
895
|
|
|
858
896
|
/** Spawn the agent's engine in a PTY, reusing its terminal clients and scrollback. */
|
|
@@ -892,6 +930,11 @@ export class AgentManager extends EventEmitter {
|
|
|
892
930
|
}
|
|
893
931
|
}
|
|
894
932
|
|
|
933
|
+
if (!installed(engine, this.config)) {
|
|
934
|
+
const error = notInstalled(engine, this.config);
|
|
935
|
+
this.patch(agent.id, { status: 'error', error });
|
|
936
|
+
throw Object.assign(new Error(error), { status: 400 });
|
|
937
|
+
}
|
|
895
938
|
let proc;
|
|
896
939
|
try {
|
|
897
940
|
proc = pty.spawn(engine.bin(this.config), args, {
|
|
@@ -1012,7 +1055,8 @@ export class AgentManager extends EventEmitter {
|
|
|
1012
1055
|
stop(ref, { timeoutMs = 5000, keepDesired = false } = {}) {
|
|
1013
1056
|
const agent = this.mustGet(ref);
|
|
1014
1057
|
if (this.runtimes.get(agent.id)?.pty) this.plugins.emit('agent.stopping', { agent: this.auditView(agent) });
|
|
1015
|
-
|
|
1058
|
+
// When it was closed (not a daemon shutdown): clean up counts from here.
|
|
1059
|
+
if (!keepDesired) this.patch(agent.id, { desired: 'stopped', closedAt: now(), closedBy: null });
|
|
1016
1060
|
const rt = this.runtimes.get(agent.id);
|
|
1017
1061
|
if (!rt?.pty) return Promise.resolve(this.view(agent));
|
|
1018
1062
|
rt.stopping = true;
|
|
@@ -1104,7 +1148,9 @@ export class AgentManager extends EventEmitter {
|
|
|
1104
1148
|
}
|
|
1105
1149
|
await this.stop(agent.id);
|
|
1106
1150
|
const warnings = [];
|
|
1107
|
-
if (deleteWorkspace && agent.workdir) {
|
|
1151
|
+
if (deleteWorkspace && agent.workdir && agent.sharedWith) {
|
|
1152
|
+
warnings.push(`Kept ${agent.workdir}: it belongs to ${this.store.get(agent.sharedWith)?.name ?? 'another agent'}`);
|
|
1153
|
+
} else if (deleteWorkspace && agent.workdir) {
|
|
1108
1154
|
try {
|
|
1109
1155
|
if (agent.worktree && agent.sourceDir) {
|
|
1110
1156
|
await git.removeWorktree(agent.sourceDir, agent.workdir);
|
|
@@ -1146,6 +1192,8 @@ export class AgentManager extends EventEmitter {
|
|
|
1146
1192
|
const agent = this.mustGet(ref);
|
|
1147
1193
|
const allowed = {};
|
|
1148
1194
|
for (const key of ['name', 'notes', 'permissionMode', 'model', 'project']) if (key in fields) allowed[key] = fields[key];
|
|
1195
|
+
// Keep: clean up never closes or deletes it.
|
|
1196
|
+
if ('keep' in fields) allowed.keep = Boolean(fields.keep);
|
|
1149
1197
|
if ('priority' in fields) {
|
|
1150
1198
|
const p = parsePriority(fields.priority, null);
|
|
1151
1199
|
if (p === null) throw Object.assign(new Error('priority must be P0, P1, P2 or P3'), { status: 400 });
|
|
@@ -1198,6 +1246,8 @@ export class AgentManager extends EventEmitter {
|
|
|
1198
1246
|
}
|
|
1199
1247
|
|
|
1200
1248
|
write(id, data, kind = 'claude') {
|
|
1249
|
+
// Focus reports and terminal replies are not you typing.
|
|
1250
|
+
if (!/^(\x1b\[[\d;?>]*[IOcRnt])+$/.test(data)) this.inputAt.set(id, Date.now());
|
|
1201
1251
|
this.termRuntime(id, kind)?.pty?.write(data);
|
|
1202
1252
|
}
|
|
1203
1253
|
|
|
@@ -1898,7 +1948,7 @@ export class AgentManager extends EventEmitter {
|
|
|
1898
1948
|
session.usage = usage;
|
|
1899
1949
|
changed = true;
|
|
1900
1950
|
}
|
|
1901
|
-
for (const key of ['title', 'lastPrompt', 'cost', 'lastActivity']) {
|
|
1951
|
+
for (const key of ['title', 'lastPrompt', 'cost', 'lastActivity', 'lastMessageAt']) {
|
|
1902
1952
|
if (res[key] != null && JSON.stringify(session[key]) !== JSON.stringify(res[key])) {
|
|
1903
1953
|
session[key] = res[key];
|
|
1904
1954
|
changed = true;
|
|
@@ -1911,7 +1961,8 @@ export class AgentManager extends EventEmitter {
|
|
|
1911
1961
|
|
|
1912
1962
|
if (withPrs) {
|
|
1913
1963
|
this.lastPrCheck.set(id, Date.now());
|
|
1914
|
-
|
|
1964
|
+
// A shared worktree's pull requests belong to the agent that owns it.
|
|
1965
|
+
const branchPrs = agent.sharedWith ? [] : await prsForBranch(agent.workdir, info?.branch);
|
|
1915
1966
|
changed = this.mergeLinks(agent, agent.prs, branchPrs, (p) => p.url) || changed;
|
|
1916
1967
|
// Fill in title/state for PRs we only know by URL, and keep open ones current.
|
|
1917
1968
|
const stale = Object.values(agent.prs).filter(
|
|
@@ -2253,7 +2304,7 @@ export class AgentManager extends EventEmitter {
|
|
|
2253
2304
|
async judgeScreen(id, rt) {
|
|
2254
2305
|
const agent = this.store.get(id);
|
|
2255
2306
|
if (!agent) return;
|
|
2256
|
-
const screen = this.screenText(rt);
|
|
2307
|
+
const screen = stripChrome(this.screenText(rt), engineOf(agent).screenChrome);
|
|
2257
2308
|
if (!screen) return;
|
|
2258
2309
|
const h = hash(screen);
|
|
2259
2310
|
if (rt.screenVerdict?.hash === h) return this.applyScreen(id, rt, rt.screenVerdict.data);
|
|
@@ -2521,6 +2572,8 @@ export class AgentManager extends EventEmitter {
|
|
|
2521
2572
|
const idleMs = this.judge.settings.idleSec * 1000;
|
|
2522
2573
|
for (const [id, rt] of this.runtimes) {
|
|
2523
2574
|
if (!rt.pty || rt.busy || rt.attention || rt.planning || rt.screenChecked || this.outbox.get(id)?.length) continue;
|
|
2575
|
+
// A team member that finished its step is meant to sit idle until its next turn.
|
|
2576
|
+
if (this.idleExpected?.(id)) continue;
|
|
2524
2577
|
if (!rt.lastOutputAt || t - rt.lastOutputAt < idleMs) continue;
|
|
2525
2578
|
// Output only right after launch (a resume repainting its screen) is not a turn worth reading.
|
|
2526
2579
|
if (rt.lastOutputAt - rt.startedAt < this.judge.settings.warmupSec * 1000) continue;
|
package/src/server.js
CHANGED
|
@@ -10,6 +10,8 @@ import { ensureHome, HOST, loadConfig, paths, PORT, saveConfig } from './config.
|
|
|
10
10
|
import { AgentManager, shellNumber } from './manager.js';
|
|
11
11
|
import { Backlog } from './backlog.js';
|
|
12
12
|
import { Races } from './races.js';
|
|
13
|
+
import { Teams } from './teams.js';
|
|
14
|
+
import { Cleanup } from './cleanup.js';
|
|
13
15
|
import { listEngines, registerEngine } from './engines/index.js';
|
|
14
16
|
import { Plugins, pluginSpecs } from './plugins.js';
|
|
15
17
|
import { HubLink, linkUrls } from './hublink.js';
|
|
@@ -55,9 +57,12 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
55
57
|
const manager = new AgentManager({ store, config, plugins });
|
|
56
58
|
const backlog = new Backlog({ manager, store, config });
|
|
57
59
|
const races = new Races({ manager, store });
|
|
60
|
+
const teams = new Teams({ manager, store, config });
|
|
61
|
+
const cleanup = new Cleanup({ manager, store, config, teams });
|
|
58
62
|
// Alerts link to the phone address when remote access is on, else the local UI.
|
|
59
63
|
const notifier = new Notifier({
|
|
60
64
|
manager,
|
|
65
|
+
teams,
|
|
61
66
|
backlog,
|
|
62
67
|
linkFor: (id) => {
|
|
63
68
|
const phone = remoteServer ? remote.remoteUrls(remotePort).find((u) => !u.vpn) : null;
|
|
@@ -398,6 +403,29 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
398
403
|
api.post('/races/:id/pick', wrap((req) => races.pick(req.params.id, req.body?.agent, { cleanup: Boolean(req.body?.cleanup) })));
|
|
399
404
|
api.delete('/races/:id', wrap((req) => races.remove(req.params.id)));
|
|
400
405
|
|
|
406
|
+
// Clean up old agents: the strategy and days (kept in config.json), what a run would do, run now.
|
|
407
|
+
api.get('/cleanup', wrap(() => cleanup.status()));
|
|
408
|
+
api.put('/cleanup', wrap(async (req) => {
|
|
409
|
+
const next = cleanup.update(req.body ?? {});
|
|
410
|
+
saveConfig({ cleanup: next });
|
|
411
|
+
return cleanup.status();
|
|
412
|
+
}));
|
|
413
|
+
api.post('/cleanup/run', wrap(async (req) => {
|
|
414
|
+
const b = req.body ?? {};
|
|
415
|
+
return cleanup.run({ close: b.close !== false, delete: b.delete !== false, ids: Array.isArray(b.ids) ? b.ids : null });
|
|
416
|
+
}));
|
|
417
|
+
api.get('/teams', wrap(() => teams.list()));
|
|
418
|
+
api.post('/teams', wrap((req) => teams.create(req.body ?? {})));
|
|
419
|
+
api.get('/teams/:id', wrap((req) => teams.view(req.params.id)));
|
|
420
|
+
api.get('/teams/:id/files/:name', wrap((req) => teams.file(req.params.id, req.params.name)));
|
|
421
|
+
api.post('/teams/:id/resume', wrap((req) => teams.resume(req.params.id, req.body ?? {})));
|
|
422
|
+
api.post('/teams/:id/send-back', wrap((req) => teams.sendBack(req.params.id, req.body?.note)));
|
|
423
|
+
api.post('/teams/:id/fix-gaps', wrap((req) => teams.fixGaps(req.params.id, req.body?.note)));
|
|
424
|
+
api.post('/teams/:id/finalize', wrap((req) => teams.finalize(req.params.id, { mode: req.body?.mode, pr: req.body?.pr })));
|
|
425
|
+
api.post('/teams/:id/stop', wrap((req) => teams.stop(req.params.id)));
|
|
426
|
+
api.post('/teams/:id/ask', wrap((req) => teams.ask(req.params.id, req.body?.text)));
|
|
427
|
+
api.delete('/teams/:id', wrap((req) => teams.remove(req.params.id, { agents: req.query.agents === '1' })));
|
|
428
|
+
|
|
401
429
|
api.post('/backlog/settings', wrap((req) => backlog.settings(req.body ?? {})));
|
|
402
430
|
api.patch('/backlog/:id', wrap((req) => backlog.update(req.params.id, req.body ?? {})));
|
|
403
431
|
api.delete('/backlog/:id', wrap((req) => backlog.remove(req.params.id)));
|
|
@@ -523,7 +551,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
523
551
|
const eventSinks = new Set();
|
|
524
552
|
api.get('/events/stream', (req, res) => {
|
|
525
553
|
const send = sse(req, res);
|
|
526
|
-
send({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list() });
|
|
554
|
+
send({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list(), teams: teams.list() });
|
|
527
555
|
eventSinks.add(send);
|
|
528
556
|
req.on('close', () => eventSinks.delete(send));
|
|
529
557
|
});
|
|
@@ -629,6 +657,17 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
629
657
|
}, 300));
|
|
630
658
|
});
|
|
631
659
|
races.on('removed', (id) => broadcast({ t: 'race-removed', id }));
|
|
660
|
+
const teamTimers = new Map();
|
|
661
|
+
teams.on('change', (id) => {
|
|
662
|
+
if (teamTimers.has(id)) return;
|
|
663
|
+
teamTimers.set(id, setTimeout(() => {
|
|
664
|
+
teamTimers.delete(id);
|
|
665
|
+
try {
|
|
666
|
+
broadcast({ t: 'team', team: teams.view(id) });
|
|
667
|
+
} catch {}
|
|
668
|
+
}, 300));
|
|
669
|
+
});
|
|
670
|
+
teams.on('removed', (id) => broadcast({ t: 'team-removed', id }));
|
|
632
671
|
manager.on('projects', (projects) => {
|
|
633
672
|
broadcast({ t: 'projects', projects });
|
|
634
673
|
broadcast({ t: 'backlog', backlog: backlog.view() });
|
|
@@ -638,7 +677,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
638
677
|
const url = new URL(req.url, 'http://x');
|
|
639
678
|
if (url.pathname === '/ws/events') {
|
|
640
679
|
eventClients.add(ws);
|
|
641
|
-
ws.send(JSON.stringify({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list() }));
|
|
680
|
+
ws.send(JSON.stringify({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list(), teams: teams.list() }));
|
|
642
681
|
ws.on('close', () => eventClients.delete(ws));
|
|
643
682
|
return;
|
|
644
683
|
}
|
|
@@ -716,6 +755,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
716
755
|
async function shutdown(reason) {
|
|
717
756
|
if (closing) return;
|
|
718
757
|
closing = true;
|
|
758
|
+
cleanup.close();
|
|
719
759
|
console.log(`[agentmgr] shutting down (${reason}); closing agents so their sessions are saved`);
|
|
720
760
|
await manager.stopAll({ final: true });
|
|
721
761
|
await manager.flush().catch((err) => console.error(`[agentmgr] final flush failed: ${err.message}`));
|
|
@@ -750,6 +790,8 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
750
790
|
hubLink,
|
|
751
791
|
server,
|
|
752
792
|
manager,
|
|
793
|
+
teams,
|
|
794
|
+
cleanup,
|
|
753
795
|
backlog,
|
|
754
796
|
live,
|
|
755
797
|
url: `http://${host}:${port}`,
|