@stilero/bankan 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.
@@ -0,0 +1,16 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Ban Kan</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@600;700;800&display=swap" rel="stylesheet" />
10
+ <script type="module" crossorigin src="/assets/index-hxSMA1kc.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-BZkAflU1.css">
12
+ </head>
13
+ <body>
14
+ <div id="root"></div>
15
+ </body>
16
+ </html>
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@stilero/bankan",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Local AI agent orchestration dashboard with a global `bankan` CLI.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/stilero/bankan.git"
10
+ },
11
+ "homepage": "https://github.com/stilero/bankan#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/stilero/bankan/issues"
14
+ },
15
+ "keywords": [
16
+ "ai",
17
+ "agents",
18
+ "kanban",
19
+ "dashboard",
20
+ "cli",
21
+ "orchestration"
22
+ ],
23
+ "bin": {
24
+ "bankan": "./bin/bankan.js"
25
+ },
26
+ "files": [
27
+ "bin",
28
+ "client/dist",
29
+ "LICENSE",
30
+ "scripts/setup.js",
31
+ "server/src",
32
+ "README.md"
33
+ ],
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "scripts": {
38
+ "setup": "node scripts/setup.js",
39
+ "build": "npm run build --prefix client",
40
+ "dev": "concurrently -n server,client -c cyan,magenta \"npm run dev --prefix server\" \"npm run dev --prefix client\"",
41
+ "start": "npm run dev",
42
+ "install:all": "npm install && npm install --prefix server && npm install --prefix client",
43
+ "prepack": "npm run build"
44
+ },
45
+ "dependencies": {
46
+ "cors": "^2.8.5",
47
+ "dotenv": "^16.4.7",
48
+ "express": "^4.18.3",
49
+ "node-pty": "^1.2.0-beta.11",
50
+ "simple-git": "^3.22.0",
51
+ "uuid": "^9.0.1",
52
+ "ws": "^8.16.0"
53
+ },
54
+ "devDependencies": {
55
+ "concurrently": "^8.2.2"
56
+ }
57
+ }
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createInterface } from 'node:readline';
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
5
+ import { execSync } from 'node:child_process';
6
+ import { join, dirname } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { getRuntimePaths } from '../server/src/paths.js';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const ROOT = join(__dirname, '..');
12
+ const IS_PACKAGED_RUNTIME = process.env.BANKAN_RUNTIME_MODE === 'packaged';
13
+ const runtimePaths = getRuntimePaths();
14
+ const ENV_FILE = runtimePaths.envFile;
15
+
16
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
17
+
18
+ function ask(question) {
19
+ return new Promise((resolve) => rl.question(question, resolve));
20
+ }
21
+
22
+ function dim(text) { return `\x1b[2m${text}\x1b[0m`; }
23
+ function green(text) { return `\x1b[32m${text}\x1b[0m`; }
24
+ function yellow(text) { return `\x1b[33m${text}\x1b[0m`; }
25
+ function red(text) { return `\x1b[31m${text}\x1b[0m`; }
26
+ function bold(text) { return `\x1b[1m${text}\x1b[0m`; }
27
+ function cyan(text) { return `\x1b[36m${text}\x1b[0m`; }
28
+
29
+ function checkCommand(cmd) {
30
+ try {
31
+ execSync(`which ${cmd}`, { stdio: 'pipe' });
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ function getNodeVersion() {
39
+ try {
40
+ const ver = execSync('node --version', { stdio: 'pipe', encoding: 'utf-8' }).trim();
41
+ return parseInt(ver.replace('v', '').split('.')[0], 10);
42
+ } catch {
43
+ return 0;
44
+ }
45
+ }
46
+
47
+ function loadExistingEnv() {
48
+ const vars = {};
49
+ try {
50
+ if (existsSync(ENV_FILE)) {
51
+ const content = readFileSync(ENV_FILE, 'utf-8');
52
+ for (const line of content.split('\n')) {
53
+ const trimmed = line.trim();
54
+ if (!trimmed || trimmed.startsWith('#')) continue;
55
+ const eqIdx = trimmed.indexOf('=');
56
+ if (eqIdx === -1) continue;
57
+ vars[trimmed.slice(0, eqIdx).trim()] = trimmed.slice(eqIdx + 1).trim();
58
+ }
59
+ }
60
+ } catch { /* ignore */ }
61
+ return vars;
62
+ }
63
+
64
+ async function main() {
65
+ console.clear();
66
+ console.log('');
67
+ console.log(cyan(bold(' ╔═══════════════════════════════════════╗')));
68
+ console.log(cyan(bold(' ║ BAN KAN Setup ║')));
69
+ console.log(cyan(bold(' ╚═══════════════════════════════════════╝')));
70
+ console.log('');
71
+ console.log(' Local AI agent orchestration dashboard.');
72
+ console.log(` This wizard will configure your environment${IS_PACKAGED_RUNTIME ? ' and save it under your user profile' : ''}.\n`);
73
+
74
+ // Step 1: Prerequisites
75
+ console.log(bold(' Prerequisites\n'));
76
+
77
+ const nodeVer = getNodeVersion();
78
+ if (nodeVer < 18) {
79
+ console.log(` ${red('✗')} Node.js >= 18 required (found v${nodeVer})`);
80
+ console.log(` Install: ${dim('https://nodejs.org')}`);
81
+ process.exit(1);
82
+ }
83
+ console.log(` ${green('✓')} Node.js v${nodeVer}`);
84
+
85
+ const tools = [
86
+ { cmd: 'git', hint: 'Install via system package manager' },
87
+ { cmd: 'claude', hint: 'npm install -g @anthropic-ai/claude-code' },
88
+ { cmd: 'codex', hint: 'npm install -g @openai/codex' },
89
+ ];
90
+
91
+ let hasAnyCLI = false;
92
+ for (const tool of tools) {
93
+ if (checkCommand(tool.cmd)) {
94
+ console.log(` ${green('✓')} ${tool.cmd}`);
95
+ if (tool.cmd === 'claude' || tool.cmd === 'codex') hasAnyCLI = true;
96
+ } else {
97
+ console.log(` ${yellow('⚠')} ${tool.cmd} not found ${dim(`(${tool.hint})`)}`);
98
+ }
99
+ }
100
+
101
+ if (!hasAnyCLI) {
102
+ console.log(`\n ${red('⚠')} Neither claude nor codex CLI found. At least one is required.\n`);
103
+ }
104
+
105
+ // Check native build tools
106
+ const platform = process.platform;
107
+ if (platform === 'darwin') {
108
+ try {
109
+ execSync('xcode-select -p', { stdio: 'pipe' });
110
+ console.log(` ${green('✓')} Xcode command line tools`);
111
+ } catch {
112
+ console.log(` ${yellow('⚠')} Xcode CLI tools missing ${dim('(xcode-select --install)')}`);
113
+ }
114
+ } else if (platform === 'linux') {
115
+ if (checkCommand('cc')) {
116
+ console.log(` ${green('✓')} C compiler`);
117
+ } else {
118
+ console.log(` ${yellow('⚠')} C compiler missing ${dim('(apt install build-essential)')}`);
119
+ }
120
+ }
121
+
122
+ console.log('');
123
+
124
+ // Step 2: Load existing config
125
+ const existing = loadExistingEnv();
126
+ const config = { ...existing };
127
+
128
+ // Step 3: Project Config
129
+ console.log(bold(' Project Configuration\n'));
130
+
131
+ const reposHint = existing.REPOS ? dim(` (${existing.REPOS}), Enter to keep`) : '';
132
+ const reposAnswer = await ask(` REPOS (comma-separated git repo paths)${reposHint}: `);
133
+ if (reposAnswer.trim()) {
134
+ config.REPOS = reposAnswer.trim();
135
+ }
136
+
137
+ if (config.REPOS) {
138
+ const repoPaths = config.REPOS.split(',').map(s => s.trim()).filter(Boolean);
139
+ for (const repoPath of repoPaths) {
140
+ try {
141
+ execSync(`git -C "${repoPath}" rev-parse HEAD`, { stdio: 'pipe' });
142
+ console.log(` ${green('✓')} ${repoPath} — valid git repo`);
143
+ } catch {
144
+ console.log(` ${yellow('⚠')} ${repoPath} — not a git repo or no commits yet`);
145
+ }
146
+ }
147
+ }
148
+
149
+ const ghRepoHint = existing.GITHUB_REPO ? dim(` (${existing.GITHUB_REPO}), Enter to keep`) : dim(' (optional, owner/repo)');
150
+ const ghRepoAnswer = await ask(` GITHUB_REPO${ghRepoHint}: `);
151
+ if (ghRepoAnswer.trim()) config.GITHUB_REPO = ghRepoAnswer.trim();
152
+
153
+ const ghTokenHint = existing.GITHUB_TOKEN ? dim(' (already set, Enter to keep)') : dim(' (optional)');
154
+ const ghTokenAnswer = await ask(` GITHUB_TOKEN${ghTokenHint}: `);
155
+ if (ghTokenAnswer.trim()) config.GITHUB_TOKEN = ghTokenAnswer.trim();
156
+
157
+ console.log('');
158
+
159
+ // Step 5: Agent Config
160
+ console.log(bold(' Agent Configuration\n'));
161
+
162
+ const imp1Default = existing.IMPLEMENTOR_1_CLI || 'claude';
163
+ const imp1Answer = await ask(` IMPLEMENTOR_1_CLI ${dim(`[${imp1Default}]`)}: `);
164
+ config.IMPLEMENTOR_1_CLI = imp1Answer.trim() || imp1Default;
165
+
166
+ const imp2Default = existing.IMPLEMENTOR_2_CLI || 'codex';
167
+ const imp2Answer = await ask(` IMPLEMENTOR_2_CLI ${dim(`[${imp2Default}]`)}: `);
168
+ config.IMPLEMENTOR_2_CLI = imp2Answer.trim() || imp2Default;
169
+
170
+ config.PORT = existing.PORT || '3001';
171
+
172
+ console.log('');
173
+
174
+ // Step 6: Write .env.local
175
+ mkdirSync(runtimePaths.dataDir, { recursive: true });
176
+ const envLines = [
177
+ `REPOS=${config.REPOS || ''}`,
178
+ `GITHUB_REPO=${config.GITHUB_REPO || ''}`,
179
+ `GITHUB_TOKEN=${config.GITHUB_TOKEN || ''}`,
180
+ `IMPLEMENTOR_1_CLI=${config.IMPLEMENTOR_1_CLI || 'claude'}`,
181
+ `IMPLEMENTOR_2_CLI=${config.IMPLEMENTOR_2_CLI || 'codex'}`,
182
+ `PORT=${config.PORT}`,
183
+ ];
184
+ writeFileSync(ENV_FILE, envLines.join('\n') + '\n');
185
+ console.log(` ${green('✓')} Config written to ${ENV_FILE}\n`);
186
+
187
+ if (IS_PACKAGED_RUNTIME) {
188
+ console.log(green(bold(' ✓ Setup complete!')));
189
+ console.log('');
190
+ console.log(` Configuration stored at: ${cyan(runtimePaths.dataDir)}`);
191
+ console.log('');
192
+ rl.close();
193
+ return;
194
+ }
195
+
196
+ // Step 7: Install Dependencies
197
+ console.log(bold(' Installing dependencies...\n'));
198
+
199
+ const installSteps = [
200
+ { label: 'root', cmd: 'npm install' },
201
+ { label: 'server', cmd: `npm install --prefix "${join(ROOT, 'server')}"` },
202
+ { label: 'client', cmd: `npm install --prefix "${join(ROOT, 'client')}"` },
203
+ ];
204
+
205
+ for (const step of installSteps) {
206
+ console.log(` Installing ${step.label} dependencies...`);
207
+ try {
208
+ execSync(step.cmd, { cwd: ROOT, stdio: 'inherit' });
209
+ console.log(` ${green('✓')} ${step.label}\n`);
210
+ } catch (err) {
211
+ console.log(` ${red('✗')} ${step.label} install failed. Try running manually: ${step.cmd}\n`);
212
+ }
213
+ }
214
+
215
+ // Step 8: Success
216
+ console.log('');
217
+ console.log(green(bold(' ✓ Setup complete!')));
218
+ console.log('');
219
+ console.log(' To start:');
220
+ console.log(cyan(' npm start'));
221
+ console.log('');
222
+ console.log(` Then open: ${cyan('http://localhost:5173')}`);
223
+ console.log('');
224
+
225
+ rl.close();
226
+ }
227
+
228
+ main().catch((err) => {
229
+ console.error('Setup failed:', err);
230
+ rl.close();
231
+ process.exit(1);
232
+ });
@@ -0,0 +1,401 @@
1
+ import pty from 'node-pty';
2
+ import { existsSync, statSync, appendFileSync } from 'node:fs';
3
+ import bus from './events.js';
4
+ import { loadSettings } from './config.js';
5
+ import store from './store.js';
6
+
7
+ const ROLE_META = {
8
+ planner: {
9
+ prefix: 'plan',
10
+ namePrefix: 'Planner',
11
+ role: 'Plan Generation',
12
+ icon: '\u270E',
13
+ color: '#6AABDB',
14
+ },
15
+ implementor: {
16
+ prefix: 'imp',
17
+ namePrefix: 'Implementor',
18
+ role: 'Code Generation',
19
+ icon: '\u2692',
20
+ colors: ['#A78BFA', '#34D399', '#60A5FA', '#F472B6', '#FB923C', '#A3E635', '#22D3EE', '#E879F9'],
21
+ },
22
+ reviewer: {
23
+ prefix: 'rev',
24
+ namePrefix: 'Reviewer',
25
+ role: 'Code Review',
26
+ icon: '\u2714',
27
+ color: '#FFD166',
28
+ },
29
+ };
30
+
31
+ const TOKEN_PATTERNS = [
32
+ /tokens used\s*[:\r\n]+\s*(\d[\d, ]*)/i,
33
+ /total tokens\s*[:=]\s*(\d[\d, ]*)/i,
34
+ /total[_ ]tokens["'\s:=>]+(\d[\d, ]*)/i,
35
+ /context(?: used)?\s*:\s*(\d[\d, ]*)/i,
36
+ /(\d[\d, ]*)\s+(?:input\s+)?tokens\b/i,
37
+ ];
38
+
39
+ class Agent {
40
+ constructor(def) {
41
+ this.id = def.id;
42
+ this.name = def.name;
43
+ this.role = def.role;
44
+ this.icon = def.icon;
45
+ this.color = def.color;
46
+ this.cli = def.cli || 'claude';
47
+ this.draining = false;
48
+ this.status = 'idle';
49
+ this.currentTask = null;
50
+ this.taskLabel = '';
51
+ this.tokens = 0;
52
+ this.taskTokenBase = 0;
53
+ this.maxTokens = 200000;
54
+ this.startedAt = null;
55
+ this.process = null;
56
+ this.terminalBuffer = [];
57
+ this.subscribers = new Set();
58
+ this.lastOutputAt = null;
59
+ this.bridge = {
60
+ active: false,
61
+ mode: null,
62
+ owner: null,
63
+ openedAt: null,
64
+ outputPath: null,
65
+ };
66
+ }
67
+
68
+ spawn(cwd, command) {
69
+ if (this.process) this.kill();
70
+
71
+ // Validate cwd is an existing directory
72
+ if (!cwd || !existsSync(cwd) || !statSync(cwd).isDirectory()) {
73
+ const errorMsg = `\r\n[ERROR] Invalid working directory: ${cwd}\r\n`;
74
+ this.terminalBuffer.push(errorMsg);
75
+ for (const ws of this.subscribers) {
76
+ try {
77
+ ws.send(JSON.stringify({
78
+ type: 'TERMINAL_DATA',
79
+ payload: { agentId: this.id, data: errorMsg },
80
+ ts: Date.now(),
81
+ }));
82
+ } catch { this.subscribers.delete(ws); }
83
+ }
84
+ return false;
85
+ }
86
+
87
+ this.status = 'active';
88
+ this.startedAt = Date.now();
89
+ this.terminalBuffer = [];
90
+ this.tokens = 0;
91
+ this.taskTokenBase = this.currentTask ? (store.getTask(this.currentTask)?.totalTokens || 0) : 0;
92
+ this.lastOutputAt = Date.now();
93
+ this.bridge = {
94
+ active: false,
95
+ mode: null,
96
+ owner: null,
97
+ openedAt: null,
98
+ outputPath: null,
99
+ };
100
+
101
+ const env = { ...process.env, TERM: 'xterm-256color' };
102
+ delete env.CLAUDECODE;
103
+ this.process = pty.spawn('bash', ['-l', '-c', command], {
104
+ name: 'xterm-256color',
105
+ cols: 220,
106
+ rows: 50,
107
+ cwd,
108
+ env,
109
+ });
110
+
111
+ this.process.onData((data) => {
112
+ this.terminalBuffer.push(data);
113
+ if (this.terminalBuffer.length > 500) {
114
+ this.terminalBuffer.shift();
115
+ }
116
+ this.lastOutputAt = Date.now();
117
+ this._parseTokens(data);
118
+ this._syncTaskTokens();
119
+ if (this.bridge.active && this.bridge.outputPath) {
120
+ try { appendFileSync(this.bridge.outputPath, data); } catch { /* ignore */ }
121
+ }
122
+
123
+ for (const ws of this.subscribers) {
124
+ try {
125
+ ws.send(JSON.stringify({
126
+ type: 'TERMINAL_DATA',
127
+ payload: { agentId: this.id, data },
128
+ ts: Date.now(),
129
+ }));
130
+ } catch {
131
+ this.subscribers.delete(ws);
132
+ }
133
+ }
134
+ });
135
+
136
+ this.process.onExit(() => {
137
+ this.process = null;
138
+ if (this.status === 'active') {
139
+ this.status = 'idle';
140
+ if (this.currentTask) {
141
+ bus.emit('agent:unexpected-exit', { agentId: this.id, taskId: this.currentTask });
142
+ }
143
+ }
144
+ });
145
+
146
+ bus.emit('agent:updated', this.getStatus());
147
+ return true;
148
+ }
149
+
150
+ _parseTokens(data) {
151
+ const recentBuffer = this.getBufferString(80);
152
+ for (const source of [recentBuffer, data]) {
153
+ for (const pattern of TOKEN_PATTERNS) {
154
+ const match = source.match(pattern);
155
+ if (!match) continue;
156
+ const parsed = parseInt(match[1].replace(/[,\s]/g, ''), 10);
157
+ if (Number.isFinite(parsed) && parsed > this.tokens) {
158
+ this.tokens = parsed;
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ _syncTaskTokens() {
165
+ if (!this.currentTask || this.tokens <= 0) return;
166
+ store.updateTaskTokens(this.currentTask, this.taskTokenBase + this.tokens);
167
+ bus.emit('agent:updated', this.getStatus());
168
+ }
169
+
170
+ write(data) {
171
+ if (this.process) {
172
+ this.process.write(data);
173
+ return true;
174
+ }
175
+
176
+ const errorMsg = '\r\n[ERROR] Agent is not running. Resolve the blocker and retry the task before sending input.\r\n';
177
+ this.terminalBuffer.push(errorMsg);
178
+ if (this.terminalBuffer.length > 500) {
179
+ this.terminalBuffer.shift();
180
+ }
181
+ for (const ws of this.subscribers) {
182
+ try {
183
+ ws.send(JSON.stringify({
184
+ type: 'TERMINAL_DATA',
185
+ payload: { agentId: this.id, data: errorMsg },
186
+ ts: Date.now(),
187
+ }));
188
+ } catch {
189
+ this.subscribers.delete(ws);
190
+ }
191
+ }
192
+ return false;
193
+ }
194
+
195
+ kill() {
196
+ if (this.process) {
197
+ try { this.process.kill(); } catch { /* ignore */ }
198
+ this.process = null;
199
+ }
200
+ this.status = 'idle';
201
+ this.currentTask = null;
202
+ this.taskLabel = '';
203
+ this.taskTokenBase = 0;
204
+ this.bridge = {
205
+ active: false,
206
+ mode: null,
207
+ owner: null,
208
+ openedAt: null,
209
+ outputPath: null,
210
+ };
211
+ bus.emit('agent:updated', this.getStatus());
212
+ }
213
+
214
+ getBufferString(chunks = 50) {
215
+ return this.terminalBuffer.slice(-chunks).join('');
216
+ }
217
+
218
+ getStatus() {
219
+ return {
220
+ id: this.id,
221
+ name: this.name,
222
+ role: this.role,
223
+ icon: this.icon,
224
+ color: this.color,
225
+ status: this.draining ? 'draining' : this.status,
226
+ task: this.taskLabel,
227
+ currentTask: this.currentTask,
228
+ tokens: this.tokens,
229
+ maxTokens: this.maxTokens,
230
+ uptime: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1000) : 0,
231
+ bridgeActive: this.bridge.active,
232
+ bridgeMode: this.bridge.mode,
233
+ bridgeOwner: this.bridge.owner,
234
+ bridgeOpenedAt: this.bridge.openedAt,
235
+ aggregatedTokens: this.currentTask
236
+ ? Math.max(store.getTask(this.currentTask)?.totalTokens || 0, this.taskTokenBase + this.tokens)
237
+ : 0,
238
+ };
239
+ }
240
+ }
241
+
242
+ const ROLE_MAP = {
243
+ planners: { meta: ROLE_META.planner, prefix: 'plan' },
244
+ implementors: { meta: ROLE_META.implementor, prefix: 'imp' },
245
+ reviewers: { meta: ROLE_META.reviewer, prefix: 'rev' },
246
+ };
247
+
248
+ class AgentManager {
249
+ constructor() {
250
+ this.agents = new Map();
251
+ this._maxSettings = {}; // { planners: 4, implementors: 8, reviewers: 4 }
252
+ this._cliSettings = {}; // { planners: 'claude', implementors: 'claude', reviewers: 'claude' }
253
+
254
+ // Orchestrator is always present
255
+ const orch = new Agent({
256
+ id: 'orch',
257
+ name: 'Orchestrator',
258
+ role: 'Pipeline Control',
259
+ icon: '\u2699',
260
+ color: '#F5A623',
261
+ });
262
+ orch.status = 'active';
263
+ orch.startedAt = Date.now();
264
+ orch.taskLabel = 'Pipeline Control';
265
+ this.agents.set('orch', orch);
266
+
267
+ // Create initial agents from settings
268
+ this.reconfigure(loadSettings());
269
+ }
270
+
271
+ reconfigure(settings) {
272
+ for (const [settingsKey, { meta, prefix }] of Object.entries(ROLE_MAP)) {
273
+ const cfg = settings.agents[settingsKey];
274
+ this._maxSettings[settingsKey] = cfg.max;
275
+ this._cliSettings[settingsKey] = cfg.cli;
276
+
277
+ // Ensure an agent exists only when the role is enabled
278
+ const current = this.getAgentsByRole(prefix);
279
+ if (cfg.max > 0 && current.length === 0) {
280
+ const color = meta.colors ? meta.colors[0] : meta.color;
281
+ const agent = new Agent({
282
+ id: `${prefix}-1`,
283
+ name: `${meta.namePrefix} 1`,
284
+ role: meta.role,
285
+ icon: meta.icon,
286
+ color,
287
+ cli: cfg.cli,
288
+ });
289
+ this.agents.set(agent.id, agent);
290
+ bus.emit('agent:updated', agent.getStatus());
291
+ }
292
+
293
+ // Scale down if current count exceeds new max
294
+ const currentAgents = this.getAgentsByRole(prefix);
295
+ if (currentAgents.length > cfg.max) {
296
+ const toRemove = currentAgents.slice(cfg.max);
297
+ for (const agent of toRemove) {
298
+ if (agent.status === 'idle') {
299
+ this.removeAgent(agent.id);
300
+ } else {
301
+ agent.draining = true;
302
+ bus.emit('agent:updated', agent.getStatus());
303
+ }
304
+ }
305
+ }
306
+
307
+ // Update CLI on all existing non-draining agents for this role
308
+ for (const agent of this.getAgentsByRole(prefix)) {
309
+ if (!agent.draining) {
310
+ agent.cli = cfg.cli;
311
+ }
312
+ }
313
+ }
314
+ }
315
+
316
+ // Scale up a role by one agent, returns the new agent or null if at max
317
+ scaleUp(settingsKey) {
318
+ const { meta, prefix } = ROLE_MAP[settingsKey];
319
+ const max = this._maxSettings[settingsKey] ?? 1;
320
+ const cli = this._cliSettings[settingsKey] || 'claude';
321
+ const current = this.getAgentsByRole(prefix);
322
+
323
+ if (current.length >= max) return null;
324
+
325
+ const nextNum = current.length > 0
326
+ ? parseInt(current[current.length - 1].id.split('-')[1], 10) + 1
327
+ : 1;
328
+
329
+ const color = meta.colors ? meta.colors[(nextNum - 1) % meta.colors.length] : meta.color;
330
+ const agent = new Agent({
331
+ id: `${prefix}-${nextNum}`,
332
+ name: `${meta.namePrefix} ${nextNum}`,
333
+ role: meta.role,
334
+ icon: meta.icon,
335
+ color,
336
+ cli,
337
+ });
338
+ this.agents.set(agent.id, agent);
339
+ bus.emit('agent:updated', agent.getStatus());
340
+ return agent;
341
+ }
342
+
343
+ getMaxForRole(settingsKey) {
344
+ return this._maxSettings[settingsKey] ?? 1;
345
+ }
346
+
347
+ get(id) {
348
+ return this.agents.get(id);
349
+ }
350
+
351
+ getAllStatus() {
352
+ return Array.from(this.agents.values()).map(a => a.getStatus());
353
+ }
354
+
355
+ getAgentsByRole(prefix) {
356
+ return Array.from(this.agents.values())
357
+ .filter(a => a.id.startsWith(prefix + '-'))
358
+ .sort((a, b) => {
359
+ const numA = parseInt(a.id.split('-')[1], 10);
360
+ const numB = parseInt(b.id.split('-')[1], 10);
361
+ return numA - numB;
362
+ });
363
+ }
364
+
365
+ getAvailableByRole(prefix) {
366
+ return this.getAgentsByRole(prefix).find(a => a.status === 'idle' && !a.draining) || null;
367
+ }
368
+
369
+ getAvailablePlanner() {
370
+ return this.getAvailableByRole('plan');
371
+ }
372
+
373
+ getAvailableImplementor() {
374
+ return this.getAvailableByRole('imp');
375
+ }
376
+
377
+ getAvailableReviewer() {
378
+ return this.getAvailableByRole('rev');
379
+ }
380
+
381
+ removeAgent(id) {
382
+ const agent = this.agents.get(id);
383
+ if (!agent) return;
384
+ // Clean up subscribers
385
+ for (const ws of agent.subscribers) {
386
+ try {
387
+ ws.send(JSON.stringify({
388
+ type: 'TERMINAL_DATA',
389
+ payload: { agentId: id, data: '\r\n[Agent removed]\r\n' },
390
+ ts: Date.now(),
391
+ }));
392
+ } catch { /* ignore */ }
393
+ }
394
+ agent.subscribers.clear();
395
+ this.agents.delete(id);
396
+ bus.emit('agent:removed', { agentId: id });
397
+ }
398
+ }
399
+
400
+ const agentManager = new AgentManager();
401
+ export default agentManager;