@sooneocean/agw 1.4.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.
Files changed (59) hide show
  1. package/README.md +116 -0
  2. package/bin/agw.ts +5 -0
  3. package/package.json +59 -0
  4. package/src/agents/base-adapter.ts +113 -0
  5. package/src/agents/claude-adapter.ts +29 -0
  6. package/src/agents/codex-adapter.ts +29 -0
  7. package/src/agents/gemini-adapter.ts +29 -0
  8. package/src/cli/commands/agents.ts +55 -0
  9. package/src/cli/commands/combo.ts +130 -0
  10. package/src/cli/commands/costs.ts +33 -0
  11. package/src/cli/commands/daemon.ts +110 -0
  12. package/src/cli/commands/history.ts +29 -0
  13. package/src/cli/commands/run.ts +59 -0
  14. package/src/cli/commands/status.ts +29 -0
  15. package/src/cli/commands/workflow.ts +73 -0
  16. package/src/cli/error-handler.ts +8 -0
  17. package/src/cli/http-client.ts +68 -0
  18. package/src/cli/index.ts +28 -0
  19. package/src/config.ts +68 -0
  20. package/src/daemon/middleware/auth.ts +35 -0
  21. package/src/daemon/middleware/rate-limiter.ts +63 -0
  22. package/src/daemon/middleware/tenant.ts +64 -0
  23. package/src/daemon/middleware/workspace.ts +40 -0
  24. package/src/daemon/routes/agents.ts +13 -0
  25. package/src/daemon/routes/combos.ts +103 -0
  26. package/src/daemon/routes/costs.ts +9 -0
  27. package/src/daemon/routes/health.ts +62 -0
  28. package/src/daemon/routes/memory.ts +32 -0
  29. package/src/daemon/routes/tasks.ts +157 -0
  30. package/src/daemon/routes/ui.ts +18 -0
  31. package/src/daemon/routes/workflows.ts +73 -0
  32. package/src/daemon/server.ts +91 -0
  33. package/src/daemon/services/agent-learning.ts +77 -0
  34. package/src/daemon/services/agent-manager.ts +71 -0
  35. package/src/daemon/services/auto-scaler.ts +77 -0
  36. package/src/daemon/services/circuit-breaker.ts +95 -0
  37. package/src/daemon/services/combo-executor.ts +300 -0
  38. package/src/daemon/services/dag-executor.ts +136 -0
  39. package/src/daemon/services/metrics.ts +35 -0
  40. package/src/daemon/services/stream-aggregator.ts +64 -0
  41. package/src/daemon/services/task-executor.ts +184 -0
  42. package/src/daemon/services/task-queue.ts +75 -0
  43. package/src/daemon/services/workflow-executor.ts +150 -0
  44. package/src/daemon/services/ws-manager.ts +90 -0
  45. package/src/dsl/parser.ts +124 -0
  46. package/src/plugins/plugin-loader.ts +72 -0
  47. package/src/router/keyword-router.ts +63 -0
  48. package/src/router/llm-router.ts +93 -0
  49. package/src/store/agent-repo.ts +57 -0
  50. package/src/store/audit-repo.ts +25 -0
  51. package/src/store/combo-repo.ts +99 -0
  52. package/src/store/cost-repo.ts +55 -0
  53. package/src/store/db.ts +137 -0
  54. package/src/store/memory-repo.ts +46 -0
  55. package/src/store/task-repo.ts +127 -0
  56. package/src/store/workflow-repo.ts +69 -0
  57. package/src/types.ts +208 -0
  58. package/tsconfig.json +17 -0
  59. package/ui/index.html +272 -0
