@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.
- package/LICENSE +21 -0
- package/README.md +363 -0
- package/bin/bankan.js +148 -0
- package/client/dist/assets/index-BZkAflU1.css +32 -0
- package/client/dist/assets/index-hxSMA1kc.js +48 -0
- package/client/dist/index.html +16 -0
- package/package.json +57 -0
- package/scripts/setup.js +232 -0
- package/server/src/agents.js +401 -0
- package/server/src/config.js +228 -0
- package/server/src/events.js +6 -0
- package/server/src/index.js +774 -0
- package/server/src/orchestrator.js +1193 -0
- package/server/src/paths.js +55 -0
- package/server/src/store.js +287 -0
|
@@ -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
|
+
}
|
package/scripts/setup.js
ADDED
|
@@ -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;
|