ai-control-center 1.15.2

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 (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +584 -0
  3. package/bin/aicc.js +772 -0
  4. package/lib/actions/approve.js +71 -0
  5. package/lib/actions/assign-project.js +132 -0
  6. package/lib/actions/browser-test.js +64 -0
  7. package/lib/actions/cleanup.js +174 -0
  8. package/lib/actions/debug.js +298 -0
  9. package/lib/actions/deploy.js +1229 -0
  10. package/lib/actions/fix-bug.js +134 -0
  11. package/lib/actions/new-feature.js +255 -0
  12. package/lib/actions/reject.js +307 -0
  13. package/lib/actions/review.js +706 -0
  14. package/lib/actions/status.js +47 -0
  15. package/lib/agents/browser-qa-agent.js +611 -0
  16. package/lib/agents/payment-agent.js +116 -0
  17. package/lib/agents/suggestion-agent.js +88 -0
  18. package/lib/cli.js +303 -0
  19. package/lib/config.js +243 -0
  20. package/lib/hub/hub-server.js +440 -0
  21. package/lib/hub/project-poller.js +75 -0
  22. package/lib/hub/skill-registry.js +89 -0
  23. package/lib/hub/state-aggregator.js +204 -0
  24. package/lib/index.js +471 -0
  25. package/lib/init/doctor.js +523 -0
  26. package/lib/init/presets.js +222 -0
  27. package/lib/init/skill-fetcher.js +77 -0
  28. package/lib/init/wizard.js +973 -0
  29. package/lib/integrations/codex-runner.js +128 -0
  30. package/lib/integrations/github-actions.js +248 -0
  31. package/lib/integrations/github-reporter.js +229 -0
  32. package/lib/integrations/screenshot-store.js +102 -0
  33. package/lib/openclaw/bridge.js +650 -0
  34. package/lib/openclaw/generate-skill.js +235 -0
  35. package/lib/openclaw/openclaw.json +64 -0
  36. package/lib/orchestrator/autonomous-loop.js +429 -0
  37. package/lib/orchestrator/thread-triggers.js +63 -0
  38. package/lib/roleplay/agent-messenger.js +75 -0
  39. package/lib/roleplay/discussion-threads.js +303 -0
  40. package/lib/roleplay/health-monitor.js +121 -0
  41. package/lib/roleplay/pm-agent.js +513 -0
  42. package/lib/roleplay/roleplay-config.js +25 -0
  43. package/lib/roleplay/room.js +164 -0
  44. package/lib/shared/action-runner.js +2330 -0
  45. package/lib/shared/event-bus.js +185 -0
  46. package/lib/slack/bot.js +378 -0
  47. package/lib/telegram/bot.js +416 -0
  48. package/lib/telegram/commands.js +1267 -0
  49. package/lib/telegram/keyboards.js +113 -0
  50. package/lib/telegram/notifications.js +247 -0
  51. package/lib/twitch/bot.js +354 -0
  52. package/lib/twitch/commands.js +302 -0
  53. package/lib/twitch/notifications.js +63 -0
  54. package/lib/utils/achievements.js +191 -0
  55. package/lib/utils/activity-log.js +182 -0
  56. package/lib/utils/agent-leaderboard.js +119 -0
  57. package/lib/utils/audit-logger.js +232 -0
  58. package/lib/utils/codebase-context.js +288 -0
  59. package/lib/utils/codebase-indexer.js +381 -0
  60. package/lib/utils/config-schema.js +230 -0
  61. package/lib/utils/context-compressor.js +172 -0
  62. package/lib/utils/correlation.js +63 -0
  63. package/lib/utils/cost-tracker.js +423 -0
  64. package/lib/utils/cron-scheduler.js +53 -0
  65. package/lib/utils/db-adapter.js +293 -0
  66. package/lib/utils/display.js +272 -0
  67. package/lib/utils/errors.js +116 -0
  68. package/lib/utils/format.js +134 -0
  69. package/lib/utils/intent-engine.js +464 -0
  70. package/lib/utils/mcp-client.js +238 -0
  71. package/lib/utils/model-ab-test.js +164 -0
  72. package/lib/utils/notify.js +122 -0
  73. package/lib/utils/persona-loader.js +80 -0
  74. package/lib/utils/pipeline-lock.js +73 -0
  75. package/lib/utils/pipeline.js +214 -0
  76. package/lib/utils/plugin-runner.js +234 -0
  77. package/lib/utils/rate-limiter.js +84 -0
  78. package/lib/utils/rbac.js +74 -0
  79. package/lib/utils/runner.js +1809 -0
  80. package/lib/utils/security.js +191 -0
  81. package/lib/utils/self-healer.js +144 -0
  82. package/lib/utils/skill-loader.js +255 -0
  83. package/lib/utils/spinner.js +132 -0
  84. package/lib/utils/stage-queue.js +50 -0
  85. package/lib/utils/state-machine.js +89 -0
  86. package/lib/utils/status-bar.js +327 -0
  87. package/lib/utils/token-estimator.js +101 -0
  88. package/lib/utils/ux-analyzer.js +101 -0
  89. package/lib/utils/webhook-emitter.js +83 -0
  90. package/lib/web/public/css/styles.css +417 -0
  91. package/lib/web/public/dark-mode.js +44 -0
  92. package/lib/web/public/hub/kanban.html +206 -0
  93. package/lib/web/public/index.html +45 -0
  94. package/lib/web/public/js/app.js +71 -0
  95. package/lib/web/public/js/ask.js +110 -0
  96. package/lib/web/public/js/dashboard.js +165 -0
  97. package/lib/web/public/js/deploy.js +72 -0
  98. package/lib/web/public/js/feature.js +79 -0
  99. package/lib/web/public/js/health.js +65 -0
  100. package/lib/web/public/js/logs.js +93 -0
  101. package/lib/web/public/js/review.js +123 -0
  102. package/lib/web/public/js/ws-client.js +82 -0
  103. package/lib/web/public/office/css/office.css +678 -0
  104. package/lib/web/public/office/index.html +148 -0
  105. package/lib/web/public/office/js/achievements-ui.js +117 -0
  106. package/lib/web/public/office/js/character.js +1056 -0
  107. package/lib/web/public/office/js/chat-bubbles.js +177 -0
  108. package/lib/web/public/office/js/cost-overlay.js +123 -0
  109. package/lib/web/public/office/js/day-night.js +68 -0
  110. package/lib/web/public/office/js/effects.js +632 -0
  111. package/lib/web/public/office/js/engine.js +146 -0
  112. package/lib/web/public/office/js/feature-ticket.js +216 -0
  113. package/lib/web/public/office/js/hub-client.js +60 -0
  114. package/lib/web/public/office/js/main.js +1757 -0
  115. package/lib/web/public/office/js/office-layout.js +1524 -0
  116. package/lib/web/public/office/js/pathfinding.js +144 -0
  117. package/lib/web/public/office/js/pixel-sprites.js +1454 -0
  118. package/lib/web/public/office/js/progress-bars.js +117 -0
  119. package/lib/web/public/office/js/replay.js +191 -0
  120. package/lib/web/public/office/js/sound-effects.js +91 -0
  121. package/lib/web/public/office/js/sprite-renderer.js +211 -0
  122. package/lib/web/public/office/js/stamina-system.js +89 -0
  123. package/lib/web/public/office/js/ui.js +107 -0
  124. package/lib/web/public/onboarding/index.html +243 -0
  125. package/lib/web/public/timeline/index.html +195 -0
  126. package/lib/web/routes/api.js +499 -0
  127. package/lib/web/routes/logs.js +20 -0
  128. package/lib/web/routes/metrics.js +99 -0
  129. package/lib/web/server.js +183 -0
  130. package/lib/web/ws/handler.js +65 -0
  131. package/package.json +67 -0
  132. package/templates/agent-architect.md +69 -0
  133. package/templates/agent-gemini-pm.md +49 -0
  134. package/templates/agent-gemini-reviewer.md +52 -0
  135. package/templates/copilot-instructions.md +36 -0
  136. package/templates/pipelines/mobile.json +27 -0
  137. package/templates/pipelines/nodejs-api.json +27 -0
  138. package/templates/pipelines/python.json +27 -0
  139. package/templates/pipelines/react.json +27 -0
  140. package/templates/pipelines/salesforce.json +27 -0
  141. package/templates/role-gemini.md +97 -0
  142. package/templates/skill-architect.md +114 -0
  143. package/templates/skill-browser-qa.md +50 -0
  144. package/templates/skill-bug-from-qa.md +58 -0
  145. package/templates/skill-chatbot.md +93 -0
  146. package/templates/skill-implement.md +78 -0
  147. package/templates/skill-openclaw.md +174 -0
  148. package/templates/skill-payment.md +110 -0
  149. package/templates/skill-pm-spec.md +77 -0
  150. package/templates/skill-requirement-capture.md +97 -0
  151. package/templates/skill-review.md +108 -0
  152. package/templates/skill-reviewer-qa.md +44 -0
  153. package/templates/skill-suggestion.md +45 -0
  154. package/templates/skill-template.md +142 -0
package/lib/config.js ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Config loader for ai-control-center.
3
+ *
4
+ * Finds and loads `aicc.config.js` from the project root.
5
+ * Provides a singleton via getConfig() and env-var helper via env().
6
+ */
7
+ import { existsSync, readFileSync } from 'fs';
8
+ import { dirname, resolve } from 'path';
9
+ import { pathToFileURL } from 'url';
10
+
11
+ let _config = null;
12
+
13
+ const DEFAULTS = {
14
+ workflowDir: '.ai-workflow',
15
+ skillsDir: '.claude/skills',
16
+ envPrefix: 'AICC',
17
+ stages: {
18
+ idle: { label: 'Idle', color: 'gray' },
19
+ inbox: { label: 'Feature Submitted', color: 'yellow' },
20
+ spec_complete: { label: 'PM Spec Ready', color: 'cyan' },
21
+ arch_complete: { label: 'Arch Ready', color: 'blue' },
22
+ implementation_complete: { label: 'Code Done', color: 'magenta' },
23
+ review_complete: { label: 'Review Ready', color: 'green' },
24
+ approved: { label: 'Approved', color: 'green' },
25
+ rejected: { label: 'Rejected', color: 'red' },
26
+ deployed: { label: 'Deployed', color: 'green' },
27
+ },
28
+ deploy: { command: null, options: [] },
29
+ review: { extensions: ['.js', '.ts', '.py', '.go'], sourceDir: 'src/' },
30
+ ai: { system: 'You are a concise DevOps assistant.', roleFile: null },
31
+ feature: { placeholder: 'Describe the feature you want to build...' },
32
+ queries: [],
33
+ debug: { commands: [] },
34
+ web: { port: 3847 },
35
+ plugins: [],
36
+
37
+ // ─── Pipeline — Stage × AI Model Configuration ──────────────────────────
38
+ // Each entry defines ONE stage: which AI provider runs it and which model.
39
+ // To switch a model, just change the `model` value for that stage.
40
+ //
41
+ // provider: 'openclaw' | 'copilot' | 'claude' | 'gemini' | 'ollama'
42
+ // model: the AI model name (copilot uses period format, claude uses hyphen)
43
+ // fallbacks: ordered list of backup 'provider:model' pairs (optional)
44
+ //
45
+ // Env var overrides (applied at runtime, take precedence over config):
46
+ // GEMINI_MODEL, CLAUDE_MODEL, COPILOT_MODEL, OLLAMA_MODEL
47
+ pipeline: [
48
+ { stage: 'chat', provider: 'claude', model: 'claude-haiku-4-5', fallbacks: ['gemini:gemini-2.5-flash', 'copilot:claude-haiku-4.5', 'ollama'] },
49
+ { stage: 'pm', provider: 'copilot', model: 'claude-sonnet-4.6', fallbacks: ['gemini:gemini-2.5-pro', 'ollama'] },
50
+ { stage: 'architect', provider: 'copilot', model: 'claude-sonnet-4.6', fallbacks: ['gemini:gemini-2.5-pro', 'ollama'] },
51
+ { stage: 'implement', provider: 'claude', model: 'claude-sonnet-4-6', fallbacks: ['copilot:claude-sonnet-4.6', 'ollama'] },
52
+ { stage: 'review', provider: 'copilot', model: 'claude-sonnet-4.6', fallbacks: ['gemini:gemini-2.5-pro', 'ollama'] },
53
+ { stage: 'deploy', provider: 'copilot', model: 'claude-sonnet-4.6', fallbacks: ['gemini:gemini-2.5-pro', 'ollama'] },
54
+ ],
55
+
56
+ // Ollama local fallback settings
57
+ ollama: { model: 'llama3.1', baseUrl: 'http://localhost:11434' },
58
+
59
+ roleplay: {
60
+ enabled: true,
61
+ verbosity: 'normal',
62
+ pmPersonality: 'professional',
63
+ agentChat: true,
64
+ progressUpdates: true,
65
+ progressInterval: 60000,
66
+ maxReviewCycles: 3,
67
+ autoDeployOnApproval: false,
68
+ openclawBridge: true, // Enable OpenClaw bridge for multi-channel communication
69
+ },
70
+ };
71
+
72
+ /**
73
+ * Walk up from `startDir` to find aicc.config.js.
74
+ */
75
+ function findProjectRoot(startDir) {
76
+ let dir = startDir;
77
+ while (true) {
78
+ if (existsSync(resolve(dir, 'aicc.config.js'))) return dir;
79
+ const parent = dirname(dir);
80
+ if (parent === dir) return null; // reached filesystem root
81
+ dir = parent;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Load the project config. Must be called once before getConfig().
87
+ */
88
+ export async function loadConfig(cwd) {
89
+ if (_config) return _config;
90
+
91
+ const root = findProjectRoot(cwd || process.cwd());
92
+ if (!root) {
93
+ throw new Error(
94
+ 'No aicc.config.js found.\n' +
95
+ 'Run "aicc init" to create one, or ensure you\'re in the project directory.'
96
+ );
97
+ }
98
+
99
+ // Load .env file if present (does NOT override existing env vars)
100
+ const envFilePath = resolve(root, '.env');
101
+ if (existsSync(envFilePath)) {
102
+ try {
103
+ const envContent = readFileSync(envFilePath, 'utf8');
104
+ for (const line of envContent.split('\n')) {
105
+ const trimmed = line.trim();
106
+ if (!trimmed || trimmed.startsWith('#')) continue;
107
+ const eqIdx = trimmed.indexOf('=');
108
+ if (eqIdx === -1) continue;
109
+ const key = trimmed.slice(0, eqIdx).trim();
110
+ const val = trimmed.slice(eqIdx + 1).trim();
111
+ if (key && !(key in process.env)) {
112
+ process.env[key] = val;
113
+ }
114
+ }
115
+ } catch { /* ignore .env read errors */ }
116
+ }
117
+
118
+ const configPath = resolve(root, 'aicc.config.js');
119
+ const mod = await import(pathToFileURL(configPath).href);
120
+ const userConfig = mod.default || mod;
121
+
122
+ // ── Backward compat: convert old `models` config → new `pipeline` format ──
123
+ let pipeline = userConfig.pipeline || null;
124
+ if (!pipeline && userConfig.models) {
125
+ pipeline = _migrateModelsToPipeline(userConfig.models);
126
+ }
127
+
128
+ // Merge with defaults
129
+ _config = {
130
+ ...DEFAULTS,
131
+ ...userConfig,
132
+ stages: { ...DEFAULTS.stages, ...(userConfig.stages || {}) },
133
+ deploy: { ...DEFAULTS.deploy, ...(userConfig.deploy || {}) },
134
+ review: { ...DEFAULTS.review, ...(userConfig.review || {}) },
135
+ ai: { ...DEFAULTS.ai, ...(userConfig.ai || {}) },
136
+ feature: { ...DEFAULTS.feature, ...(userConfig.feature || {}) },
137
+ debug: { ...DEFAULTS.debug, ...(userConfig.debug || {}) },
138
+ web: { ...DEFAULTS.web, ...(userConfig.web || {}) },
139
+ pipeline: pipeline || DEFAULTS.pipeline,
140
+ ollama: { ...DEFAULTS.ollama, ...(userConfig.ollama || {}) },
141
+ roleplay: { ...DEFAULTS.roleplay, ...(userConfig.roleplay || {}) },
142
+ // Resolved absolute paths
143
+ _root: root,
144
+ _workflowDir: resolve(root, userConfig.workflowDir || DEFAULTS.workflowDir),
145
+ _statusFile: resolve(root, userConfig.workflowDir || DEFAULTS.workflowDir, 'status.json'),
146
+ _configPath: configPath,
147
+ };
148
+
149
+ return _config;
150
+ }
151
+
152
+ /**
153
+ * Get the loaded config. Throws if loadConfig() hasn't been called.
154
+ */
155
+ export function getConfig() {
156
+ if (!_config) {
157
+ throw new Error('Config not loaded. Call loadConfig() first.');
158
+ }
159
+ return _config;
160
+ }
161
+
162
+ /**
163
+ * Get an environment variable using the configured prefix.
164
+ * env('TELEGRAM_TOKEN') → process.env.XCONN_TELEGRAM_TOKEN (if envPrefix is 'XCONN')
165
+ * Falls back to AICC_ prefix.
166
+ */
167
+ export function env(suffix) {
168
+ const prefix = _config ? _config.envPrefix : 'AICC';
169
+ return process.env[`${prefix}_${suffix}`] || process.env[`AICC_${suffix}`];
170
+ }
171
+
172
+ /**
173
+ * Get the pipeline entry for a given stage.
174
+ * Returns { stage, provider, model, fallbacks } or a sensible default.
175
+ */
176
+ export function getPipelineStage(stage) {
177
+ const cfg = _config || DEFAULTS;
178
+ const pipeline = cfg.pipeline || DEFAULTS.pipeline;
179
+ const entry = pipeline.find(e => e.stage === stage);
180
+ if (entry) return entry;
181
+ // Fallback: use 'default' entry or first non-chat entry
182
+ const defaultEntry = pipeline.find(e => e.stage === 'default');
183
+ if (defaultEntry) return { ...defaultEntry, stage };
184
+ return { stage, provider: 'copilot', model: 'claude-sonnet-4.6', fallbacks: ['gemini:gemini-2.5-pro', 'ollama'] };
185
+ }
186
+
187
+ /**
188
+ * Get the Ollama config.
189
+ */
190
+ export function getOllamaConfig() {
191
+ const cfg = _config || DEFAULTS;
192
+ return cfg.ollama || DEFAULTS.ollama;
193
+ }
194
+
195
+ /**
196
+ * Backward compat: convert old `models` config to new `pipeline[]` format.
197
+ */
198
+ function _migrateModelsToPipeline(models) {
199
+ const sp = models.stageProviders || {};
200
+ const chatModel = models.chatModel || 'claude-haiku-4.5';
201
+ const crossModel = models.copilotCrossAiModel || 'claude-sonnet-4.6';
202
+ const claudeModel = (models.claudeChain || ['claude-sonnet-4-6'])[0];
203
+ const copilotModel = (models.copilotChain || ['claude-sonnet-4.6'])[0];
204
+ const geminiModel = (models.geminiChain || ['gemini-2.5-pro'])[0];
205
+
206
+ function buildEntry(stage) {
207
+ const providers = sp[stage] || sp.default || ['copilot', 'gemini', 'ollama'];
208
+ const primary = providers[0];
209
+ let model;
210
+ switch (primary) {
211
+ case 'claude': model = claudeModel; break;
212
+ case 'gemini': model = (models.stages?.[stage]?.geminiChain || [geminiModel])[0]; break;
213
+ case 'copilot': model = stage === 'implement' ? copilotModel : crossModel; break;
214
+ default: model = models.ollamaModel || 'llama3.1';
215
+ }
216
+ const fallbacks = providers.slice(1).map(p => {
217
+ switch (p) {
218
+ case 'gemini': return `gemini:${geminiModel}`;
219
+ case 'claude': return `claude:${claudeModel}`;
220
+ case 'copilot': return `copilot:${copilotModel}`;
221
+ case 'ollama': return 'ollama';
222
+ default: return p;
223
+ }
224
+ });
225
+ return { stage, provider: primary, model, fallbacks };
226
+ }
227
+
228
+ return [
229
+ { stage: 'chat', provider: 'copilot', model: chatModel },
230
+ buildEntry('pm'),
231
+ buildEntry('architect'),
232
+ buildEntry('implement'),
233
+ buildEntry('review'),
234
+ buildEntry('deploy'),
235
+ ];
236
+ }
237
+
238
+ /**
239
+ * Reset config (for testing).
240
+ */
241
+ export function resetConfig() {
242
+ _config = null;
243
+ }
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Hub Server — Multi-project aggregator for AI Control Center.
3
+ *
4
+ * Polls each registered project's /api/status and /api/health,
5
+ * aggregates state, and broadcasts changes via WebSocket.
6
+ *
7
+ * Uses native http.createServer with simple request routing (no Express).
8
+ *
9
+ * Usage: aicc hub
10
+ */
11
+ import { createServer } from 'http';
12
+ import { existsSync, readFileSync } from 'fs';
13
+ import { dirname, extname, join, resolve } from 'path';
14
+ import { fileURLToPath } from 'url';
15
+ import WebSocket, { WebSocketServer } from 'ws';
16
+ import { getConfig } from '../config.js';
17
+ import { aggregateStates, detectChanges } from './state-aggregator.js';
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+
21
+ // ─── Helpers ───────────────────────────────────────────────────────────────────
22
+
23
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
24
+
25
+ /** Parse JSON body from an IncomingMessage */
26
+ function parseBody(req) {
27
+ return new Promise((resolve, reject) => {
28
+ const chunks = [];
29
+ req.on('data', (c) => chunks.push(c));
30
+ req.on('end', () => {
31
+ try { resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {}); }
32
+ catch (e) { reject(e); }
33
+ });
34
+ req.on('error', reject);
35
+ });
36
+ }
37
+
38
+ /** Send a JSON response */
39
+ function json(res, data, status = 200) {
40
+ const body = JSON.stringify(data);
41
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
42
+ res.end(body);
43
+ }
44
+
45
+ /** Simple URL path matcher — returns params or null */
46
+ function matchRoute(method, url, pattern, expectedMethod) {
47
+ if (method !== expectedMethod) return null;
48
+ const urlPath = url.split('?')[0];
49
+ const patternParts = pattern.split('/');
50
+ const urlParts = urlPath.split('/');
51
+ if (patternParts.length !== urlParts.length) return null;
52
+ const params = {};
53
+ for (let i = 0; i < patternParts.length; i++) {
54
+ if (patternParts[i].startsWith(':')) {
55
+ params[patternParts[i].slice(1)] = decodeURIComponent(urlParts[i]);
56
+ } else if (patternParts[i] !== urlParts[i]) {
57
+ return null;
58
+ }
59
+ }
60
+ return params;
61
+ }
62
+
63
+ // ─── HubServer class ───────────────────────────────────────────────────────────
64
+
65
+ /**
66
+ * Multi-project hub that aggregates status from multiple AICC instances.
67
+ *
68
+ * @example
69
+ * const hub = new HubServer({ port: 3850, pollInterval: 3000, projects: [...] });
70
+ * await hub.start();
71
+ */
72
+ export class HubServer {
73
+ /**
74
+ * @param {object} config — hub configuration
75
+ * @param {number} [config.port=3850] — HTTP port
76
+ * @param {number} [config.pollInterval=3000] — polling interval in ms
77
+ * @param {Array<{name:string, url:string, color?:string, icon?:string}>} config.projects
78
+ */
79
+ constructor(config = {}) {
80
+ this._port = config.port || 3850;
81
+ this._pollInterval = config.pollInterval || 30000; // health poll only, slow
82
+ this._projects = config.projects || [];
83
+ this._server = null;
84
+ this._wss = null;
85
+ this._pollTimer = null;
86
+ this._state = null;
87
+ this._running = false;
88
+ this._projectWs = new Map(); // project name → WebSocket client
89
+ this._projectStatus = new Map(); // project name → latest status
90
+ this._projectHealth = new Map(); // project name → latest health
91
+ this._projectOnline = new Map(); // project name → boolean
92
+ this._reconnectTimers = new Map(); // project name → reconnect timer
93
+ }
94
+
95
+ /** Start HTTP server + WebSocket, begin polling */
96
+ async start() {
97
+ this._server = createServer((req, res) => this._handleRequest(req, res));
98
+ this._wss = new WebSocketServer({ server: this._server, path: '/ws' });
99
+
100
+ this._wss.on('connection', (ws) => {
101
+ // Send current state on connect
102
+ if (this._state) {
103
+ try { ws.send(JSON.stringify({ type: 'state', ...this._state })); } catch { /* skip */ }
104
+ }
105
+ });
106
+
107
+ this._running = true;
108
+ // Subscribe to each project's WebSocket for real-time updates
109
+ for (const project of this._projects) {
110
+ this._subscribeToProject(project);
111
+ }
112
+ // Initial poll to get health data and bootstrap state
113
+ await this._pollProjects();
114
+ // Slow poll for health checks only (status comes via WebSocket)
115
+ this._schedulePoll();
116
+
117
+ return new Promise((resolve) => {
118
+ this._server.listen(this._port, () => {
119
+ console.log(`\n 🏢 AI Hub Server`);
120
+ console.log(` ─────────────────────────────────────`);
121
+ console.log(` API: http://localhost:${this._port}/api/projects`);
122
+ console.log(` Health: http://localhost:${this._port}/api/health`);
123
+ console.log(` WebSocket: ws://localhost:${this._port}/ws`);
124
+ console.log(` Projects:`);
125
+ for (const p of this._projects) {
126
+ console.log(` • ${p.icon || '📦'} ${p.name} → ${p.url}`);
127
+ }
128
+ console.log();
129
+ resolve();
130
+ });
131
+ });
132
+ }
133
+
134
+ /** Gracefully shut down */
135
+ async stop() {
136
+ this._running = false;
137
+ if (this._pollTimer) { clearTimeout(this._pollTimer); this._pollTimer = null; }
138
+ // Close all project WebSocket subscriptions
139
+ for (const [name, ws] of this._projectWs) {
140
+ try { ws.close(); } catch { /* ignore */ }
141
+ }
142
+ this._projectWs.clear();
143
+ for (const timer of this._reconnectTimers.values()) clearTimeout(timer);
144
+ this._reconnectTimers.clear();
145
+ if (this._wss) { this._wss.close(); this._wss = null; }
146
+ if (this._server) {
147
+ await new Promise((resolve) => this._server.close(resolve));
148
+ this._server = null;
149
+ }
150
+ }
151
+
152
+ /** Returns the current aggregated project states */
153
+ getProjectStates() {
154
+ return this._state;
155
+ }
156
+
157
+ // ── Real-time WebSocket subscriptions ───────────────────────────────────────
158
+
159
+ /** Subscribe to a project's WebSocket for instant status updates */
160
+ _subscribeToProject(project) {
161
+ if (!this._running) return;
162
+ const wsUrl = project.url.replace(/^http/, 'ws') + '/ws';
163
+ try {
164
+ const ws = new WebSocket(wsUrl);
165
+ ws.on('open', () => {
166
+ this._projectOnline.set(project.name, true);
167
+ this._projectWs.set(project.name, ws);
168
+ // Clear reconnect timer
169
+ const rt = this._reconnectTimers.get(project.name);
170
+ if (rt) { clearTimeout(rt); this._reconnectTimers.delete(project.name); }
171
+ });
172
+ ws.on('message', (raw) => {
173
+ try {
174
+ const msg = JSON.parse(raw.toString());
175
+ if (msg.type === 'status' && msg.data) {
176
+ this._projectStatus.set(project.name, msg.data.status || msg.data);
177
+ this._projectOnline.set(project.name, true);
178
+ this._rebuildAndBroadcast();
179
+ }
180
+ } catch { /* ignore non-JSON */ }
181
+ });
182
+ ws.on('close', () => {
183
+ this._projectWs.delete(project.name);
184
+ this._projectOnline.set(project.name, false);
185
+ this._rebuildAndBroadcast();
186
+ this._scheduleReconnect(project);
187
+ });
188
+ ws.on('error', () => {
189
+ this._projectOnline.set(project.name, false);
190
+ try { ws.close(); } catch { /* ignore */ }
191
+ });
192
+ } catch {
193
+ this._scheduleReconnect(project);
194
+ }
195
+ }
196
+
197
+ /** Reconnect to a project's WebSocket after a delay */
198
+ _scheduleReconnect(project) {
199
+ if (!this._running) return;
200
+ if (this._reconnectTimers.has(project.name)) return;
201
+ const timer = setTimeout(() => {
202
+ this._reconnectTimers.delete(project.name);
203
+ this._subscribeToProject(project);
204
+ }, 5000);
205
+ this._reconnectTimers.set(project.name, timer);
206
+ }
207
+
208
+ /** Rebuild aggregated state from cached data and broadcast */
209
+ _rebuildAndBroadcast() {
210
+ const responses = this._projects.map((project) => ({
211
+ name: project.name,
212
+ status: this._projectStatus.get(project.name) || null,
213
+ health: this._projectHealth.get(project.name) || null,
214
+ online: this._projectOnline.get(project.name) || false,
215
+ }));
216
+
217
+ const oldState = this._state;
218
+ this._state = aggregateStates(this._projects, responses);
219
+
220
+ const changes = oldState ? detectChanges(oldState, this._state) : [];
221
+ if (!oldState || changes.length > 0) {
222
+ if (changes.length > 0) {
223
+ for (const c of changes) {
224
+ console.log(`[Hub] Change: ${c.project} ${c.type} ${c.from} → ${c.to}`);
225
+ }
226
+ }
227
+ this._broadcastState();
228
+ }
229
+ }
230
+
231
+ // ── Polling (health checks only) ──────────────────────────────────────────
232
+
233
+ /** Poll each project's /api/status and /api/health — updates cache maps */
234
+ async _pollProjects() {
235
+ await Promise.all(
236
+ this._projects.map(async (project) => {
237
+ try {
238
+ const [statusRes, healthRes] = await Promise.all([
239
+ fetch(`${project.url}/api/status`, { signal: AbortSignal.timeout(5000) }),
240
+ fetch(`${project.url}/api/health`, { signal: AbortSignal.timeout(5000) }),
241
+ ]);
242
+ if (statusRes.ok) this._projectStatus.set(project.name, await statusRes.json());
243
+ if (healthRes.ok) this._projectHealth.set(project.name, await healthRes.json());
244
+ this._projectOnline.set(project.name, statusRes.ok);
245
+ } catch {
246
+ // Only mark offline if no WebSocket is connected
247
+ if (!this._projectWs.has(project.name)) {
248
+ this._projectOnline.set(project.name, false);
249
+ }
250
+ }
251
+ }),
252
+ );
253
+ this._rebuildAndBroadcast();
254
+ }
255
+
256
+ /** Schedule the next poll */
257
+ _schedulePoll() {
258
+ if (!this._running) return;
259
+ this._pollTimer = setTimeout(async () => {
260
+ await this._pollProjects();
261
+ this._schedulePoll();
262
+ }, this._pollInterval);
263
+ }
264
+
265
+ /** Broadcast current state to all connected WebSocket clients */
266
+ _broadcastState() {
267
+ if (!this._wss || !this._state) return;
268
+ const msg = JSON.stringify({ type: 'state', ...this._state });
269
+ for (const ws of this._wss.clients) {
270
+ if (ws.readyState === 1) {
271
+ try { ws.send(msg); } catch { /* skip dead connections */ }
272
+ }
273
+ }
274
+ }
275
+
276
+ // ── HTTP request routing ─────────────────────────────────────────────────────
277
+
278
+ async _handleRequest(req, res) {
279
+ const { method, url } = req;
280
+ let params;
281
+
282
+ // CORS headers for dashboard access
283
+ res.setHeader('Access-Control-Allow-Origin', '*');
284
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
285
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
286
+ if (method === 'OPTIONS') { res.writeHead(204); return res.end(); }
287
+
288
+ try {
289
+ // GET /api/projects — all projects with current state
290
+ if (method === 'GET' && url.split('?')[0] === '/api/projects') {
291
+ const state = this._state || { projects: {}, summary: {} };
292
+ return json(res, {
293
+ projects: Object.values(state.projects),
294
+ summary: state.summary,
295
+ hub: { port: this._port, pollInterval: this._pollInterval },
296
+ });
297
+ }
298
+
299
+ // GET /api/projects/:name — single project detail
300
+ if ((params = matchRoute(method, url, '/api/projects/:name', 'GET'))) {
301
+ const state = this._state || { projects: {} };
302
+ const project = state.projects[params.name];
303
+ if (!project) return json(res, { error: 'Project not found' }, 404);
304
+ return json(res, project);
305
+ }
306
+
307
+ // POST /api/projects/:name/feature — forward feature submission
308
+ if ((params = matchRoute(method, url, '/api/projects/:name/feature', 'POST'))) {
309
+ const projectCfg = this._projects.find(p => p.name === params.name);
310
+ if (!projectCfg) return json(res, { error: 'Project not found' }, 404);
311
+ const body = await parseBody(req);
312
+ try {
313
+ const r = await fetch(`${projectCfg.url}/api/feature`, {
314
+ method: 'POST',
315
+ headers: { 'Content-Type': 'application/json' },
316
+ body: JSON.stringify(body),
317
+ });
318
+ return json(res, await r.json(), r.status);
319
+ } catch (e) {
320
+ return json(res, { error: `Cannot reach ${projectCfg.name}: ${e.message}` }, 502);
321
+ }
322
+ }
323
+
324
+ // POST /api/projects/:name/action/:action — forward any pipeline action
325
+ if (method === 'POST') {
326
+ const actionMatch = url.split('?')[0].match(/^\/api\/projects\/([^/]+)\/action\/([^/]+)$/);
327
+ if (actionMatch) {
328
+ const projectName = decodeURIComponent(actionMatch[1]);
329
+ const action = decodeURIComponent(actionMatch[2]);
330
+ const projectCfg = this._projects.find(p => p.name === projectName);
331
+ if (!projectCfg) return json(res, { error: 'Project not found' }, 404);
332
+ const body = await parseBody(req);
333
+ try {
334
+ const r = await fetch(`${projectCfg.url}/api/${action}`, {
335
+ method: 'POST',
336
+ headers: { 'Content-Type': 'application/json' },
337
+ body: JSON.stringify(body),
338
+ signal: AbortSignal.timeout(30000),
339
+ });
340
+ return json(res, await r.json(), r.status);
341
+ } catch (e) {
342
+ return json(res, { error: `Cannot reach ${projectCfg.name}: ${e.message}` }, 502);
343
+ }
344
+ }
345
+ }
346
+
347
+ // GET /api/health — hub health + all project health
348
+ if (method === 'GET' && url.split('?')[0] === '/api/health') {
349
+ const state = this._state || { projects: {}, summary: { total: 0, active: 0, idle: 0, offline: 0 } };
350
+ return json(res, {
351
+ hub: 'ok',
352
+ uptime: process.uptime(),
353
+ projectCount: state.summary.total,
354
+ onlineCount: state.summary.total - state.summary.offline,
355
+ projects: Object.values(state.projects).map(p => ({
356
+ name: p.name,
357
+ online: p.online,
358
+ health: p.health,
359
+ })),
360
+ });
361
+ }
362
+
363
+ // Serve /office/ static files (pixel art RPG visualizer)
364
+ const urlPath = url.split('?')[0];
365
+ if (method === 'GET' && urlPath.startsWith('/office')) {
366
+ const officeDir = resolve(__dirname, '../web/public/office');
367
+ let filePath = urlPath === '/office' || urlPath === '/office/'
368
+ ? join(officeDir, 'index.html')
369
+ : join(officeDir, urlPath.replace('/office/', ''));
370
+
371
+ if (existsSync(filePath)) {
372
+ const MIME = {
373
+ '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
374
+ '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml',
375
+ '.woff2': 'font/woff2', '.woff': 'font/woff',
376
+ };
377
+ const ext = extname(filePath);
378
+ const content = readFileSync(filePath);
379
+ res.writeHead(200, {
380
+ 'Content-Type': MIME[ext] || 'application/octet-stream',
381
+ 'Cache-Control': 'no-cache',
382
+ });
383
+ return res.end(content);
384
+ }
385
+ }
386
+
387
+ // 404 fallback
388
+ json(res, { error: 'Not found' }, 404);
389
+ } catch (e) {
390
+ json(res, { error: e.message }, 500);
391
+ }
392
+ }
393
+ }
394
+
395
+ // ─── Convenience starter ───────────────────────────────────────────────────────
396
+
397
+ /**
398
+ * Start the hub server with the given options or from loaded config.
399
+ *
400
+ * @param {object} [options]
401
+ * @param {number} [options.port] — override port (default from config or 3850)
402
+ * @returns {{ server: import('http').Server, wss: WebSocketServer }}
403
+ */
404
+ export async function startHub(options = {}) {
405
+ let hubConfig;
406
+ try {
407
+ const cfg = getConfig();
408
+ hubConfig = cfg.hub || {};
409
+ } catch {
410
+ hubConfig = {};
411
+ }
412
+
413
+ const config = {
414
+ port: options.port || hubConfig.port || 3850,
415
+ pollInterval: hubConfig.pollInterval || 3000,
416
+ projects: hubConfig.projects || [],
417
+ };
418
+
419
+ if (config.projects.length === 0) {
420
+ console.error(' ✗ No projects configured for hub.');
421
+ console.error(' Add hub.projects to aicc.config.js');
422
+ console.error(' Example:');
423
+ console.error(' hub: {');
424
+ console.error(' projects: [');
425
+ console.error(' { name: "MyProject", url: "http://localhost:3847" }');
426
+ console.error(' ]');
427
+ console.error(' }');
428
+ process.exit(1);
429
+ }
430
+
431
+ const hub = new HubServer(config);
432
+ await hub.start();
433
+
434
+ // Graceful shutdown
435
+ const shutdown = () => { hub.stop().then(() => process.exit(0)); };
436
+ process.on('SIGINT', shutdown);
437
+ process.on('SIGTERM', shutdown);
438
+
439
+ return { server: hub._server, wss: hub._wss };
440
+ }