@@ -0,0 +1,35 @@
1
+ export interface MetricsSnapshot {
2
+ uptime: number;
3
+ tasks: { total: number; completed: number; failed: number; running: number };
4
+ agents: { total: number; available: number };
5
+ costs: { daily: number; monthly: number };
6
+ performance: { avgDurationMs: number; p95DurationMs: number };
7
+ memory: { heapUsed: number; heapTotal: number; rss: number };
8
+ }
9
+
10
+ export class MetricsCollector {
11
+ private startTime = Date.now();
12
+ private durations: number[] = [];
13
+
14
+ recordDuration(ms: number): void {
15
+ if (this.durations.length >= 500) this.durations.shift();
16
+ this.durations.push(ms);
17
+ }
18
+
19
+ getPerformance(): { avgDurationMs: number; p95DurationMs: number } {
20
+ if (this.durations.length === 0) return { avgDurationMs: 0, p95DurationMs: 0 };
21
+ const sorted = [...this.durations].sort((a, b) => a - b);
22
+ const avg = sorted.reduce((a, b) => a + b, 0) / sorted.length;
23
+ const p95 = sorted[Math.floor(sorted.length * 0.95)] ?? 0;
24
+ return { avgDurationMs: Math.round(avg), p95DurationMs: p95 };
25
+ }
26
+
27
+ getUptime(): number {
28
+ return Date.now() - this.startTime;
29
+ }
30
+
31
+ getMemory(): { heapUsed: number; heapTotal: number; rss: number } {
32
+ const mem = process.memoryUsage();
33
+ return { heapUsed: mem.heapUsed, heapTotal: mem.heapTotal, rss: mem.rss };
34
+ }
35
+ }
@@ -0,0 +1,64 @@
1
+ import { EventEmitter } from 'node:events';
2
+
3
+ export interface StreamChunk {
4
+ taskId: string;
5
+ source: 'stdout' | 'stderr';
6
+ content: string;
7
+ timestamp: number;
8
+ agentId?: string;
9
+ }
10
+
11
+ /**
12
+ * Aggregates multiple task streams into a single ordered stream.
13
+ * Useful for combos and workflows where multiple agents run and
14
+ * the caller wants a unified stream of all output.
15
+ */
16
+ export class StreamAggregator extends EventEmitter {
17
+ private chunks: StreamChunk[] = [];
18
+ private taskIds = new Set<string>();
19
+ private completedTasks = new Set<string>();
20
+
21
+ track(taskId: string, agentId?: string): void {
22
+ this.taskIds.add(taskId);
23
+ }
24
+
25
+ addChunk(taskId: string, source: 'stdout' | 'stderr', content: string, agentId?: string): void {
26
+ const chunk: StreamChunk = {
27
+ taskId, source, content, timestamp: Date.now(), agentId,
28
+ };
29
+ this.chunks.push(chunk);
30
+ this.emit('chunk', chunk);
31
+ }
32
+
33
+ markComplete(taskId: string): void {
34
+ this.completedTasks.add(taskId);
35
+ if (this.isAllComplete()) {
36
+ this.emit('complete', this.getFullOutput());
37
+ }
38
+ }
39
+
40
+ isAllComplete(): boolean {
41
+ return this.taskIds.size > 0 && this.completedTasks.size >= this.taskIds.size;
42
+ }
43
+
44
+ getChunks(): StreamChunk[] {
45
+ return [...this.chunks];
46
+ }
47
+
48
+ getFullOutput(): string {
49
+ return this.chunks
50
+ .filter(c => c.source === 'stdout')
51
+ .map(c => c.content)
52
+ .join('');
53
+ }
54
+
55
+ getOutputByTask(): Record<string, string> {
56
+ const result: Record<string, string> = {};
57
+ for (const chunk of this.chunks) {
58
+ if (chunk.source === 'stdout') {
59
+ result[chunk.taskId] = (result[chunk.taskId] ?? '') + chunk.content;
60
+ }
61
+ }
62
+ return result;
63
+ }
64
+ }
@@ -0,0 +1,184 @@
1
+ import { nanoid } from 'nanoid';
2
+ import { EventEmitter } from 'node:events';
3
+ import type Database from 'better-sqlite3';
4
+ import type { CreateTaskRequest, TaskDescriptor } from '../../types.js';
5
+ import { TaskRepo } from '../../store/task-repo.js';
6
+ import { AuditRepo } from '../../store/audit-repo.js';
7
+ import { CostRepo } from '../../store/cost-repo.js';
8
+ import { AgentManager } from './agent-manager.js';
9
+ import { TaskQueue } from './task-queue.js';
10
+
11
+ export class TaskExecutor extends EventEmitter {
12
+ private taskQueue: TaskQueue;
13
+ private costRepo: CostRepo;
14
+ private dailyCostLimit?: number;
15
+ private monthlyCostLimit?: number;
16
+
17
+ constructor(
18
+ private taskRepo: TaskRepo,
19
+ private auditRepo: AuditRepo,
20
+ private agentManager: AgentManager,
21
+ costRepo?: CostRepo | null,
22
+ maxConcurrencyPerAgent: number = 3,
23
+ dailyCostLimit?: number,
24
+ monthlyCostLimit?: number,
25
+ db?: Database.Database,
26
+ ) {
27
+ super();
28
+ this.costRepo = costRepo ?? (db ? new CostRepo(db) : null) as CostRepo;
29
+ this.dailyCostLimit = dailyCostLimit;
30
+ this.monthlyCostLimit = monthlyCostLimit;
31
+ this.taskQueue = new TaskQueue(taskRepo, maxConcurrencyPerAgent);
32
+
33
+ this.taskQueue.on('queued', (taskId: string) => {
34
+ this.auditRepo.log(taskId, 'task.queued', { reason: 'concurrency limit' });
35
+ });
36
+ }
37
+
38
+ private checkQuota(): void {
39
+ if (!this.costRepo) return;
40
+ if (this.dailyCostLimit) {
41
+ const daily = this.costRepo.getDailyCost();
42
+ if (daily >= this.dailyCostLimit) {
43
+ this.auditRepo.log(null, 'cost.quota_exceeded', { type: 'daily', current: daily, limit: this.dailyCostLimit });
44
+ throw new Error(`Daily cost limit exceeded ($${daily.toFixed(2)} / $${this.dailyCostLimit.toFixed(2)})`);
45
+ }
46
+ }
47
+ if (this.monthlyCostLimit) {
48
+ const monthly = this.costRepo.getMonthlyCost();
49
+ if (monthly >= this.monthlyCostLimit) {
50
+ this.auditRepo.log(null, 'cost.quota_exceeded', { type: 'monthly', current: monthly, limit: this.monthlyCostLimit });
51
+ throw new Error(`Monthly cost limit exceeded ($${monthly.toFixed(2)} / $${this.monthlyCostLimit.toFixed(2)})`);
52
+ }
53
+ }
54
+ }
55
+
56
+ async execute(request: CreateTaskRequest, routeFn?: (prompt: string) => Promise<{ agentId: string; reason: string; confidence: number }>): Promise<TaskDescriptor> {
57
+ this.checkQuota();
58
+
59
+ const taskId = nanoid(12);
60
+ const workingDirectory = request.workingDirectory ?? process.cwd();
61
+ const createdAt = new Date().toISOString();
62
+ const priority = request.priority ?? 3;
63
+
64
+ // Create task
65
+ this.taskRepo.create({
66
+ taskId,
67
+ prompt: request.prompt,
68
+ workingDirectory,
69
+ status: 'pending',
70
+ priority,
71
+ createdAt,
72
+ preferredAgent: request.preferredAgent,
73
+ workflowId: request.workflowId,
74
+ stepIndex: request.stepIndex,
75
+ });
76
+ this.auditRepo.log(taskId, 'task.created', { prompt: request.prompt, priority });
77
+ this.emit('task:status', taskId, { status: 'pending' });
78
+
79
+ // Route
80
+ this.taskRepo.updateStatus(taskId, 'routing');
81
+ this.emit('task:status', taskId, { status: 'routing' });
82
+
83
+ let agentId: string;
84
+ let routingReason: string;
85
+
86
+ if (request.preferredAgent) {
87
+ agentId = request.preferredAgent;
88
+ routingReason = 'User override';
89
+ } else if (routeFn) {
90
+ const decision = await routeFn(request.prompt);
91
+ agentId = decision.agentId;
92
+ routingReason = decision.reason;
93
+ } else {
94
+ throw new Error('No routing function provided and no preferred agent');
95
+ }
96
+
97
+ // Don't set 'running' yet — task may be queued (M1: status accuracy)
98
+ this.auditRepo.log(taskId, 'task.routed', { agentId, reason: routingReason });
99
+
100
+ const adapter = this.agentManager.getAdapter(agentId);
101
+ if (!adapter) {
102
+ this.taskRepo.updateStatus(taskId, 'failed');
103
+ this.auditRepo.log(taskId, 'task.failed', { error: `Agent ${agentId} not available` });
104
+ return this.taskRepo.getById(taskId)!;
105
+ }
106
+
107
+ // Execute through the concurrency queue
108
+ const executeTask = async (): Promise<void> => {
109
+ // NOW set 'running' — task is actually starting (M1)
110
+ this.taskRepo.updateStatus(taskId, 'running', agentId, routingReason);
111
+ this.auditRepo.log(taskId, 'task.started', { agentId });
112
+ this.emit('task:status', taskId, { status: 'running', agentId, reason: routingReason });
113
+ const onStdout = (...args: unknown[]) => this.emit('task:stdout', taskId, String(args[0]));
114
+ const onStderr = (...args: unknown[]) => this.emit('task:stderr', taskId, String(args[0]));
115
+ adapter.on('stdout', onStdout);
116
+ adapter.on('stderr', onStderr);
117
+
118
+ const task: TaskDescriptor = {
119
+ taskId,
120
+ prompt: request.prompt,
121
+ workingDirectory,
122
+ status: 'running',
123
+ priority,
124
+ assignedAgent: agentId,
125
+ createdAt,
126
+ };
127
+
128
+ try {
129
+ const result = await adapter.execute(task);
130
+ const finalStatus = result.exitCode === 0 ? 'completed' : 'failed';
131
+ this.taskRepo.updateResult(taskId, result);
132
+ this.taskRepo.updateStatus(taskId, finalStatus);
133
+ const eventType = result.exitCode === 0 ? 'task.completed' : 'task.failed';
134
+ this.auditRepo.log(taskId, eventType, { exitCode: result.exitCode, durationMs: result.durationMs });
135
+
136
+ if (result.costEstimate && this.costRepo) {
137
+ this.costRepo.record(taskId, agentId, result.costEstimate, result.tokenEstimate ?? 0);
138
+ }
139
+
140
+ this.emit('task:done', taskId, result);
141
+ } finally {
142
+ adapter.removeListener('stdout', onStdout);
143
+ adapter.removeListener('stderr', onStderr);
144
+ }
145
+ };
146
+
147
+ // Use queue for concurrency control (M2: reject on error to avoid hanging)
148
+ return new Promise<TaskDescriptor>((resolve, reject) => {
149
+ const wrappedExecute = async () => {
150
+ try {
151
+ await executeTask();
152
+ resolve(this.taskRepo.getById(taskId)!);
153
+ } catch (err) {
154
+ this.taskRepo.updateStatus(taskId, 'failed');
155
+ this.auditRepo.log(taskId, 'task.failed', { error: (err as Error).message });
156
+ resolve(this.taskRepo.getById(taskId)!);
157
+ }
158
+ };
159
+
160
+ const started = this.taskQueue.enqueue({
161
+ taskId,
162
+ agentId,
163
+ priority,
164
+ execute: wrappedExecute,
165
+ });
166
+
167
+ if (!started) {
168
+ this.emit('task:status', taskId, { status: 'queued' });
169
+ }
170
+ });
171
+ }
172
+
173
+ getTask(taskId: string): TaskDescriptor | undefined {
174
+ return this.taskRepo.getById(taskId);
175
+ }
176
+
177
+ listTasks(limit: number = 20, offset: number = 0): TaskDescriptor[] {
178
+ return this.taskRepo.list(limit, offset);
179
+ }
180
+
181
+ getCostRepo(): CostRepo {
182
+ return this.costRepo;
183
+ }
184
+ }
@@ -0,0 +1,75 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { TaskRepo } from '../../store/task-repo.js';
3
+
4
+ interface QueuedTask {
5
+ taskId: string;
6
+ agentId: string;
7
+ priority: number;
8
+ execute: () => Promise<void>;
9
+ }
10
+
11
+ export class TaskQueue extends EventEmitter {
12
+ private queue: QueuedTask[] = [];
13
+ private runningCount: Map<string, number> = new Map();
14
+
15
+ constructor(
16
+ private taskRepo: TaskRepo,
17
+ private maxConcurrencyPerAgent: number,
18
+ ) {
19
+ super();
20
+ }
21
+
22
+ getRunningCount(agentId: string): number {
23
+ return this.runningCount.get(agentId) ?? 0;
24
+ }
25
+
26
+ canRun(agentId: string): boolean {
27
+ return this.getRunningCount(agentId) < this.maxConcurrencyPerAgent;
28
+ }
29
+
30
+ enqueue(item: QueuedTask): boolean {
31
+ if (this.canRun(item.agentId)) {
32
+ this.startTask(item);
33
+ return true; // started immediately
34
+ }
35
+ // Insert sorted by priority DESC
36
+ const idx = this.queue.findIndex(q => q.priority < item.priority);
37
+ if (idx === -1) this.queue.push(item);
38
+ else this.queue.splice(idx, 0, item);
39
+ this.emit('queued', item.taskId, item.agentId);
40
+ return false; // queued
41
+ }
42
+
43
+ private startTask(item: QueuedTask): void {
44
+ const current = this.runningCount.get(item.agentId) ?? 0;
45
+ this.runningCount.set(item.agentId, current + 1);
46
+
47
+ item.execute()
48
+ .catch((err) => {
49
+ this.emit('task:error', item.taskId, err);
50
+ })
51
+ .finally(() => {
52
+ const count = this.runningCount.get(item.agentId) ?? 1;
53
+ this.runningCount.set(item.agentId, count - 1);
54
+ this.processQueue(item.agentId);
55
+ });
56
+ }
57
+
58
+ private processQueue(agentId: string): void {
59
+ const idx = this.queue.findIndex(q => q.agentId === agentId);
60
+ if (idx === -1) return;
61
+ if (!this.canRun(agentId)) return;
62
+
63
+ const next = this.queue.splice(idx, 1)[0];
64
+ this.startTask(next);
65
+ this.emit('dequeued', next.taskId, next.agentId);
66
+ }
67
+
68
+ getQueueLength(): number {
69
+ return this.queue.length;
70
+ }
71
+
72
+ getQueuedTasks(): QueuedTask[] {
73
+ return [...this.queue];
74
+ }
75
+ }
@@ -0,0 +1,150 @@
1
+ import { nanoid } from 'nanoid';
2
+ import { EventEmitter } from 'node:events';
3
+ import type { CreateWorkflowRequest, WorkflowDescriptor, CreateTaskRequest } from '../../types.js';
4
+ import { WorkflowRepo } from '../../store/workflow-repo.js';
5
+ import { AuditRepo } from '../../store/audit-repo.js';
6
+ import type { TaskExecutor } from './task-executor.js';
7
+ import type { LlmRouter } from '../../router/llm-router.js';
8
+ import type { AgentManager } from './agent-manager.js';
9
+
10
+ export class WorkflowExecutor extends EventEmitter {
11
+ constructor(
12
+ private workflowRepo: WorkflowRepo,
13
+ private auditRepo: AuditRepo,
14
+ private taskExecutor: TaskExecutor,
15
+ private router: LlmRouter,
16
+ private agentManager: AgentManager,
17
+ ) {
18
+ super();
19
+ }
20
+
21
+ /** Start a workflow in the background. Returns workflowId immediately. */
22
+ start(request: CreateWorkflowRequest): string {
23
+ const workflowId = nanoid(12);
24
+ const createdAt = new Date().toISOString();
25
+
26
+ this.workflowRepo.create({
27
+ workflowId,
28
+ name: request.name,
29
+ steps: request.steps,
30
+ mode: request.mode ?? 'sequential',
31
+ status: 'pending',
32
+ createdAt,
33
+ workingDirectory: request.workingDirectory,
34
+ priority: request.priority,
35
+ });
36
+ this.auditRepo.log(null, 'workflow.created', { workflowId, name: request.name, stepCount: request.steps.length });
37
+
38
+ // Execute in background — don't block the caller
39
+ this.runWorkflow(workflowId, request).catch((err) => {
40
+ this.emit('workflow:error', workflowId, err);
41
+ });
42
+
43
+ return workflowId;
44
+ }
45
+
46
+ /** Execute workflow synchronously (for testing or CLI). */
47
+ async execute(request: CreateWorkflowRequest): Promise<WorkflowDescriptor> {
48
+ const workflowId = nanoid(12);
49
+ const createdAt = new Date().toISOString();
50
+
51
+ this.workflowRepo.create({
52
+ workflowId,
53
+ name: request.name,
54
+ steps: request.steps,
55
+ mode: request.mode ?? 'sequential',
56
+ status: 'pending',
57
+ createdAt,
58
+ workingDirectory: request.workingDirectory,
59
+ priority: request.priority,
60
+ });
61
+ this.auditRepo.log(null, 'workflow.created', { workflowId, name: request.name, stepCount: request.steps.length });
62
+
63
+ await this.runWorkflow(workflowId, request);
64
+ return this.workflowRepo.getById(workflowId)!;
65
+ }
66
+
67
+ private async runWorkflow(workflowId: string, request: CreateWorkflowRequest): Promise<void> {
68
+ this.workflowRepo.updateStatus(workflowId, 'running');
69
+
70
+ try {
71
+ if (request.mode === 'parallel') {
72
+ await this.executeParallel(workflowId, request);
73
+ } else {
74
+ await this.executeSequential(workflowId, request);
75
+ }
76
+ this.workflowRepo.updateStatus(workflowId, 'completed');
77
+ this.auditRepo.log(null, 'workflow.completed', { workflowId });
78
+ this.emit('workflow:done', workflowId);
79
+ } catch (err) {
80
+ this.workflowRepo.updateStatus(workflowId, 'failed');
81
+ this.auditRepo.log(null, 'workflow.failed', { workflowId, error: (err as Error).message });
82
+ this.emit('workflow:done', workflowId);
83
+ }
84
+ }
85
+
86
+ private async executeSequential(workflowId: string, request: CreateWorkflowRequest): Promise<void> {
87
+ for (let i = 0; i < request.steps.length; i++) {
88
+ const step = request.steps[i];
89
+ this.workflowRepo.updateStep(workflowId, i);
90
+ this.auditRepo.log(null, 'workflow.step', { workflowId, stepIndex: i });
91
+
92
+ const taskRequest: CreateTaskRequest = {
93
+ prompt: step.prompt,
94
+ preferredAgent: step.preferredAgent,
95
+ workingDirectory: request.workingDirectory,
96
+ priority: request.priority,
97
+ workflowId,
98
+ stepIndex: i,
99
+ };
100
+
101
+ const availableAgents = this.agentManager.getAvailableAgents();
102
+ const task = await this.taskExecutor.execute(taskRequest, async (p) => {
103
+ return this.router.route(p, availableAgents, step.preferredAgent);
104
+ });
105
+
106
+ this.workflowRepo.addTaskId(workflowId, task.taskId);
107
+
108
+ if (task.status === 'failed') {
109
+ throw new Error(`Step ${i} failed (task ${task.taskId})`);
110
+ }
111
+ }
112
+ }
113
+
114
+ private async executeParallel(workflowId: string, request: CreateWorkflowRequest): Promise<void> {
115
+ this.auditRepo.log(null, 'workflow.step', { workflowId, mode: 'parallel', stepCount: request.steps.length });
116
+
117
+ const promises = request.steps.map(async (step, i) => {
118
+ const taskRequest: CreateTaskRequest = {
119
+ prompt: step.prompt,
120
+ preferredAgent: step.preferredAgent,
121
+ workingDirectory: request.workingDirectory,
122
+ priority: request.priority,
123
+ workflowId,
124
+ stepIndex: i,
125
+ };
126
+
127
+ const availableAgents = this.agentManager.getAvailableAgents();
128
+ const task = await this.taskExecutor.execute(taskRequest, async (p) => {
129
+ return this.router.route(p, availableAgents, step.preferredAgent);
130
+ });
131
+
132
+ this.workflowRepo.addTaskId(workflowId, task.taskId);
133
+ return task;
134
+ });
135
+
136
+ const results = await Promise.all(promises);
137
+ const failed = results.filter(t => t.status === 'failed');
138
+ if (failed.length > 0) {
139
+ throw new Error(`${failed.length} step(s) failed: ${failed.map(t => t.taskId).join(', ')}`);
140
+ }
141
+ }
142
+
143
+ getWorkflow(workflowId: string): WorkflowDescriptor | undefined {
144
+ return this.workflowRepo.getById(workflowId);
145
+ }
146
+
147
+ listWorkflows(limit: number = 20, offset: number = 0): WorkflowDescriptor[] {
148
+ return this.workflowRepo.list(limit, offset);
149
+ }
150
+ }
@@ -0,0 +1,90 @@
1
+ import { EventEmitter } from 'node:events';
2
+
3
+ export interface WsClient {
4
+ id: string;
5
+ subscriptions: Set<string>; // taskIds being watched
6
+ send: (data: string) => void;
7
+ close: () => void;
8
+ }
9
+
10
+ /**
11
+ * WebSocket connection manager for bidirectional real-time communication.
12
+ * Clients can:
13
+ * - Subscribe to task events by taskId
14
+ * - Receive broadcast events (agent status, metrics)
15
+ * - Send commands (cancel task, update priority)
16
+ */
17
+ export class WsManager extends EventEmitter {
18
+ private clients = new Map<string, WsClient>();
19
+ private nextId = 0;
20
+
21
+ addClient(send: (data: string) => void, close: () => void): string {
22
+ const id = `ws-${++this.nextId}`;
23
+ this.clients.set(id, { id, subscriptions: new Set(), send, close });
24
+ this.emit('client:connect', id);
25
+ return id;
26
+ }
27
+
28
+ removeClient(id: string): void {
29
+ this.clients.delete(id);
30
+ this.emit('client:disconnect', id);
31
+ }
32
+
33
+ subscribe(clientId: string, taskId: string): void {
34
+ const client = this.clients.get(clientId);
35
+ if (client) client.subscriptions.add(taskId);
36
+ }
37
+
38
+ unsubscribe(clientId: string, taskId: string): void {
39
+ const client = this.clients.get(clientId);
40
+ if (client) client.subscriptions.delete(taskId);
41
+ }
42
+
43
+ /** Send event to all clients subscribed to this taskId */
44
+ sendToSubscribers(taskId: string, event: string, data: unknown): void {
45
+ const message = JSON.stringify({ event, taskId, data, timestamp: Date.now() });
46
+ for (const client of this.clients.values()) {
47
+ if (client.subscriptions.has(taskId) || client.subscriptions.has('*')) {
48
+ try { client.send(message); } catch { /* ignore dead connections */ }
49
+ }
50
+ }
51
+ }
52
+
53
+ /** Broadcast to all connected clients */
54
+ broadcast(event: string, data: unknown): void {
55
+ const message = JSON.stringify({ event, data, timestamp: Date.now() });
56
+ for (const client of this.clients.values()) {
57
+ try { client.send(message); } catch { /* ignore */ }
58
+ }
59
+ }
60
+
61
+ /** Process incoming message from a client */
62
+ handleMessage(clientId: string, raw: string): void {
63
+ try {
64
+ const msg = JSON.parse(raw) as { action: string; taskId?: string; [key: string]: unknown };
65
+ switch (msg.action) {
66
+ case 'subscribe':
67
+ if (msg.taskId) this.subscribe(clientId, msg.taskId);
68
+ break;
69
+ case 'unsubscribe':
70
+ if (msg.taskId) this.unsubscribe(clientId, msg.taskId);
71
+ break;
72
+ case 'subscribe-all':
73
+ this.subscribe(clientId, '*');
74
+ break;
75
+ default:
76
+ this.emit('command', clientId, msg);
77
+ }
78
+ } catch {
79
+ // Ignore malformed messages
80
+ }
81
+ }
82
+
83
+ getClientCount(): number {
84
+ return this.clients.size;
85
+ }
86
+
87
+ getClient(id: string): WsClient | undefined {
88
+ return this.clients.get(id);
89
+ }
90
+ }