vibeteam 0.5.4 → 0.6.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.
@@ -1412,6 +1412,110 @@ function broadcastKanbanTasks(projectId) {
1412
1412
 
1413
1413
  const IDEATION_DIR = resolve(expandHome('~/.vibeteam/data/ideation'));
1414
1414
 
1415
+ // ============================================================================
1416
+ // Custom Ideation Categories
1417
+ // ============================================================================
1418
+
1419
+ const DEFAULT_CATEGORIES = [
1420
+ { id: 'code_improvements', label: 'Code Improvements', color: '#4ade80', icon: '🔧', isDefault: true },
1421
+ { id: 'ui_ux', label: 'UI/UX', color: '#60a5fa', icon: '🎨', isDefault: true },
1422
+ { id: 'security', label: 'Security', color: '#f87171', icon: '🔒', isDefault: true },
1423
+ { id: 'performance', label: 'Performance', color: '#fbbf24', icon: '⚡', isDefault: true },
1424
+ { id: 'documentation', label: 'Documentation', color: '#a78bfa', icon: '📝', isDefault: true },
1425
+ { id: 'code_quality', label: 'Code Quality', color: '#22d3d8', icon: '✨', isDefault: true },
1426
+ { id: 'new_features', label: 'New Features', color: '#a855f7', icon: '🚀', isDefault: true },
1427
+ ];
1428
+
1429
+ const CUSTOM_CATEGORIES_FILE = resolve(expandHome('~/.vibeteam/data/custom-categories.json'));
1430
+
1431
+ class CustomCategoryManager {
1432
+ constructor() {
1433
+ this.categories = []; // Array of { id, label, color, icon, isCustom: true }
1434
+ this.load();
1435
+ }
1436
+
1437
+ load() {
1438
+ try {
1439
+ if (existsSync(CUSTOM_CATEGORIES_FILE)) {
1440
+ this.categories = JSON.parse(readFileSync(CUSTOM_CATEGORIES_FILE, 'utf8'));
1441
+ }
1442
+ } catch (e) {
1443
+ log(`Failed to load custom categories: ${e.message}`);
1444
+ this.categories = [];
1445
+ }
1446
+ }
1447
+
1448
+ save() {
1449
+ try {
1450
+ const dir = resolve(expandHome('~/.vibeteam/data'));
1451
+ if (!existsSync(dir)) {
1452
+ mkdirSync(dir, { recursive: true });
1453
+ }
1454
+ writeFileSync(CUSTOM_CATEGORIES_FILE, JSON.stringify(this.categories, null, 2));
1455
+ } catch (e) {
1456
+ log(`Failed to save custom categories: ${e.message}`);
1457
+ }
1458
+ }
1459
+
1460
+ getAll() {
1461
+ return [...DEFAULT_CATEGORIES, ...this.categories];
1462
+ }
1463
+
1464
+ _slugify(label) {
1465
+ return label
1466
+ .toLowerCase()
1467
+ .replace(/[^a-z0-9]+/g, '_')
1468
+ .replace(/^_|_$/g, '');
1469
+ }
1470
+
1471
+ addCategory(category) {
1472
+ const { label, color, icon } = category;
1473
+ if (!label || !color || !icon) {
1474
+ throw new Error('label, color, and icon are required');
1475
+ }
1476
+ const slug = this._slugify(label);
1477
+ const suffix = Math.random().toString(36).substring(2, 6);
1478
+ const id = `${slug}_${suffix}`;
1479
+ const newCat = { id, label, color, icon, isCustom: true };
1480
+ this.categories.push(newCat);
1481
+ this.save();
1482
+ return newCat;
1483
+ }
1484
+
1485
+ updateCategory(id, updates) {
1486
+ // Can't update default categories
1487
+ if (DEFAULT_CATEGORIES.some(c => c.id === id)) {
1488
+ throw new Error('Cannot update default categories');
1489
+ }
1490
+ const cat = this.categories.find(c => c.id === id);
1491
+ if (!cat) {
1492
+ throw new Error('Category not found');
1493
+ }
1494
+ if (updates.label !== undefined) cat.label = updates.label;
1495
+ if (updates.color !== undefined) cat.color = updates.color;
1496
+ if (updates.icon !== undefined) cat.icon = updates.icon;
1497
+ this.save();
1498
+ return cat;
1499
+ }
1500
+
1501
+ deleteCategory(id) {
1502
+ if (DEFAULT_CATEGORIES.some(c => c.id === id)) {
1503
+ throw new Error('Cannot delete default categories');
1504
+ }
1505
+ const idx = this.categories.findIndex(c => c.id === id);
1506
+ if (idx === -1) {
1507
+ throw new Error('Category not found');
1508
+ }
1509
+ const removed = this.categories.splice(idx, 1)[0];
1510
+ this.save();
1511
+ return removed;
1512
+ }
1513
+
1514
+ isValidCategory(categoryId) {
1515
+ return this.getAll().some(c => c.id === categoryId);
1516
+ }
1517
+ }
1518
+
1415
1519
  class IdeationManager {
1416
1520
  constructor() {
1417
1521
  this.sessions = new Map(); // projectId -> session
@@ -1450,6 +1554,44 @@ class IdeationManager {
1450
1554
  }
1451
1555
  }
1452
1556
 
1557
+ loadAllSessions() {
1558
+ try {
1559
+ if (!existsSync(IDEATION_DIR)) return;
1560
+ const files = readdirSync(IDEATION_DIR).filter(f => f.endsWith('.json'));
1561
+ let loaded = 0;
1562
+ for (const file of files) {
1563
+ try {
1564
+ const filePath = join(IDEATION_DIR, file);
1565
+ const data = JSON.parse(readFileSync(filePath, 'utf8'));
1566
+ const projectId = file.replace(/\.json$/, '');
1567
+ this.sessions.set(projectId, data);
1568
+ loaded++;
1569
+ } catch (e) {
1570
+ log(`Failed to load ideation file ${file}: ${e.message}`);
1571
+ }
1572
+ }
1573
+ // Reset any sessions stuck in generating/streaming from a previous server run
1574
+ let reset = 0;
1575
+ for (const [pid, sess] of this.sessions) {
1576
+ if (sess.status === 'generating' || sess.status === 'streaming') {
1577
+ sess.status = 'error';
1578
+ sess.error = 'Generation was interrupted (server restart). Please retry.';
1579
+ sess.updatedAt = Date.now();
1580
+ this.save(pid);
1581
+ reset++;
1582
+ }
1583
+ }
1584
+ if (reset > 0) {
1585
+ log(`Reset ${reset} stale ideation session(s) from generating/streaming to error`);
1586
+ }
1587
+ if (loaded > 0) {
1588
+ log(`Loaded ${loaded} ideation session(s) from disk`);
1589
+ }
1590
+ } catch (e) {
1591
+ log(`Failed to load ideation sessions: ${e.message}`);
1592
+ }
1593
+ }
1594
+
1453
1595
  getSession(projectId) {
1454
1596
  if (!this.sessions.has(projectId)) {
1455
1597
  this.loadSession(projectId);
@@ -1504,10 +1646,7 @@ class IdeationManager {
1504
1646
  id: sessionId,
1505
1647
  projectId,
1506
1648
  ideas: [],
1507
- enabledCategories: enabledCategories || [
1508
- 'code_improvements', 'ui_ux', 'security', 'performance',
1509
- 'documentation', 'code_quality', 'new_features'
1510
- ],
1649
+ enabledCategories: enabledCategories || DEFAULT_CATEGORIES.map(c => c.id),
1511
1650
  status: 'generating',
1512
1651
  createdAt: Date.now(),
1513
1652
  updatedAt: Date.now(),
@@ -1527,15 +1666,63 @@ class IdeationManager {
1527
1666
  });
1528
1667
 
1529
1668
  try {
1530
- const categoryList = (session.enabledCategories || []).join(', ');
1669
+ const enabledCats = session.enabledCategories || DEFAULT_CATEGORIES.map(c => c.id);
1670
+ const allCategories = customCategoryManager.getAll();
1671
+ const enabledCategoryDetails = enabledCats.map(catId => {
1672
+ const cat = allCategories.find(c => c.id === catId);
1673
+ return cat ? `${catId} (${cat.label})` : catId;
1674
+ });
1675
+ const categoryList = enabledCats.join(', ');
1676
+ const categoryDetailList = enabledCategoryDetails.join(', ');
1677
+ // Try to read CLAUDE.md or README for project context
1678
+ let projectContext = '';
1679
+ try {
1680
+ const possibleDocs = ['CLAUDE.md', 'README.md', 'readme.md'];
1681
+ for (const docFile of possibleDocs) {
1682
+ const docPath = join(projectPath, docFile);
1683
+ if (existsSync(docPath)) {
1684
+ const content = readFileSync(docPath, 'utf8').slice(0, 3000);
1685
+ projectContext = `\n\nProject documentation (${docFile}):\n${content}\n`;
1686
+ break;
1687
+ }
1688
+ }
1689
+ } catch (e) {
1690
+ // ignore
1691
+ }
1692
+
1693
+ // Detect project type from common markers
1694
+ let projectTypeHint = '';
1695
+ try {
1696
+ const entries = readdirSync(projectPath);
1697
+ if (entries.includes('Assets') && entries.includes('ProjectSettings')) {
1698
+ projectTypeHint = '\nThis is a Unity project. Focus ONLY on files in Assets/ (especially Scripts/). IGNORE: Library/, Logs/, Temp/, Packages/, obj/, .vs/, *.meta files, and any auto-generated Unity files.';
1699
+ } else if (entries.includes('Cargo.toml')) {
1700
+ projectTypeHint = '\nThis is a Rust project. Focus on src/ directory.';
1701
+ } else if (entries.includes('go.mod')) {
1702
+ projectTypeHint = '\nThis is a Go project. Focus on .go files.';
1703
+ } else if (entries.includes('Podfile') || entries.some(e => e.endsWith('.xcodeproj'))) {
1704
+ projectTypeHint = '\nThis is an iOS/macOS project. Focus on source code, not Pods/ or build artifacts.';
1705
+ } else if (entries.includes('build.gradle') || entries.includes('build.gradle.kts')) {
1706
+ projectTypeHint = '\nThis is a Java/Kotlin/Android project. Focus on src/ directories, not build/ or .gradle/.';
1707
+ }
1708
+ } catch (e) {
1709
+ // ignore
1710
+ }
1711
+
1531
1712
  const prompt = `You are an expert code analyst. Analyze this project's codebase and generate improvement ideas.
1713
+ ${projectContext}${projectTypeHint}
1714
+ Categories to analyze: ${categoryDetailList}
1532
1715
 
1533
- Categories to analyze: ${categoryList}
1716
+ IMPORTANT INSTRUCTIONS:
1717
+ 1. First, if there is a CLAUDE.md file, read it to understand the project structure and conventions.
1718
+ 2. Explore ONLY the project's source code directories — skip libraries, dependencies, build outputs, and auto-generated files.
1719
+ 3. Read a few key source files to understand patterns and quality.
1720
+ 4. Then output your analysis.
1534
1721
 
1535
1722
  You MUST output your response as a valid JSON array of idea objects. No other text, no markdown, no code blocks. ONLY a JSON array.
1536
1723
 
1537
1724
  Each idea object must have these fields:
1538
- - "category": one of: code_improvements, ui_ux, security, performance, documentation, code_quality, new_features
1725
+ - "category": one of: ${categoryList}
1539
1726
  - "title": short descriptive title (under 80 chars)
1540
1727
  - "description": 1-3 sentence description of the specific improvement
1541
1728
  - "effort": one of: trivial, small, medium, large, complex
@@ -1543,12 +1730,10 @@ Each idea object must have these fields:
1543
1730
 
1544
1731
  Rules:
1545
1732
  - Generate 15-30 ideas total across the requested categories
1546
- - Only generate ideas for the categories listed above
1733
+ - Only generate ideas for the categories listed above, using the EXACT category IDs provided
1547
1734
  - Focus on actionable, specific improvements (reference real files/functions when possible)
1548
1735
  - effort: trivial (<1hr), small (1-4hr), medium (1-2 days), large (3-5 days), complex (1+ weeks)
1549
- - impact: low (nice to have), medium (noticeable improvement), high (significant value), critical (essential/blocking)
1550
-
1551
- Start by exploring the project structure and reading key files, then output the JSON array.`;
1736
+ - impact: low (nice to have), medium (noticeable improvement), high (significant value), critical (essential/blocking)`;
1552
1737
 
1553
1738
  // Use claude CLI in --print mode for reliable output capture
1554
1739
  // spawn() with array args prevents shell injection
@@ -1558,6 +1743,7 @@ Start by exploring the project structure and reading key files, then output the
1558
1743
  '--output-format', 'text',
1559
1744
  '--dangerously-skip-permissions',
1560
1745
  '--model', 'sonnet',
1746
+ '--max-turns', '5',
1561
1747
  prompt,
1562
1748
  ];
1563
1749
 
@@ -1587,10 +1773,12 @@ Start by exploring the project structure and reading key files, then output the
1587
1773
  });
1588
1774
 
1589
1775
  // Track this generation with 5-minute timeout
1776
+ let timedOut = false;
1590
1777
  const generationTimeout = setTimeout(() => {
1591
1778
  log(`Ideation generation timed out for project ${projectId}`);
1779
+ timedOut = true;
1592
1780
  child.kill('SIGTERM');
1593
- this._finishGeneration(projectId, 'error', 'Generation timed out (5 min)');
1781
+ // Don't call _finishGeneration here - let the close handler do it
1594
1782
  }, 5 * 60 * 1000);
1595
1783
 
1596
1784
  this.activeGenerations.set(projectId, {
@@ -1611,15 +1799,49 @@ Start by exploring the project structure and reading key files, then output the
1611
1799
  });
1612
1800
 
1613
1801
  child.on('close', (code) => {
1614
- log(`Ideation: claude --print exited with code ${code} for project ${projectId}`);
1802
+ log(`Ideation: claude --print exited with code ${code} for project ${projectId} (timedOut=${timedOut})`);
1803
+
1804
+ if (timedOut) {
1805
+ // If we got partial output before timeout, try to parse it
1806
+ if (stdout.trim()) {
1807
+ this._parseAndAddIdeas(projectId, stdout);
1808
+ const session = this.sessions.get(projectId);
1809
+ const ideaCount = session?.ideas?.length || 0;
1810
+ if (ideaCount > 0) {
1811
+ this._finishGeneration(projectId, 'done');
1812
+ return;
1813
+ }
1814
+ }
1815
+ this._finishGeneration(projectId, 'error', 'Generation timed out — the project may be too large. Try selecting fewer categories.');
1816
+ return;
1817
+ }
1615
1818
 
1616
- if (code !== 0 && !stdout.trim()) {
1617
- this._finishGeneration(projectId, 'error', `Claude exited with code ${code}: ${stderr.slice(0, 200)}`);
1819
+ if (code !== 0) {
1820
+ // Non-zero exit: try to salvage ideas from partial stdout
1821
+ if (stdout.trim()) {
1822
+ this._parseAndAddIdeas(projectId, stdout);
1823
+ const session = this.sessions.get(projectId);
1824
+ const ideaCount = session?.ideas?.length || 0;
1825
+ if (ideaCount > 0) {
1826
+ log(`Ideation: Claude exited with code ${code} but salvaged ${ideaCount} ideas`);
1827
+ this._finishGeneration(projectId, 'done');
1828
+ return;
1829
+ }
1830
+ }
1831
+ const hint = code === 143 ? ' (process was killed — may need more time or memory)' : '';
1832
+ this._finishGeneration(projectId, 'error', `Claude exited with code ${code}${hint}: ${stderr.slice(0, 200)}`);
1618
1833
  return;
1619
1834
  }
1620
1835
 
1621
1836
  // Final parse of complete output
1622
1837
  this._parseAndAddIdeas(projectId, stdout);
1838
+ const session = this.sessions.get(projectId);
1839
+ const ideaCount = session?.ideas?.length || 0;
1840
+ if (ideaCount === 0) {
1841
+ log(`Ideation: Claude returned no parseable ideas. stdout length=${stdout.length}, stderr=${stderr.slice(0, 300)}`);
1842
+ this._finishGeneration(projectId, 'error', 'Claude did not return any ideas. This may happen if the AI response was not in the expected format. Try again.');
1843
+ return;
1844
+ }
1623
1845
  this._finishGeneration(projectId, 'done');
1624
1846
  });
1625
1847
 
@@ -1713,9 +1935,13 @@ Start by exploring the project structure and reading key files, then output the
1713
1935
  return;
1714
1936
  }
1715
1937
 
1716
- const validCategories = ['code_improvements', 'ui_ux', 'security', 'performance', 'documentation', 'code_quality', 'new_features'];
1717
- if (!validCategories.includes(ideaData.category)) {
1718
- return;
1938
+ // Validate against all categories (default + custom)
1939
+ if (!customCategoryManager.isValidCategory(ideaData.category)) {
1940
+ // Also accept if category matches an enabled category in the session (in case custom was added during generation)
1941
+ const session = this.sessions.get(projectId);
1942
+ if (!session?.enabledCategories?.includes(ideaData.category)) {
1943
+ return;
1944
+ }
1719
1945
  }
1720
1946
 
1721
1947
  const idea = {
@@ -1777,6 +2003,7 @@ Start by exploring the project structure and reading key files, then output the
1777
2003
  }
1778
2004
 
1779
2005
  const ideationManager = new IdeationManager();
2006
+ const customCategoryManager = new CustomCategoryManager();
1780
2007
 
1781
2008
  // ============================================================================
1782
2009
  // Planning Pipeline Manager
@@ -1978,8 +2205,15 @@ class PlanningPipelineManager {
1978
2205
  async createPipeline(taskId, projectId, projectPath, title, description, attachments) {
1979
2206
  // Check for existing pipeline for this task
1980
2207
  for (const p of this.pipelines.values()) {
1981
- if (p.taskId === taskId && p.status === 'running') {
1982
- throw new Error(`Task already has an active pipeline`);
2208
+ if (p.taskId === taskId) {
2209
+ if (p.status === 'running') {
2210
+ throw new Error(`Task already has an active pipeline`);
2211
+ }
2212
+ // Remove old completed/failed/cancelled pipeline to allow re-execution
2213
+ log(`Pipeline ${taskId.slice(0, 8)}: Removing old pipeline (status: ${p.status}) for re-execution`);
2214
+ this.pipelines.delete(p.id);
2215
+ this.save();
2216
+ break;
1983
2217
  }
1984
2218
  }
1985
2219
 
@@ -3505,11 +3739,20 @@ function pollTerminalForClient(ws, sessionId, tmuxSession, forceUpdate = false)
3505
3739
  lastTerminalOutput.set(sessionId, stdout);
3506
3740
 
3507
3741
  if (ws.readyState === WebSocket.OPEN) {
3742
+ // Compute delta: append if new output starts with old, otherwise full
3743
+ let delta;
3744
+ if (!forceUpdate && lastOutput && stdout.startsWith(lastOutput)) {
3745
+ delta = { type: 'append', data: stdout.slice(lastOutput.length) };
3746
+ } else {
3747
+ delta = { type: 'full', data: stdout };
3748
+ }
3749
+
3508
3750
  ws.send(JSON.stringify({
3509
3751
  type: 'terminal_output',
3510
3752
  payload: {
3511
3753
  sessionId,
3512
- data: stdout
3754
+ version: 2,
3755
+ delta
3513
3756
  }
3514
3757
  }));
3515
3758
  }
@@ -3557,11 +3800,20 @@ function pollAllTerminals(onEachComplete, terminalSnapshot) {
3557
3800
  lastTerminalOutput.set(sessionId, stdout);
3558
3801
 
3559
3802
  if (ws.readyState === WebSocket.OPEN) {
3803
+ // Compute delta: append if new output starts with old, otherwise full
3804
+ let delta;
3805
+ if (lastOutput && stdout.startsWith(lastOutput)) {
3806
+ delta = { type: 'append', data: stdout.slice(lastOutput.length) };
3807
+ } else {
3808
+ delta = { type: 'full', data: stdout };
3809
+ }
3810
+
3560
3811
  ws.send(JSON.stringify({
3561
3812
  type: 'terminal_output',
3562
3813
  payload: {
3563
3814
  sessionId,
3564
- data: stdout
3815
+ version: 2,
3816
+ delta
3565
3817
  }
3566
3818
  }));
3567
3819
  }
@@ -4911,6 +5163,72 @@ function watchEventsFile() {
4911
5163
  });
4912
5164
  log(`Watching events file: ${EVENTS_FILE}`);
4913
5165
  }
5166
+ // ============================================================================
5167
+ // Relay Connection (Mobile Remote Control)
5168
+ // ============================================================================
5169
+ let relayWs = null;
5170
+ let relaySessionToken = null;
5171
+ let relayConnected = false;
5172
+
5173
+ // Send message to relay (wrapping in envelope)
5174
+ function sendToRelay(data) {
5175
+ if (relayWs && relayWs.readyState === 1) {
5176
+ try {
5177
+ relayWs.send(JSON.stringify({
5178
+ type: 'relay_message',
5179
+ from: 'host',
5180
+ payload: data,
5181
+ timestamp: Date.now(),
5182
+ seq: 0,
5183
+ }));
5184
+ } catch (e) {
5185
+ console.error('[relay] Failed to send:', e);
5186
+ }
5187
+ }
5188
+ }
5189
+
5190
+ // Handle REST-over-relay requests from mobile
5191
+ async function handleRelayRequest(request, ws) {
5192
+ const { method, path, body } = request;
5193
+ if (!method || !path) return;
5194
+
5195
+ try {
5196
+ // Route the request internally
5197
+ const url = `http://localhost:${PORT}${path}`;
5198
+ const options = {
5199
+ method,
5200
+ headers: { 'Content-Type': 'application/json' },
5201
+ };
5202
+ if (body && method !== 'GET') {
5203
+ options.body = JSON.stringify(body);
5204
+ }
5205
+
5206
+ const response = await fetch(url, options);
5207
+ const responseBody = await response.json().catch(() => null);
5208
+
5209
+ // Send response back through relay
5210
+ if (ws.readyState === 1) {
5211
+ ws.send(JSON.stringify({
5212
+ type: 'relay_message',
5213
+ from: 'host',
5214
+ payload: { status: response.status, body: responseBody },
5215
+ timestamp: Date.now(),
5216
+ seq: 0,
5217
+ }));
5218
+ }
5219
+ } catch (err) {
5220
+ if (ws.readyState === 1) {
5221
+ ws.send(JSON.stringify({
5222
+ type: 'relay_message',
5223
+ from: 'host',
5224
+ payload: { status: 500, body: { error: err.message } },
5225
+ timestamp: Date.now(),
5226
+ seq: 0,
5227
+ }));
5228
+ }
5229
+ }
5230
+ }
5231
+
4914
5232
  // ============================================================================
4915
5233
  // WebSocket
4916
5234
  // ============================================================================
@@ -4921,6 +5239,8 @@ function broadcast(message) {
4921
5239
  client.send(data);
4922
5240
  }
4923
5241
  }
5242
+ // Forward to relay for mobile clients
5243
+ sendToRelay(message);
4924
5244
  }
4925
5245
  function handleClientMessage(ws, message) {
4926
5246
  switch (message.type) {
@@ -5997,6 +6317,25 @@ async function handleHttpRequest(req, res) {
5997
6317
  res.end(JSON.stringify({ ok: false, error: 'Task not found' }));
5998
6318
  return;
5999
6319
  }
6320
+ // When moving back to ideas, reset pipeline fields on the task and remove old pipeline
6321
+ if (toColumn === 'ideas') {
6322
+ const existingPipeline = pipelineManager.pipelines.get(taskId);
6323
+ if (existingPipeline && existingPipeline.status !== 'running') {
6324
+ log(`Task ${taskId.slice(0, 8)} moved to ideas: clearing old pipeline (status: ${existingPipeline.status})`);
6325
+ pipelineManager.pipelines.delete(taskId);
6326
+ pipelineManager.save();
6327
+ broadcast({ type: 'pipeline_removed', payload: { pipelineId: taskId } });
6328
+ // Reset pipeline fields on the task
6329
+ kanbanManager.updateTask(taskId, {
6330
+ pipelineId: undefined,
6331
+ pipelinePhase: null,
6332
+ pipelineStatus: undefined,
6333
+ pipelineLog: undefined,
6334
+ reviewFeedback: undefined,
6335
+ reviewStatus: undefined,
6336
+ });
6337
+ }
6338
+ }
6000
6339
  broadcast({ type: 'kanban_task_moved', payload: { taskId, toColumn, position: task.position, task } });
6001
6340
  res.writeHead(200, { 'Content-Type': 'application/json' });
6002
6341
  res.end(JSON.stringify({ ok: true, task }));
@@ -7164,6 +7503,193 @@ async function handleHttpRequest(req, res) {
7164
7503
  // Ideation Endpoints
7165
7504
  // ============================================================================
7166
7505
 
7506
+ // GET /ideation/categories - Get all categories (default + custom)
7507
+ if (req.method === 'GET' && req.url === '/ideation/categories') {
7508
+ res.writeHead(200, { 'Content-Type': 'application/json' });
7509
+ res.end(JSON.stringify({ ok: true, categories: customCategoryManager.getAll() }));
7510
+ return;
7511
+ }
7512
+
7513
+ // POST /ideation/categories - Create a custom category
7514
+ if (req.method === 'POST' && req.url === '/ideation/categories') {
7515
+ collectRequestBody(req).then((rawBody) => {
7516
+ try {
7517
+ const body = JSON.parse(rawBody);
7518
+ const { label, color, icon } = body;
7519
+ if (!label || !color || !icon) {
7520
+ res.writeHead(400, { 'Content-Type': 'application/json' });
7521
+ res.end(JSON.stringify({ ok: false, error: 'label, color, and icon are required' }));
7522
+ return;
7523
+ }
7524
+ const category = customCategoryManager.addCategory({ label, color, icon });
7525
+ broadcast({ type: 'ideation_categories', payload: { categories: customCategoryManager.getAll() } });
7526
+ res.writeHead(201, { 'Content-Type': 'application/json' });
7527
+ res.end(JSON.stringify({ ok: true, category }));
7528
+ } catch (e) {
7529
+ res.writeHead(500, { 'Content-Type': 'application/json' });
7530
+ res.end(JSON.stringify({ ok: false, error: e.message }));
7531
+ }
7532
+ }).catch(() => {
7533
+ res.writeHead(413, { 'Content-Type': 'application/json' });
7534
+ res.end(JSON.stringify({ error: 'Request body too large' }));
7535
+ });
7536
+ return;
7537
+ }
7538
+
7539
+ // POST /ideation/categories/suggest - AI-powered category suggestions
7540
+ if (req.method === 'POST' && req.url === '/ideation/categories/suggest') {
7541
+ collectRequestBody(req).then(async (rawBody) => {
7542
+ try {
7543
+ const body = JSON.parse(rawBody);
7544
+ const { projectId, projectPath } = body;
7545
+ if (!projectId || !projectPath) {
7546
+ res.writeHead(400, { 'Content-Type': 'application/json' });
7547
+ res.end(JSON.stringify({ ok: false, error: 'projectId and projectPath are required' }));
7548
+ return;
7549
+ }
7550
+
7551
+ const existingLabels = customCategoryManager.getAll().map(c => c.label).join(', ');
7552
+ const suggestPrompt = `You are a project analyst. Analyze this project and suggest 5-8 domain-specific ideation categories that would be useful for generating improvement ideas.
7553
+
7554
+ The project is located at: ${projectPath}
7555
+
7556
+ Existing categories (do NOT suggest duplicates of these): ${existingLabels}
7557
+
7558
+ You MUST output your response as a valid JSON array. No other text, no markdown, no code blocks. ONLY a JSON array.
7559
+
7560
+ Each category object must have:
7561
+ - "label": descriptive name for the category (2-4 words, Title Case)
7562
+ - "color": hex color string (e.g., "#ff6b6b") - pick visually distinct colors
7563
+ - "icon": a single emoji that represents the category
7564
+
7565
+ Consider what kind of project this is (web app, API, game, mobile app, CLI tool, library, etc.) and suggest categories specific to its domain. For example:
7566
+ - A game project might get: Game Mechanics, Level Design, Player Experience
7567
+ - A web app might get: Accessibility, SEO, State Management
7568
+ - An API might get: Rate Limiting, Error Handling, API Design
7569
+
7570
+ Explore the project structure and key files to understand what it does, then output the JSON array.`;
7571
+
7572
+ const claudePath = 'claude';
7573
+ const args = [
7574
+ '--print',
7575
+ '--output-format', 'text',
7576
+ '--dangerously-skip-permissions',
7577
+ '--model', 'sonnet',
7578
+ suggestPrompt,
7579
+ ];
7580
+
7581
+ const childEnv = { ...process.env, CLAUDE_CODE_ENTRYPOINT: 'vibeteam-category-suggest' };
7582
+ delete childEnv.CLAUDECODE;
7583
+
7584
+ const child = spawn(claudePath, args, {
7585
+ cwd: projectPath,
7586
+ env: childEnv,
7587
+ stdio: ['ignore', 'pipe', 'pipe'],
7588
+ });
7589
+
7590
+ let stdout = '';
7591
+ let stderr = '';
7592
+
7593
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
7594
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
7595
+
7596
+ const timeout = setTimeout(() => {
7597
+ child.kill('SIGTERM');
7598
+ }, 3 * 60 * 1000);
7599
+
7600
+ child.on('close', (code) => {
7601
+ clearTimeout(timeout);
7602
+ if (code !== 0 && !stdout.trim()) {
7603
+ res.writeHead(500, { 'Content-Type': 'application/json' });
7604
+ res.end(JSON.stringify({ ok: false, error: `Claude exited with code ${code}: ${stderr.slice(0, 200)}` }));
7605
+ return;
7606
+ }
7607
+
7608
+ try {
7609
+ let output = stdout.trim();
7610
+ output = output.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/i, '');
7611
+ const arrayStart = output.indexOf('[');
7612
+ const arrayEnd = output.lastIndexOf(']');
7613
+ if (arrayStart !== -1 && arrayEnd !== -1 && arrayEnd > arrayStart) {
7614
+ output = output.substring(arrayStart, arrayEnd + 1);
7615
+ }
7616
+ const suggestions = JSON.parse(output);
7617
+ if (!Array.isArray(suggestions)) {
7618
+ throw new Error('Output is not an array');
7619
+ }
7620
+ // Validate and clean suggestions
7621
+ const cleaned = suggestions
7622
+ .filter(s => s.label && s.color && s.icon)
7623
+ .map(s => ({ label: s.label, color: s.color, icon: s.icon }));
7624
+ res.writeHead(200, { 'Content-Type': 'application/json' });
7625
+ // Add temporary IDs so frontend can track them
7626
+ const withIds = cleaned.map(s => ({
7627
+ ...s,
7628
+ id: `suggestion_${Math.random().toString(36).substring(2, 8)}`,
7629
+ isCustom: true,
7630
+ }));
7631
+ res.end(JSON.stringify({ ok: true, categories: withIds }));
7632
+ } catch (e) {
7633
+ log(`Category suggest: Failed to parse output: ${e.message}`);
7634
+ res.writeHead(500, { 'Content-Type': 'application/json' });
7635
+ res.end(JSON.stringify({ ok: false, error: 'Failed to parse AI suggestions' }));
7636
+ }
7637
+ });
7638
+
7639
+ child.on('error', (err) => {
7640
+ clearTimeout(timeout);
7641
+ res.writeHead(500, { 'Content-Type': 'application/json' });
7642
+ res.end(JSON.stringify({ ok: false, error: err.message }));
7643
+ });
7644
+ } catch (e) {
7645
+ res.writeHead(500, { 'Content-Type': 'application/json' });
7646
+ res.end(JSON.stringify({ ok: false, error: e.message }));
7647
+ }
7648
+ }).catch(() => {
7649
+ res.writeHead(413, { 'Content-Type': 'application/json' });
7650
+ res.end(JSON.stringify({ error: 'Request body too large' }));
7651
+ });
7652
+ return;
7653
+ }
7654
+
7655
+ // PUT /ideation/categories/:id - Update a custom category
7656
+ if (req.method === 'PUT' && req.url?.match(/^\/ideation\/categories\/[^/]+$/)) {
7657
+ const id = req.url.split('/ideation/categories/')[1]?.split('?')[0];
7658
+ collectRequestBody(req).then((rawBody) => {
7659
+ try {
7660
+ const body = JSON.parse(rawBody);
7661
+ const category = customCategoryManager.updateCategory(id, body);
7662
+ broadcast({ type: 'ideation_categories', payload: { categories: customCategoryManager.getAll() } });
7663
+ res.writeHead(200, { 'Content-Type': 'application/json' });
7664
+ res.end(JSON.stringify({ ok: true, category }));
7665
+ } catch (e) {
7666
+ const status = e.message.includes('not found') ? 404 : e.message.includes('Cannot update') ? 403 : 500;
7667
+ res.writeHead(status, { 'Content-Type': 'application/json' });
7668
+ res.end(JSON.stringify({ ok: false, error: e.message }));
7669
+ }
7670
+ }).catch(() => {
7671
+ res.writeHead(413, { 'Content-Type': 'application/json' });
7672
+ res.end(JSON.stringify({ error: 'Request body too large' }));
7673
+ });
7674
+ return;
7675
+ }
7676
+
7677
+ // DELETE /ideation/categories/:id - Delete a custom category
7678
+ if (req.method === 'DELETE' && req.url?.match(/^\/ideation\/categories\/[^/]+$/)) {
7679
+ const id = req.url.split('/ideation/categories/')[1]?.split('?')[0];
7680
+ try {
7681
+ customCategoryManager.deleteCategory(id);
7682
+ broadcast({ type: 'ideation_categories', payload: { categories: customCategoryManager.getAll() } });
7683
+ res.writeHead(200, { 'Content-Type': 'application/json' });
7684
+ res.end(JSON.stringify({ ok: true }));
7685
+ } catch (e) {
7686
+ const status = e.message.includes('not found') ? 404 : e.message.includes('Cannot delete') ? 403 : 500;
7687
+ res.writeHead(status, { 'Content-Type': 'application/json' });
7688
+ res.end(JSON.stringify({ ok: false, error: e.message }));
7689
+ }
7690
+ return;
7691
+ }
7692
+
7167
7693
  // POST /ideation/generate - Generate ideas for a project
7168
7694
  if (req.method === 'POST' && req.url === '/ideation/generate') {
7169
7695
  collectRequestBody(req).then(async (rawBody) => {
@@ -7303,10 +7829,7 @@ async function handleHttpRequest(req, res) {
7303
7829
  id: sessionId,
7304
7830
  projectId,
7305
7831
  ideas: [],
7306
- enabledCategories: [
7307
- 'code_improvements', 'ui_ux', 'security', 'performance',
7308
- 'documentation', 'code_quality', 'new_features'
7309
- ],
7832
+ enabledCategories: DEFAULT_CATEGORIES.map(c => c.id),
7310
7833
  status: 'done',
7311
7834
  createdAt: Date.now(),
7312
7835
  updatedAt: Date.now(),
@@ -7496,6 +8019,139 @@ async function handleHttpRequest(req, res) {
7496
8019
  }
7497
8020
 
7498
8021
  // API Documentation
8022
+ // ========================================================================
8023
+ // Relay connection management (Mobile Remote Control)
8024
+ // ========================================================================
8025
+ if (req.method === 'POST' && req.url === '/relay/connect') {
8026
+ collectRequestBody(req).then(body => {
8027
+ try {
8028
+ const { code, relayUrl = 'wss://vibeteam-relay.fly.dev/connect' } = JSON.parse(body) || {};
8029
+
8030
+ if (!code) {
8031
+ res.writeHead(400, { 'Content-Type': 'application/json' });
8032
+ res.end(JSON.stringify({ error: 'Pairing code required' }));
8033
+ return;
8034
+ }
8035
+
8036
+ // Close existing relay connection
8037
+ if (relayWs) {
8038
+ try { relayWs.close(); } catch {}
8039
+ relayWs = null;
8040
+ }
8041
+
8042
+ try {
8043
+ const url = `${relayUrl}?code=${encodeURIComponent(code)}&role=host`;
8044
+ const ws = new WebSocket(url);
8045
+
8046
+ ws.on('open', () => {
8047
+ console.log('[relay] Connected to relay server');
8048
+ relayConnected = true;
8049
+ broadcast({ type: 'relay_status', payload: { connected: true } });
8050
+ });
8051
+
8052
+ ws.on('message', (data) => {
8053
+ try {
8054
+ const msg = JSON.parse(data.toString());
8055
+
8056
+ // Handle relay control messages
8057
+ if (msg.type === 'paired') {
8058
+ relaySessionToken = msg.payload?.sessionToken;
8059
+ console.log('[relay] Paired with mobile device');
8060
+ broadcast({ type: 'relay_status', payload: { connected: true, paired: true } });
8061
+
8062
+ // Send current state to mobile via relay
8063
+ const sessionsData = getSessions();
8064
+ sendToRelay({ type: 'sessions', payload: sessionsData });
8065
+
8066
+ // Send projects
8067
+ sendToRelay({ type: 'projects', payload: projectsManager.getProjects() });
8068
+ return;
8069
+ }
8070
+
8071
+ if (msg.type === 'waiting') {
8072
+ console.log('[relay] Waiting for mobile device to connect...');
8073
+ if (msg.payload?.sessionToken) {
8074
+ relaySessionToken = msg.payload.sessionToken;
8075
+ }
8076
+ broadcast({ type: 'relay_status', payload: { connected: true, paired: false, waiting: true } });
8077
+ return;
8078
+ }
8079
+
8080
+ if (msg.type === 'ping') {
8081
+ if (ws.readyState === 1) {
8082
+ ws.send(JSON.stringify({ type: 'pong', from: 'host', timestamp: Date.now(), seq: 0 }));
8083
+ }
8084
+ return;
8085
+ }
8086
+
8087
+ if (msg.type === 'mobile_disconnected') {
8088
+ console.log('[relay] Mobile device disconnected');
8089
+ broadcast({ type: 'relay_status', payload: { connected: true, paired: false } });
8090
+ return;
8091
+ }
8092
+
8093
+ // Handle relay_message from mobile (REST-over-relay)
8094
+ if (msg.type === 'relay_message' && msg.payload) {
8095
+ handleRelayRequest(msg.payload, ws);
8096
+ return;
8097
+ }
8098
+ } catch (e) {
8099
+ console.error('[relay] Error processing message:', e);
8100
+ }
8101
+ });
8102
+
8103
+ ws.on('close', () => {
8104
+ console.log('[relay] Disconnected from relay server');
8105
+ relayWs = null;
8106
+ relayConnected = false;
8107
+ relaySessionToken = null;
8108
+ broadcast({ type: 'relay_status', payload: { connected: false } });
8109
+ });
8110
+
8111
+ ws.on('error', (err) => {
8112
+ console.error('[relay] WebSocket error:', err.message);
8113
+ });
8114
+
8115
+ relayWs = ws;
8116
+ res.writeHead(200, { 'Content-Type': 'application/json' });
8117
+ res.end(JSON.stringify({ success: true, message: 'Connecting to relay...' }));
8118
+ } catch (err) {
8119
+ res.writeHead(500, { 'Content-Type': 'application/json' });
8120
+ res.end(JSON.stringify({ error: 'Failed to connect to relay', details: err.message }));
8121
+ }
8122
+ } catch (e) {
8123
+ res.writeHead(400, { 'Content-Type': 'application/json' });
8124
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
8125
+ }
8126
+ }).catch(() => {
8127
+ res.writeHead(413, { 'Content-Type': 'application/json' });
8128
+ res.end(JSON.stringify({ error: 'Request body too large' }));
8129
+ });
8130
+ return;
8131
+ }
8132
+
8133
+ if (req.method === 'GET' && req.url === '/relay/status') {
8134
+ res.writeHead(200, { 'Content-Type': 'application/json' });
8135
+ res.end(JSON.stringify({
8136
+ connected: relayConnected,
8137
+ sessionToken: relaySessionToken ? '***' : null,
8138
+ }));
8139
+ return;
8140
+ }
8141
+
8142
+ if (req.method === 'POST' && req.url === '/relay/disconnect') {
8143
+ if (relayWs) {
8144
+ try { relayWs.close(); } catch {}
8145
+ relayWs = null;
8146
+ }
8147
+ relayConnected = false;
8148
+ relaySessionToken = null;
8149
+ broadcast({ type: 'relay_status', payload: { connected: false } });
8150
+ res.writeHead(200, { 'Content-Type': 'application/json' });
8151
+ res.end(JSON.stringify({ success: true }));
8152
+ return;
8153
+ }
8154
+
7499
8155
  if (req.method === 'GET' && req.url === '/api/docs/openapi.json') {
7500
8156
  res.writeHead(200, { 'Content-Type': 'application/json' });
7501
8157
  res.end(JSON.stringify(openapiSpec));
@@ -7642,6 +8298,8 @@ function main() {
7642
8298
  loadSessions();
7643
8299
  // Load saved text tiles
7644
8300
  loadTiles();
8301
+ // Load saved ideation sessions
8302
+ ideationManager.loadAllSessions();
7645
8303
  // Start git status tracking with adaptive polling
7646
8304
  gitStatusManager.setUpdateHandler(({ sessionId, status }) => {
7647
8305
  const session = managedSessions.get(sessionId);
@@ -7707,6 +8365,8 @@ function main() {
7707
8365
  payload: { tasks: kanbanManager.getTasks() },
7708
8366
  };
7709
8367
  ws.send(JSON.stringify(kanbanMsg));
8368
+ // Send ideation categories (default + custom)
8369
+ ws.send(JSON.stringify({ type: 'ideation_categories', payload: { categories: customCategoryManager.getAll() } }));
7710
8370
  // Send ideation sessions for all projects
7711
8371
  for (const [, session] of ideationManager.sessions) {
7712
8372
  ws.send(JSON.stringify({ type: 'ideation_session', payload: { session } }));