@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,774 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import { WebSocketServer } from 'ws';
4
+ import { createServer } from 'node:http';
5
+ import { readdirSync, statSync, existsSync, rmSync, mkdirSync, writeFileSync, readFileSync, chmodSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { resolve, dirname as pathDirname, join } from 'node:path';
8
+ import { execFileSync } from 'node:child_process';
9
+ import { fileURLToPath } from 'node:url';
10
+ import config, { loadSettings, saveSettings, validateSettings, getWorkspacesDir, getRuntimeStatePaths } from './config.js';
11
+ import store from './store.js';
12
+ import agentManager from './agents.js';
13
+ import bus from './events.js';
14
+
15
+ const app = express();
16
+ app.use(cors());
17
+ app.use(express.json());
18
+
19
+ function stageToResumeStatus(task) {
20
+ const settings = loadSettings();
21
+ const planningDisabled = settings.agents?.planners?.max === 0;
22
+ const previousStatus = task.previousStatus;
23
+ if (previousStatus) {
24
+ if (previousStatus === 'blocked') {
25
+ return 'blocked';
26
+ }
27
+ if (previousStatus === 'awaiting_approval') {
28
+ return 'awaiting_approval';
29
+ }
30
+ if (['workspace_setup', 'planning', 'backlog', 'queued', 'implementing', 'review'].includes(previousStatus)) {
31
+ if (planningDisabled && ['workspace_setup', 'planning', 'backlog'].includes(previousStatus)) {
32
+ return 'queued';
33
+ }
34
+ return previousStatus;
35
+ }
36
+ }
37
+ if (task.lastActiveStage === 'review') {
38
+ return 'review';
39
+ }
40
+ if (task.lastActiveStage === 'implementation') {
41
+ return 'queued';
42
+ }
43
+ if (task.lastActiveStage === 'planning') {
44
+ return task.plan ? 'awaiting_approval' : (planningDisabled ? 'queued' : 'backlog');
45
+ }
46
+ return planningDisabled ? 'queued' : 'backlog';
47
+ }
48
+
49
+ function stageToRetryStatus(task) {
50
+ const settings = loadSettings();
51
+ const planningDisabled = settings.agents?.planners?.max === 0;
52
+ if (task.assignedTo) {
53
+ if (task.assignedTo.startsWith('plan-')) return 'planning';
54
+ if (task.assignedTo.startsWith('imp-')) return 'implementing';
55
+ if (task.assignedTo.startsWith('rev-')) return 'review';
56
+ }
57
+
58
+ if ((task.blockedReason || '').includes('maximum review cycles')) {
59
+ return 'queued';
60
+ }
61
+ if (task.lastActiveStage === 'review') {
62
+ return 'review';
63
+ }
64
+ if (task.lastActiveStage === 'implementation') {
65
+ return 'queued';
66
+ }
67
+ if (task.lastActiveStage === 'planning') {
68
+ return task.plan ? 'awaiting_approval' : (planningDisabled ? 'queued' : 'backlog');
69
+ }
70
+ return planningDisabled ? 'queued' : 'backlog';
71
+ }
72
+
73
+ // REST API
74
+ app.get('/api/status', (req, res) => {
75
+ res.json({
76
+ agents: agentManager.getAllStatus(),
77
+ tasks: store.getAllTasks(),
78
+ uptime: process.uptime(),
79
+ });
80
+ });
81
+
82
+ app.get('/api/repos', (req, res) => {
83
+ res.json({ repos: loadSettings().repos || [] });
84
+ });
85
+
86
+ app.get('/api/browse-dir', (req, res) => {
87
+ const requestedPath = req.query.path || homedir();
88
+ const absPath = resolve(requestedPath);
89
+
90
+ if (!existsSync(absPath)) {
91
+ return res.status(400).json({ error: 'Path does not exist' });
92
+ }
93
+
94
+ try {
95
+ const stat = statSync(absPath);
96
+ if (!stat.isDirectory()) {
97
+ return res.status(400).json({ error: 'Path is not a directory' });
98
+ }
99
+
100
+ const entries = readdirSync(absPath, { withFileTypes: true });
101
+ const dirs = entries
102
+ .filter(e => e.isDirectory() && !e.name.startsWith('.'))
103
+ .map(e => e.name)
104
+ .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
105
+
106
+ const parent = pathDirname(absPath);
107
+ res.json({
108
+ current: absPath,
109
+ parent: parent !== absPath ? parent : null,
110
+ dirs,
111
+ });
112
+ } catch (err) {
113
+ res.status(403).json({ error: 'Permission denied' });
114
+ }
115
+ });
116
+
117
+ app.post('/api/tasks', (req, res) => {
118
+ const { title, priority, description, repoPath } = req.body;
119
+ if (!title) return res.status(400).json({ error: 'Title is required' });
120
+ const task = store.addTask({ title, priority, description, repoPath });
121
+ res.status(201).json(task);
122
+ });
123
+
124
+ app.patch('/api/tasks/:id/approve', (req, res) => {
125
+ const task = store.getTask(req.params.id);
126
+ if (!task) return res.status(404).json({ error: 'Task not found' });
127
+ bus.emit('plan:approved', task.id);
128
+ res.json({ ok: true });
129
+ });
130
+
131
+ app.patch('/api/tasks/:id/reject', (req, res) => {
132
+ const task = store.getTask(req.params.id);
133
+ if (!task) return res.status(404).json({ error: 'Task not found' });
134
+ const { feedback } = req.body || {};
135
+ bus.emit('plan:rejected', { taskId: task.id, feedback: feedback || '' });
136
+ res.json({ ok: true });
137
+ });
138
+
139
+ app.delete('/api/tasks/:id', async (req, res) => {
140
+ const task = store.getTask(req.params.id);
141
+ if (!task) return res.status(404).json({ error: 'Task not found' });
142
+ if (task.status !== 'done') return res.status(400).json({ error: 'Only completed tasks can be deleted' });
143
+ await orchestrator.deleteTask(task.id);
144
+ broadcast('TASK_DELETED', { taskId: task.id });
145
+ res.json({ ok: true });
146
+ });
147
+
148
+ app.get('/api/settings', (req, res) => {
149
+ res.json(loadSettings());
150
+ });
151
+
152
+ app.put('/api/settings', (req, res) => {
153
+ const settings = req.body;
154
+ const errors = validateSettings(settings);
155
+ if (errors.length > 0) {
156
+ return res.status(400).json({ errors });
157
+ }
158
+ saveSettings(settings);
159
+ const persistedSettings = loadSettings();
160
+ bus.emit('settings:changed', persistedSettings);
161
+ broadcast('SETTINGS_UPDATED', persistedSettings);
162
+ res.json(persistedSettings);
163
+ });
164
+
165
+ const runtimePaths = getRuntimeStatePaths();
166
+ const CLIENT_DIST_DIR = runtimePaths.clientDistDir;
167
+
168
+ if (existsSync(CLIENT_DIST_DIR)) {
169
+ app.use(express.static(CLIENT_DIST_DIR));
170
+
171
+ app.get('*', (req, res, next) => {
172
+ if (req.path.startsWith('/api/')) {
173
+ next();
174
+ return;
175
+ }
176
+ res.sendFile(join(CLIENT_DIST_DIR, 'index.html'));
177
+ });
178
+ }
179
+
180
+ // HTTP + WebSocket server
181
+ const server = createServer(app);
182
+ const wss = new WebSocketServer({ server });
183
+
184
+ const wsClients = new Set();
185
+ const bridgeSessions = new Map();
186
+ const BRIDGES_DIR = runtimePaths.bridgesDir;
187
+
188
+ function broadcast(type, payload) {
189
+ const msg = JSON.stringify({ type, payload, ts: Date.now() });
190
+ for (const ws of wsClients) {
191
+ try { ws.send(msg); } catch { wsClients.delete(ws); }
192
+ }
193
+ }
194
+
195
+ function sendToClient(ws, type, payload) {
196
+ try {
197
+ ws.send(JSON.stringify({ type, payload, ts: Date.now() }));
198
+ } catch {
199
+ wsClients.delete(ws);
200
+ }
201
+ }
202
+
203
+ function shellQuote(value) {
204
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
205
+ }
206
+
207
+ function ensureBridgeFiles() {
208
+ mkdirSync(BRIDGES_DIR, { recursive: true });
209
+ }
210
+
211
+ function getBridgeStatus(outputPath) {
212
+ return {
213
+ active: true,
214
+ mode: 'terminal-app',
215
+ owner: 'Terminal.app',
216
+ openedAt: new Date().toISOString(),
217
+ outputPath,
218
+ };
219
+ }
220
+
221
+ function closeBridge(agent, { broadcastEvent = true, notifyType = 'BRIDGE_RETURNED' } = {}) {
222
+ const session = bridgeSessions.get(agent.id);
223
+ if (session) {
224
+ clearInterval(session.pollTimer);
225
+ try { rmSync(session.dir, { recursive: true, force: true }); } catch { /* ignore */ }
226
+ bridgeSessions.delete(agent.id);
227
+ }
228
+
229
+ if (agent.bridge.active) {
230
+ agent.bridge = {
231
+ active: false,
232
+ mode: null,
233
+ owner: null,
234
+ openedAt: null,
235
+ outputPath: null,
236
+ };
237
+ bus.emit('agent:updated', agent.getStatus());
238
+ if (broadcastEvent) {
239
+ broadcast(notifyType, { agentId: agent.id, agentName: agent.name });
240
+ }
241
+ }
242
+ }
243
+
244
+ function readBridgeAppend(session, key) {
245
+ try {
246
+ const content = readFileSync(session[key], 'utf-8');
247
+ const previousLength = session.offsets[key] || 0;
248
+ const chunk = content.slice(previousLength);
249
+ session.offsets[key] = content.length;
250
+ return chunk;
251
+ } catch {
252
+ return '';
253
+ }
254
+ }
255
+
256
+ function processBridgeInput(agent, session) {
257
+ const controlChunk = readBridgeAppend(session, 'controlPath');
258
+ if (controlChunk.includes('RETURN')) {
259
+ closeBridge(agent);
260
+ return;
261
+ }
262
+
263
+ const inputChunk = readBridgeAppend(session, 'inputPath');
264
+ if (!inputChunk) return;
265
+
266
+ const lines = inputChunk
267
+ .split('\n')
268
+ .map(line => line.replace(/\r$/, ''))
269
+ .filter(Boolean);
270
+
271
+ for (const line of lines) {
272
+ agent.write(line + '\n');
273
+ }
274
+ }
275
+
276
+ function openBridgeInTerminal(agent) {
277
+ if (!agent?.process || !agent.currentTask) {
278
+ return { ok: false, message: 'Agent session is not running.' };
279
+ }
280
+
281
+ const task = store.getTask(agent.currentTask);
282
+ if (!task) {
283
+ return { ok: false, message: 'Task state was not found for this agent.' };
284
+ }
285
+
286
+ if (agent.bridge.active) {
287
+ return { ok: true };
288
+ }
289
+
290
+ ensureBridgeFiles();
291
+ const sessionDir = join(BRIDGES_DIR, agent.id);
292
+ mkdirSync(sessionDir, { recursive: true });
293
+
294
+ const inputPath = join(sessionDir, 'input.log');
295
+ const controlPath = join(sessionDir, 'control.log');
296
+ const outputPath = join(sessionDir, 'output.log');
297
+ const scriptPath = join(sessionDir, 'bridge.sh');
298
+
299
+ writeFileSync(inputPath, '');
300
+ writeFileSync(controlPath, '');
301
+ writeFileSync(outputPath, agent.getBufferString(500));
302
+ writeFileSync(scriptPath, `#!/bin/bash
303
+ clear
304
+ echo "Ban Kan terminal bridge for ${agent.name}"
305
+ echo "Task: ${task.title}"
306
+ echo
307
+ echo "Type a line and press Enter to send it to the live agent session."
308
+ echo "Type /return to hand control back to Ban Kan."
309
+ echo
310
+ tail -n +1 -f ${shellQuote(outputPath)} &
311
+ TAIL_PID=$!
312
+ trap 'kill $TAIL_PID 2>/dev/null' EXIT
313
+
314
+ while IFS= read -r line; do
315
+ if [ "$line" = "/return" ]; then
316
+ printf "RETURN\\n" >> ${shellQuote(controlPath)}
317
+ break
318
+ fi
319
+ printf "%s\\n" "$line" >> ${shellQuote(inputPath)}
320
+ done
321
+ `);
322
+ chmodSync(scriptPath, 0o755);
323
+
324
+ const session = {
325
+ dir: sessionDir,
326
+ inputPath,
327
+ controlPath,
328
+ outputPath,
329
+ offsets: {
330
+ inputPath: 0,
331
+ controlPath: 0,
332
+ },
333
+ pollTimer: null,
334
+ };
335
+
336
+ session.pollTimer = setInterval(() => {
337
+ if (!agent.process) {
338
+ closeBridge(agent, { broadcastEvent: false });
339
+ return;
340
+ }
341
+ processBridgeInput(agent, session);
342
+ }, 250);
343
+
344
+ bridgeSessions.set(agent.id, session);
345
+ agent.bridge = getBridgeStatus(outputPath);
346
+ bus.emit('agent:updated', agent.getStatus());
347
+
348
+ try {
349
+ const shellCommand = `bash ${shellQuote(scriptPath)}`;
350
+ execFileSync('osascript', [
351
+ '-e', 'tell application "Terminal" to activate',
352
+ '-e', `tell application "Terminal" to do script ${JSON.stringify(shellCommand)}`,
353
+ ]);
354
+ } catch (err) {
355
+ closeBridge(agent, { broadcastEvent: false });
356
+ return { ok: false, message: `Failed to open Terminal.app: ${err.message}` };
357
+ }
358
+
359
+ broadcast('BRIDGE_OPENED', { agentId: agent.id, agentName: agent.name });
360
+ return { ok: true };
361
+ }
362
+
363
+ function launchVsCodeWorkspace(workspacePath) {
364
+ const attempts = [];
365
+
366
+ const tryCommand = (command, args) => {
367
+ try {
368
+ execFileSync(command, args, { stdio: 'ignore' });
369
+ return true;
370
+ } catch (err) {
371
+ attempts.push(`${command}: ${err.message}`);
372
+ return false;
373
+ }
374
+ };
375
+
376
+ if (process.platform === 'darwin') {
377
+ if (
378
+ tryCommand('code', ['-n', workspacePath]) ||
379
+ tryCommand('open', ['-a', 'Visual Studio Code', workspacePath]) ||
380
+ tryCommand('open', ['-a', 'Visual Studio Code - Insiders', workspacePath])
381
+ ) {
382
+ return { ok: true };
383
+ }
384
+ } else if (process.platform === 'win32') {
385
+ if (
386
+ tryCommand('code.cmd', ['-n', workspacePath]) ||
387
+ tryCommand('cmd', ['/c', 'start', '', 'code', '-n', workspacePath])
388
+ ) {
389
+ return { ok: true };
390
+ }
391
+ } else {
392
+ if (
393
+ tryCommand('code', ['-n', workspacePath]) ||
394
+ tryCommand('code-insiders', ['-n', workspacePath])
395
+ ) {
396
+ return { ok: true };
397
+ }
398
+ }
399
+
400
+ return {
401
+ ok: false,
402
+ message: `Failed to launch VS Code. Tried: ${attempts.join('; ') || 'no launcher commands available'}`,
403
+ };
404
+ }
405
+
406
+ function openTaskWorkspace(taskId) {
407
+ const task = store.getTask(taskId);
408
+ if (!task) {
409
+ return { ok: false, message: 'Task not found.' };
410
+ }
411
+
412
+ if (!task.workspacePath) {
413
+ return { ok: false, message: `Task ${task.id} does not have a local workspace yet.` };
414
+ }
415
+
416
+ if (!existsSync(task.workspacePath)) {
417
+ return { ok: false, message: `Workspace path does not exist: ${task.workspacePath}` };
418
+ }
419
+
420
+ try {
421
+ if (!statSync(task.workspacePath).isDirectory()) {
422
+ return { ok: false, message: `Workspace path is not a directory: ${task.workspacePath}` };
423
+ }
424
+ } catch (err) {
425
+ return { ok: false, message: `Unable to inspect workspace path: ${err.message}` };
426
+ }
427
+
428
+ const launchResult = launchVsCodeWorkspace(task.workspacePath);
429
+ if (!launchResult.ok) {
430
+ return launchResult;
431
+ }
432
+
433
+ return {
434
+ ok: true,
435
+ message: `Opened ${task.id} in VS Code`,
436
+ };
437
+ }
438
+
439
+ wss.on('connection', (ws) => {
440
+ wsClients.add(ws);
441
+
442
+ // Send INIT
443
+ ws.send(JSON.stringify({
444
+ type: 'INIT',
445
+ payload: {
446
+ tasks: store.getAllTasks(),
447
+ agents: agentManager.getAllStatus(),
448
+ repos: loadSettings().repos || [],
449
+ settings: loadSettings(),
450
+ },
451
+ ts: Date.now(),
452
+ }));
453
+
454
+ ws.on('message', (raw) => {
455
+ let msg;
456
+ try { msg = JSON.parse(raw); } catch { return; }
457
+
458
+ switch (msg.type) {
459
+ case 'ADD_TASK': {
460
+ const { title, priority, description, repoPath } = msg.payload || {};
461
+ if (title) store.addTask({ title, priority, description, repoPath });
462
+ break;
463
+ }
464
+ case 'APPROVE_PLAN': {
465
+ const { taskId } = msg.payload || {};
466
+ if (taskId) bus.emit('plan:approved', taskId);
467
+ break;
468
+ }
469
+ case 'REJECT_PLAN': {
470
+ const { taskId, feedback } = msg.payload || {};
471
+ if (taskId) bus.emit('plan:rejected', { taskId, feedback: feedback || '' });
472
+ break;
473
+ }
474
+ case 'UPDATE_SETTINGS': {
475
+ const settings = msg.payload;
476
+ const errors = validateSettings(settings);
477
+ if (errors.length > 0) {
478
+ try {
479
+ ws.send(JSON.stringify({
480
+ type: 'SETTINGS_ERROR',
481
+ payload: { errors },
482
+ ts: Date.now(),
483
+ }));
484
+ } catch { /* ignore */ }
485
+ break;
486
+ }
487
+ saveSettings(settings);
488
+ const persistedSettings = loadSettings();
489
+ bus.emit('settings:changed', persistedSettings);
490
+ broadcast('SETTINGS_UPDATED', persistedSettings);
491
+ break;
492
+ }
493
+ case 'PAUSE_TASK': {
494
+ const { taskId } = msg.payload || {};
495
+ const task = store.getTask(taskId);
496
+ if (task && !['done', 'paused', 'aborted'].includes(task.status)) {
497
+ const previousStatus = task.status;
498
+ // Kill assigned agent if any
499
+ if (task.assignedTo) {
500
+ const agent = agentManager.get(task.assignedTo);
501
+ if (agent) {
502
+ agent.kill();
503
+ }
504
+ }
505
+ store.updateTask(taskId, {
506
+ status: 'paused',
507
+ previousStatus,
508
+ assignedTo: null,
509
+ });
510
+ }
511
+ break;
512
+ }
513
+ case 'RESUME_TASK': {
514
+ const { taskId } = msg.payload || {};
515
+ const task = store.getTask(taskId);
516
+ if (task && task.status === 'paused') {
517
+ const resumeTo = stageToResumeStatus(task);
518
+ store.updateTask(taskId, {
519
+ status: resumeTo,
520
+ previousStatus: null,
521
+ });
522
+ }
523
+ break;
524
+ }
525
+ case 'ABORT_TASK': {
526
+ const { taskId } = msg.payload || {};
527
+ if (taskId) orchestrator.abortTask(taskId);
528
+ break;
529
+ }
530
+ case 'RESET_TASK': {
531
+ const { taskId } = msg.payload || {};
532
+ if (taskId) orchestrator.resetTask(taskId);
533
+ break;
534
+ }
535
+ case 'DELETE_TASK': {
536
+ const { taskId } = msg.payload || {};
537
+ const task = store.getTask(taskId);
538
+ if (task?.status === 'done') {
539
+ orchestrator.deleteTask(taskId);
540
+ broadcast('TASK_DELETED', { taskId });
541
+ }
542
+ break;
543
+ }
544
+ case 'RETRY_TASK': {
545
+ const { taskId } = msg.payload || {};
546
+ const task = store.getTask(taskId);
547
+ if (task && task.status === 'blocked') {
548
+ const retryStatus = stageToRetryStatus(task);
549
+ const agent = task.assignedTo ? agentManager.get(task.assignedTo) : null;
550
+
551
+ if (agent && agent.process) {
552
+ agent.status = 'active';
553
+ bus.emit('agent:updated', agent.getStatus());
554
+ }
555
+
556
+ store.updateTask(taskId, {
557
+ status: retryStatus,
558
+ blockedReason: null,
559
+ assignedTo: agent?.process ? task.assignedTo : null,
560
+ });
561
+ broadcast('TASK_RETRIED', { taskId, retryStatus });
562
+ }
563
+ break;
564
+ }
565
+ case 'EDIT_TASK': {
566
+ const { taskId, updates } = msg.payload || {};
567
+ const task = store.getTask(taskId);
568
+ if (task && updates) {
569
+ const allowed = {};
570
+ if (updates.title !== undefined) allowed.title = updates.title;
571
+ if (updates.description !== undefined) allowed.description = updates.description;
572
+ if (updates.priority !== undefined) allowed.priority = updates.priority;
573
+ if (updates.repoPath !== undefined) allowed.repoPath = updates.repoPath;
574
+ if (Object.keys(allowed).length > 0) {
575
+ store.updateTask(taskId, allowed);
576
+ }
577
+ }
578
+ break;
579
+ }
580
+ case 'INJECT_MESSAGE': {
581
+ const { agentId, message } = msg.payload || {};
582
+ const agent = agentManager.get(agentId);
583
+ if (agent?.bridge.active) {
584
+ try {
585
+ ws.send(JSON.stringify({
586
+ type: 'BRIDGE_ERROR',
587
+ payload: { message: `${agent.name} input is currently locked to Terminal.app.` },
588
+ ts: Date.now(),
589
+ }));
590
+ } catch { /* ignore */ }
591
+ } else if (agent) {
592
+ agent.write(message + '\n');
593
+ }
594
+ break;
595
+ }
596
+ case 'INJECT_RAW': {
597
+ const { agentId, data } = msg.payload || {};
598
+ const agent = agentManager.get(agentId);
599
+ if (agent?.bridge.active) {
600
+ try {
601
+ ws.send(JSON.stringify({
602
+ type: 'BRIDGE_ERROR',
603
+ payload: { message: `${agent.name} input is currently locked to Terminal.app.` },
604
+ ts: Date.now(),
605
+ }));
606
+ } catch { /* ignore */ }
607
+ } else if (agent) {
608
+ agent.write(data);
609
+ }
610
+ break;
611
+ }
612
+ case 'OPEN_TASK_WORKSPACE': {
613
+ const { taskId } = msg.payload || {};
614
+ const result = openTaskWorkspace(taskId);
615
+ if (result.ok) {
616
+ sendToClient(ws, 'TASK_WORKSPACE_OPENED', { taskId, message: result.message });
617
+ } else {
618
+ sendToClient(ws, 'TASK_WORKSPACE_ERROR', { taskId, message: result.message });
619
+ }
620
+ break;
621
+ }
622
+ case 'OPEN_AGENT_TERMINAL': {
623
+ const agent = agentManager.get(msg.payload?.agentId);
624
+ const result = openBridgeInTerminal(agent);
625
+ if (!result.ok) {
626
+ sendToClient(ws, 'BRIDGE_ERROR', { message: result.message });
627
+ }
628
+ break;
629
+ }
630
+ case 'RETURN_AGENT_TERMINAL': {
631
+ const agent = agentManager.get(msg.payload?.agentId);
632
+ if (agent) closeBridge(agent);
633
+ break;
634
+ }
635
+ case 'PAUSE_AGENT': {
636
+ const agent = agentManager.get(msg.payload?.agentId);
637
+ if (agent) { agent.status = 'paused'; bus.emit('agent:updated', agent.getStatus()); }
638
+ break;
639
+ }
640
+ case 'RESUME_AGENT': {
641
+ const agent = agentManager.get(msg.payload?.agentId);
642
+ if (agent && agent.process) { agent.status = 'active'; bus.emit('agent:updated', agent.getStatus()); }
643
+ break;
644
+ }
645
+ case 'SUBSCRIBE_TERMINAL': {
646
+ const agent = agentManager.get(msg.payload?.agentId);
647
+ if (agent) {
648
+ agent.subscribers.add(ws);
649
+ // Replay buffer
650
+ for (const chunk of agent.terminalBuffer) {
651
+ try {
652
+ ws.send(JSON.stringify({
653
+ type: 'TERMINAL_DATA',
654
+ payload: { agentId: agent.id, data: chunk },
655
+ ts: Date.now(),
656
+ }));
657
+ } catch { break; }
658
+ }
659
+ }
660
+ break;
661
+ }
662
+ case 'UNSUBSCRIBE_TERMINAL': {
663
+ const agent = agentManager.get(msg.payload?.agentId);
664
+ if (agent) agent.subscribers.delete(ws);
665
+ break;
666
+ }
667
+ }
668
+ });
669
+
670
+ ws.on('close', () => {
671
+ wsClients.delete(ws);
672
+ // Remove from all agent subscriber sets
673
+ for (const [, agent] of agentManager.agents) {
674
+ agent.subscribers.delete(ws);
675
+ }
676
+ });
677
+ });
678
+
679
+ // Event bus → WS broadcast
680
+ bus.on('tasks:changed', (tasks) => broadcast('TASKS_UPDATED', { tasks }));
681
+ bus.on('task:added', (task) => broadcast('TASK_ADDED', { task }));
682
+ bus.on('agents:updated', (agents) => broadcast('AGENTS_UPDATED', { agents }));
683
+ bus.on('agent:removed', (data) => broadcast('AGENT_REMOVED', data));
684
+ bus.on('plan:ready', (data) => broadcast('PLAN_READY', data));
685
+ bus.on('review:passed', (data) => broadcast('REVIEW_PASSED', data));
686
+ bus.on('review:failed', (data) => broadcast('REVIEW_FAILED', data));
687
+ bus.on('pr:created', (data) => broadcast('PR_CREATED', data));
688
+ bus.on('task:blocked', (data) => broadcast('TASK_BLOCKED', data));
689
+ bus.on('repos:updated', (repos) => broadcast('REPOS_UPDATED', { repos }));
690
+ bus.on('plan:partial', (data) => broadcast('PLAN_PARTIAL', data));
691
+ bus.on('task:aborted', (data) => broadcast('TASK_ABORTED', data));
692
+ bus.on('task:reset', (data) => broadcast('TASK_RESET', data));
693
+ bus.on('agent:updated', (agentStatus) => {
694
+ broadcast('AGENT_UPDATED', { agent: agentStatus });
695
+ if (!agentStatus.bridgeActive && bridgeSessions.has(agentStatus.id)) {
696
+ const agent = agentManager.get(agentStatus.id);
697
+ if (agent) closeBridge(agent, { broadcastEvent: false });
698
+ }
699
+ });
700
+
701
+ let orchestrator = null;
702
+ let startupPromise = null;
703
+
704
+ async function ensureAppStarted() {
705
+ if (startupPromise) {
706
+ return startupPromise;
707
+ }
708
+
709
+ startupPromise = (async () => {
710
+ store.restartRecovery();
711
+
712
+ const workspacesDir = getWorkspacesDir();
713
+ if (existsSync(workspacesDir)) {
714
+ const terminalStatuses = ['done', 'backlog', 'aborted', 'awaiting_human_review'];
715
+ let entries;
716
+ try { entries = readdirSync(workspacesDir); } catch { entries = []; }
717
+ for (const entry of entries) {
718
+ const task = store.getTask(entry);
719
+ if (!task || terminalStatuses.includes(task.status)) {
720
+ try {
721
+ rmSync(join(workspacesDir, entry), { recursive: true, force: true });
722
+ console.log(`Cleaned up orphan workspace: ${entry}`);
723
+ } catch (err) {
724
+ console.error(`Failed to cleanup workspace ${entry}:`, err.message);
725
+ }
726
+ }
727
+ }
728
+ }
729
+
730
+ const imported = await import('./orchestrator.js');
731
+ orchestrator = imported.default;
732
+ orchestrator.start();
733
+ })();
734
+
735
+ return startupPromise;
736
+ }
737
+
738
+ let started = false;
739
+
740
+ export async function startServer({ port = config.PORT, host = '127.0.0.1' } = {}) {
741
+ if (started) {
742
+ const address = server.address();
743
+ if (address && typeof address === 'object') {
744
+ return { server, port: address.port, host };
745
+ }
746
+ return { server, port, host };
747
+ }
748
+
749
+ await ensureAppStarted();
750
+
751
+ await new Promise((resolvePromise, rejectPromise) => {
752
+ server.once('error', rejectPromise);
753
+ server.listen(port, host, () => {
754
+ server.off('error', rejectPromise);
755
+ started = true;
756
+ resolvePromise();
757
+ });
758
+ });
759
+
760
+ const address = server.address();
761
+ const resolvedPort = typeof address === 'object' && address ? address.port : port;
762
+ console.log(`Ban Kan server running on http://${host}:${resolvedPort}`);
763
+ return { server, port: resolvedPort, host };
764
+ }
765
+
766
+ const entryPath = process.argv[1] ? resolve(process.argv[1]) : null;
767
+ const currentModulePath = fileURLToPath(import.meta.url);
768
+
769
+ if (entryPath === currentModulePath) {
770
+ startServer().catch((err) => {
771
+ console.error('Failed to start Ban Kan:', err);
772
+ process.exit(1);
773
+ });
774
+ }