claudeboard 1.3.0 → 1.5.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.
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  const MODEL = "claude-sonnet-4-20250514";
7
- const MAX_TOKENS = 8096;
7
+ const MAX_TOKENS = 16000; // Increased — large codegen responses need more tokens
8
8
 
9
9
  function getHeaders() {
10
10
  const key = process.env.ANTHROPIC_API_KEY;
@@ -59,18 +59,35 @@ export async function callClaude(systemPrompt, userMessage, options = {}) {
59
59
 
60
60
  /**
61
61
  * Call Claude API expecting JSON response
62
- * Returns parsed object or throws
62
+ * Robust: tries 3 extraction strategies + auto-repair for truncated responses
63
63
  */
64
64
  export async function callClaudeJSON(systemPrompt, userMessage, options = {}) {
65
65
  const sys = systemPrompt + "\n\nYou MUST respond with valid JSON only. No markdown, no explanation, no backticks. Pure JSON.";
66
- const { text } = await callClaude(sys, userMessage, options);
66
+ const { text } = await callClaude(sys, userMessage, { ...options, maxTokens: options.maxTokens || MAX_TOKENS });
67
67
 
68
+ // Try 1: direct parse after stripping backticks
68
69
  try {
69
- const clean = text.replace(/```json|```/g, "").trim();
70
+ const clean = text.replace(/```json\n?|```/g, "").trim();
70
71
  return JSON.parse(clean);
71
- } catch (err) {
72
- throw new Error(`Failed to parse JSON from Claude: ${text.slice(0, 200)}`);
73
- }
72
+ } catch {}
73
+
74
+ // Try 2: extract first { } or [ ] block
75
+ try {
76
+ const match = text.match(/(\{[\s\S]*\}|\[[\s\S]*\])/);
77
+ if (match) return JSON.parse(match[1]);
78
+ } catch {}
79
+
80
+ // Try 3: ask Claude to repair truncated/broken JSON
81
+ try {
82
+ const repair = await callClaude(
83
+ "You are a JSON repair tool. Fix this broken or truncated JSON. Return ONLY valid JSON, nothing else, no backticks.",
84
+ `Broken JSON:\n${text.slice(0, 8000)}`
85
+ );
86
+ const clean = repair.text.replace(/```json\n?|```/g, "").trim();
87
+ return JSON.parse(clean);
88
+ } catch {}
89
+
90
+ throw new Error(`Failed to parse JSON after 3 attempts. Preview: ${text.slice(0, 300)}`);
74
91
  }
75
92
 
76
93
  /**
package/bin/cli.js CHANGED
@@ -62,6 +62,7 @@ program
62
62
  supabaseUrl: answers.supabaseUrl,
63
63
  supabaseKey: answers.supabaseKey,
64
64
  anthropicKey: answers.anthropicKey,
65
+ projectDir: process.cwd(),
65
66
  createdAt: new Date().toISOString(),
66
67
  };
67
68
 
@@ -106,6 +107,7 @@ program
106
107
  ANTHROPIC_API_KEY: config.anthropicKey || process.env.ANTHROPIC_API_KEY || "",
107
108
  PORT: String(port),
108
109
  PROJECT_NAME: config.projectName,
110
+ PROJECT_DIR: config.projectDir || process.cwd(),
109
111
  },
110
112
  stdio: "pipe",
111
113
  });
@@ -640,6 +640,188 @@
640
640
  }
641
641
  .btn-create:hover { opacity: 0.85; }
642
642
 
643
+ /* ── EXPO PANEL ── */
644
+ .expo-panel {
645
+ position: fixed;
646
+ bottom: 0; left: 0; right: 320px;
647
+ background: var(--bg);
648
+ border-top: 1px solid var(--border);
649
+ z-index: 20;
650
+ transition: transform 0.25s ease;
651
+ transform: translateY(100%);
652
+ }
653
+ .expo-panel.open { transform: translateY(0); }
654
+
655
+ .expo-panel-header {
656
+ display: flex;
657
+ align-items: center;
658
+ gap: 10px;
659
+ padding: 10px 16px;
660
+ border-bottom: 1px solid var(--border);
661
+ cursor: pointer;
662
+ user-select: none;
663
+ }
664
+
665
+ .expo-panel-title {
666
+ font-family: var(--mono);
667
+ font-size: 12px;
668
+ font-weight: 600;
669
+ color: var(--text);
670
+ }
671
+
672
+ .expo-logs {
673
+ height: 160px;
674
+ overflow-y: auto;
675
+ padding: 10px 16px;
676
+ font-family: var(--mono);
677
+ font-size: 11px;
678
+ color: var(--muted);
679
+ line-height: 1.6;
680
+ }
681
+
682
+ .expo-logs::-webkit-scrollbar { width: 3px; }
683
+ .expo-logs::-webkit-scrollbar-thumb { background: var(--border); }
684
+
685
+ .expo-qr-wrap {
686
+ padding: 12px 16px;
687
+ display: flex;
688
+ align-items: center;
689
+ gap: 16px;
690
+ border-top: 1px solid var(--border);
691
+ }
692
+
693
+ .expo-url {
694
+ font-family: var(--mono);
695
+ font-size: 11px;
696
+ color: var(--accent);
697
+ word-break: break-all;
698
+ }
699
+
700
+ .expo-status-badge {
701
+ padding: 3px 10px;
702
+ border-radius: 12px;
703
+ font-family: var(--mono);
704
+ font-size: 10px;
705
+ font-weight: 700;
706
+ text-transform: uppercase;
707
+ }
708
+
709
+ .expo-status-badge.stopped { background: rgba(107,112,148,0.2); color: var(--muted); }
710
+ .expo-status-badge.installing { background: rgba(251,191,36,0.15); color: var(--yellow); }
711
+ .expo-status-badge.starting { background: rgba(251,191,36,0.15); color: var(--yellow); }
712
+ .expo-status-badge.running { background: rgba(74,222,128,0.15); color: var(--green); }
713
+ .expo-status-badge.error { background: rgba(248,113,113,0.15); color: var(--red); }
714
+
715
+ /* ── TERMINAL PANEL ── */
716
+ .term-panel {
717
+ position: fixed;
718
+ bottom: 0; left: 0; right: 320px;
719
+ height: 320px;
720
+ background: #0d0f1a;
721
+ border-top: 2px solid var(--border);
722
+ z-index: 19;
723
+ display: flex;
724
+ flex-direction: column;
725
+ transform: translateY(100%);
726
+ transition: transform 0.25s ease;
727
+ }
728
+
729
+ .term-panel.open { transform: translateY(0); }
730
+
731
+ .term-header {
732
+ display: flex;
733
+ align-items: center;
734
+ gap: 10px;
735
+ padding: 8px 14px;
736
+ background: #111320;
737
+ border-bottom: 1px solid var(--border);
738
+ flex-shrink: 0;
739
+ }
740
+
741
+ .term-title {
742
+ font-family: var(--mono);
743
+ font-size: 11px;
744
+ color: var(--muted);
745
+ text-transform: uppercase;
746
+ letter-spacing: 0.1em;
747
+ }
748
+
749
+ #terminal {
750
+ flex: 1;
751
+ overflow: hidden;
752
+ padding: 4px;
753
+ }
754
+
755
+ .btn-term-close {
756
+ margin-left: auto;
757
+ background: none;
758
+ border: none;
759
+ color: var(--muted);
760
+ cursor: pointer;
761
+ font-size: 16px;
762
+ line-height: 1;
763
+ padding: 2px 6px;
764
+ }
765
+ .btn-term-close:hover { color: var(--text); }
766
+
767
+ /* ── TOOLBAR BOTTOM ── */
768
+ .bottom-toolbar {
769
+ position: fixed;
770
+ bottom: 0; left: 0; right: 320px;
771
+ height: 40px;
772
+ background: var(--bg);
773
+ border-top: 1px solid var(--border);
774
+ display: flex;
775
+ align-items: center;
776
+ padding: 0 16px;
777
+ gap: 8px;
778
+ z-index: 15;
779
+ }
780
+
781
+ .toolbar-btn {
782
+ display: flex;
783
+ align-items: center;
784
+ gap: 6px;
785
+ padding: 5px 12px;
786
+ border-radius: 5px;
787
+ font-family: var(--mono);
788
+ font-size: 11px;
789
+ font-weight: 600;
790
+ cursor: pointer;
791
+ border: 1px solid var(--border);
792
+ background: rgba(255,255,255,0.04);
793
+ color: var(--muted);
794
+ transition: all 0.15s;
795
+ }
796
+
797
+ .toolbar-btn:hover { color: var(--text); border-color: var(--dim); background: rgba(255,255,255,0.08); }
798
+ .toolbar-btn.active { color: var(--accent); border-color: rgba(108,138,255,0.4); background: rgba(108,138,255,0.08); }
799
+ .toolbar-btn.expo-running { color: var(--green); border-color: rgba(74,222,128,0.4); background: rgba(74,222,128,0.08); }
800
+
801
+ /* adjust board to not overlap toolbar */
802
+ .board-wrap { padding-bottom: 40px; }
803
+
804
+ /* ── RETRY BUTTON on failed cards ── */
805
+ .card-retry-btn {
806
+ margin-top: 10px;
807
+ width: 100%;
808
+ padding: 6px;
809
+ background: rgba(248,113,113,0.1);
810
+ border: 1px solid rgba(248,113,113,0.25);
811
+ border-radius: 6px;
812
+ color: var(--red);
813
+ font-size: 11px;
814
+ font-weight: 600;
815
+ font-family: var(--mono);
816
+ cursor: pointer;
817
+ transition: all 0.15s;
818
+ text-align: center;
819
+ }
820
+ .card-retry-btn:hover {
821
+ background: rgba(248,113,113,0.2);
822
+ border-color: rgba(248,113,113,0.5);
823
+ }
824
+
643
825
  /* Empty column state */
644
826
  .col-empty {
645
827
  text-align: center;
@@ -792,6 +974,112 @@
792
974
  </div>
793
975
  </div>
794
976
 
977
+ <!-- RETRY / EDIT MODAL -->
978
+ <div class="overlay" id="retryModal" onclick="if(event.target===this)closeRetry()">
979
+ <div class="modal">
980
+ <div class="modal-title" style="display:flex;align-items:center;gap:10px">
981
+ <span style="color:var(--red)">✕</span>
982
+ <span>Edit & Retry Failed Task</span>
983
+ </div>
984
+
985
+ <div style="background:rgba(248,113,113,0.06);border:1px solid rgba(248,113,113,0.2);border-radius:8px;padding:10px 12px;margin-bottom:16px;font-family:var(--mono);font-size:11px;color:var(--red)" id="retryErrorLog">
986
+ No error log found.
987
+ </div>
988
+
989
+ <div class="field">
990
+ <label>Title</label>
991
+ <input type="text" id="r-title">
992
+ </div>
993
+ <div class="field">
994
+ <label>Description</label>
995
+ <textarea id="r-desc" rows="4"></textarea>
996
+ </div>
997
+ <div class="field">
998
+ <label style="color:var(--accent)">💬 Note for the agent (hint to fix the issue)</label>
999
+ <textarea id="r-note" rows="3" placeholder="e.g. Use tailwind v3 not v4. The error is about missing module X. Try a simpler approach without..."></textarea>
1000
+ </div>
1001
+ <div class="modal-grid">
1002
+ <div class="field">
1003
+ <label>Priority</label>
1004
+ <select id="r-priority">
1005
+ <option value="high">High</option>
1006
+ <option value="medium">Medium</option>
1007
+ <option value="low">Low</option>
1008
+ </select>
1009
+ </div>
1010
+ <div class="field">
1011
+ <label>Type</label>
1012
+ <select id="r-type">
1013
+ <option value="feature">Feature</option>
1014
+ <option value="bug">Bug</option>
1015
+ <option value="config">Config</option>
1016
+ <option value="refactor">Refactor</option>
1017
+ <option value="test">Test</option>
1018
+ </select>
1019
+ </div>
1020
+ </div>
1021
+ <div class="modal-actions">
1022
+ <button class="btn-cancel" onclick="closeRetry()">Cancel</button>
1023
+ <button class="btn-create" style="background:var(--red)" onclick="submitRetry()">↩ Retry Task</button>
1024
+ </div>
1025
+ </div>
1026
+ </div>
1027
+
1028
+ <!-- BOTTOM TOOLBAR -->
1029
+ <div class="bottom-toolbar">
1030
+ <button class="toolbar-btn" id="expoBtn" onclick="toggleExpoPanel()">
1031
+ 📱 Expo
1032
+ <span class="expo-status-badge stopped" id="expoBadge">stopped</span>
1033
+ </button>
1034
+ <button class="toolbar-btn" id="termBtn" onclick="toggleTerminal()">
1035
+ ⌨️ Terminal
1036
+ </button>
1037
+ </div>
1038
+
1039
+ <!-- EXPO PANEL -->
1040
+ <div class="expo-panel" id="expoPanel">
1041
+ <div class="expo-panel-header" onclick="toggleExpoPanel()">
1042
+ <span class="expo-panel-title">📱 Expo Go</span>
1043
+ <span class="expo-status-badge stopped" id="expoPanelBadge">stopped</span>
1044
+ <div style="margin-left:auto;display:flex;gap:8px">
1045
+ <button class="btn btn-primary" id="expoStartBtn" onclick="event.stopPropagation();startExpo()" style="font-size:11px;padding:4px 12px">Start Expo</button>
1046
+ <button class="btn btn-ghost" id="expoStopBtn" onclick="event.stopPropagation();stopExpo()" style="font-size:11px;padding:4px 12px;display:none">Stop</button>
1047
+ </div>
1048
+ </div>
1049
+ <div style="display:flex;gap:0">
1050
+ <div style="flex:1">
1051
+ <div class="expo-logs" id="expoLogs">Expo not started. Click "Start Expo" to install dependencies and launch with tunnel.</div>
1052
+ <div class="expo-qr-wrap" id="expoUrlWrap" style="display:none">
1053
+ <div>
1054
+ <div style="font-family:var(--mono);font-size:10px;color:var(--muted);margin-bottom:4px">SCAN WITH EXPO GO</div>
1055
+ <div class="expo-url" id="expoUrl">—</div>
1056
+ </div>
1057
+ </div>
1058
+ </div>
1059
+ <div id="qrWrap" style="padding:12px;display:none">
1060
+ <canvas id="qrCanvas" width="120" height="120"></canvas>
1061
+ </div>
1062
+ </div>
1063
+ </div>
1064
+
1065
+ <!-- TERMINAL PANEL -->
1066
+ <div class="term-panel" id="termPanel">
1067
+ <div class="term-header">
1068
+ <span style="color:var(--green);font-size:14px">⬤</span>
1069
+ <span class="term-title">Terminal — <span style="color:var(--accent)" id="termDir">project</span></span>
1070
+ <button class="btn-term-close" onclick="toggleTerminal()">✕</button>
1071
+ </div>
1072
+ <div id="terminal"></div>
1073
+ </div>
1074
+
1075
+ <!-- xterm.js -->
1076
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/xterm/5.3.0/xterm.min.css">
1077
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/xterm/5.3.0/xterm.min.js"></script>
1078
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/xterm/5.3.0/addon-fit.min.js"></script>
1079
+
1080
+ <!-- QR code via qrcodejs -->
1081
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
1082
+
795
1083
  <script>
796
1084
  // ── STATE ────────────────────────────────────────────────────────────────────
797
1085
  let board = { epics: [], logs: [] };
@@ -815,6 +1103,12 @@ function connectWS() {
815
1103
  board.logs.unshift(data);
816
1104
  if (activeTab === 'activity') renderLogs();
817
1105
  }
1106
+ if (event === 'expo_status') {
1107
+ setExpoStatus(data.status, data.url);
1108
+ }
1109
+ if (event === 'expo_log') {
1110
+ appendExpoLog(data.message);
1111
+ }
818
1112
  };
819
1113
  }
820
1114
 
@@ -903,6 +1197,7 @@ function renderKanban() {
903
1197
  function cardHTML(task) {
904
1198
  const icons = { todo: '', in_progress: '', done: '✓', error: '✕', blocked: '—' };
905
1199
  const shortEpic = (task.epicName || '').split(' ').slice(0,2).join(' ');
1200
+ const isError = task.status === 'error';
906
1201
  return `
907
1202
  <div class="card fade-in" draggable="true" data-id="${task.id}" data-status="${task.status}">
908
1203
  <div class="card-top">
@@ -915,6 +1210,7 @@ function cardHTML(task) {
915
1210
  <span class="tag ${task.type}">${task.type}</span>
916
1211
  ${shortEpic ? `<span class="card-epic">${esc(shortEpic)}</span>` : ''}
917
1212
  </div>
1213
+ ${isError ? `<button class="card-retry-btn" onclick="event.stopPropagation();openRetry('${task.id}')">↩ Edit & Retry</button>` : ''}
918
1214
  </div>`;
919
1215
  }
920
1216
 
@@ -1057,6 +1353,230 @@ async function submitTask() {
1057
1353
  loadBoard();
1058
1354
  }
1059
1355
 
1356
+ // ── RETRY MODAL ───────────────────────────────────────────────────────────────
1357
+ let retryTaskId = null;
1358
+
1359
+ async function openRetry(id) {
1360
+ retryTaskId = id;
1361
+ const task = allTasks().find(t => t.id === id);
1362
+ if (!task) return;
1363
+
1364
+ // Fill form with current task data
1365
+ document.getElementById('r-title').value = task.title;
1366
+ document.getElementById('r-desc').value = task.description || '';
1367
+ document.getElementById('r-note').value = '';
1368
+ document.getElementById('r-priority').value = task.priority || 'medium';
1369
+ document.getElementById('r-type').value = task.type || 'feature';
1370
+
1371
+ // Load last error log
1372
+ const res = await fetch(`/api/tasks/${id}/logs`);
1373
+ const { logs } = await res.json();
1374
+ const errorLogs = logs.filter(l => l.type === 'error');
1375
+ const lastError = errorLogs[errorLogs.length - 1];
1376
+ document.getElementById('retryErrorLog').textContent =
1377
+ lastError ? lastError.message : 'No error log found.';
1378
+
1379
+ document.getElementById('retryModal').className = 'overlay open';
1380
+ }
1381
+
1382
+ function closeRetry() {
1383
+ document.getElementById('retryModal').className = 'overlay';
1384
+ retryTaskId = null;
1385
+ }
1386
+
1387
+ async function submitRetry() {
1388
+ if (!retryTaskId) return;
1389
+
1390
+ const title = document.getElementById('r-title').value.trim();
1391
+ const desc = document.getElementById('r-desc').value.trim();
1392
+ const note = document.getElementById('r-note').value.trim();
1393
+ const priority = document.getElementById('r-priority').value;
1394
+ const type = document.getElementById('r-type').value;
1395
+
1396
+ if (!title) return;
1397
+
1398
+ // Build updated description — append agent note if provided
1399
+ const updatedDesc = note
1400
+ ? `${desc}\n\n⚠️ AGENT NOTE (from human review): ${note}`
1401
+ : desc;
1402
+
1403
+ // Update task fields + reset status to todo
1404
+ await fetch(`/api/tasks/${retryTaskId}`, {
1405
+ method: 'PATCH',
1406
+ headers: { 'Content-Type': 'application/json' },
1407
+ body: JSON.stringify({
1408
+ title,
1409
+ description: updatedDesc,
1410
+ priority,
1411
+ type,
1412
+ status: 'todo',
1413
+ started_at: null,
1414
+ }),
1415
+ });
1416
+
1417
+ // Log the retry
1418
+ await fetch(`/api/tasks/${retryTaskId}/log`, {
1419
+ method: 'POST',
1420
+ headers: { 'Content-Type': 'application/json' },
1421
+ body: JSON.stringify({
1422
+ message: note
1423
+ ? `↩ Retried by human with note: "${note}"`
1424
+ : '↩ Retried by human — reset to todo',
1425
+ }),
1426
+ });
1427
+
1428
+ closeRetry();
1429
+ loadBoard();
1430
+ }
1431
+
1432
+ // ── EXPO ─────────────────────────────────────────────────────────────────────
1433
+ let expoOpen = false;
1434
+ let termOpen = false;
1435
+ let term = null;
1436
+ let termSocket = null;
1437
+ let termFit = null;
1438
+ let qrInstance = null;
1439
+
1440
+ function toggleExpoPanel() {
1441
+ expoOpen = !expoOpen;
1442
+ document.getElementById('expoPanel').className = 'expo-panel' + (expoOpen ? ' open' : '');
1443
+ document.getElementById('expoBtn').className = 'toolbar-btn' + (expoOpen ? ' active' : '');
1444
+ if (termOpen) { termOpen = false; document.getElementById('termPanel').className = 'term-panel'; }
1445
+ }
1446
+
1447
+ async function startExpo() {
1448
+ document.getElementById('expoStartBtn').style.display = 'none';
1449
+ document.getElementById('expoStopBtn').style.display = 'inline-flex';
1450
+ appendExpoLog('▶ Starting Expo...');
1451
+ await fetch('/api/expo/start', { method: 'POST' });
1452
+ }
1453
+
1454
+ async function stopExpo() {
1455
+ await fetch('/api/expo/stop', { method: 'POST' });
1456
+ document.getElementById('expoStartBtn').style.display = 'inline-flex';
1457
+ document.getElementById('expoStopBtn').style.display = 'none';
1458
+ document.getElementById('expoUrlWrap').style.display = 'none';
1459
+ document.getElementById('qrWrap').style.display = 'none';
1460
+ appendExpoLog('■ Expo stopped.');
1461
+ }
1462
+
1463
+ function appendExpoLog(msg) {
1464
+ const el = document.getElementById('expoLogs');
1465
+ const line = document.createElement('div');
1466
+ line.textContent = msg;
1467
+ el.appendChild(line);
1468
+ el.scrollTop = el.scrollHeight;
1469
+ }
1470
+
1471
+ function setExpoStatus(status, url) {
1472
+ const labels = { stopped:'stopped', installing:'installing...', starting:'starting...', running:'running', error:'error' };
1473
+ const label = labels[status] || status;
1474
+
1475
+ ['expoBadge','expoPanelBadge'].forEach(id => {
1476
+ const el = document.getElementById(id);
1477
+ el.className = `expo-status-badge ${status}`;
1478
+ el.textContent = label;
1479
+ });
1480
+
1481
+ document.getElementById('expoBtn').className = 'toolbar-btn' + (status === 'running' ? ' expo-running' : (expoOpen ? ' active' : ''));
1482
+
1483
+ if (status === 'running' && url) {
1484
+ document.getElementById('expoUrl').textContent = url;
1485
+ document.getElementById('expoUrlWrap').style.display = 'flex';
1486
+
1487
+ // Generate QR code
1488
+ document.getElementById('qrWrap').style.display = 'block';
1489
+ const canvas = document.getElementById('qrCanvas');
1490
+ const ctx = canvas.getContext('2d');
1491
+ ctx.clearRect(0, 0, 120, 120);
1492
+
1493
+ if (window.QRCode) {
1494
+ document.getElementById('qrCanvas').innerHTML = '';
1495
+ try {
1496
+ new QRCode(document.getElementById('qrCanvas'), {
1497
+ text: url,
1498
+ width: 120, height: 120,
1499
+ colorDark: '#e2e4f0',
1500
+ colorLight: '#0d0f1a',
1501
+ });
1502
+ } catch {}
1503
+ }
1504
+ }
1505
+
1506
+ if (status === 'stopped' || status === 'error') {
1507
+ document.getElementById('expoStartBtn').style.display = 'inline-flex';
1508
+ document.getElementById('expoStopBtn').style.display = 'none';
1509
+ }
1510
+ }
1511
+
1512
+ // ── TERMINAL ─────────────────────────────────────────────────────────────────
1513
+ function toggleTerminal() {
1514
+ termOpen = !termOpen;
1515
+ document.getElementById('termPanel').className = 'term-panel' + (termOpen ? ' open' : '');
1516
+ document.getElementById('termBtn').className = 'toolbar-btn' + (termOpen ? ' active' : '');
1517
+
1518
+ if (expoOpen) { expoOpen = false; document.getElementById('expoPanel').className = 'expo-panel'; }
1519
+
1520
+ if (termOpen && !term) initTerminal();
1521
+ if (termOpen && termFit) setTimeout(() => termFit.fit(), 100);
1522
+ }
1523
+
1524
+ function initTerminal() {
1525
+ term = new Terminal({
1526
+ theme: {
1527
+ background: '#0d0f1a',
1528
+ foreground: '#e2e4f0',
1529
+ cursor: '#6c8aff',
1530
+ cursorAccent: '#0d0f1a',
1531
+ selection: 'rgba(108,138,255,0.3)',
1532
+ black: '#1e2130', red: '#f87171', green: '#4ade80', yellow: '#fbbf24',
1533
+ blue: '#6c8aff', magenta: '#c084fc', cyan: '#22d3ee', white: '#e2e4f0',
1534
+ brightBlack: '#454868', brightRed: '#fca5a5', brightGreen: '#86efac',
1535
+ brightYellow: '#fde68a', brightBlue: '#93c5fd', brightMagenta: '#d8b4fe',
1536
+ brightCyan: '#67e8f9', brightWhite: '#f1f5f9',
1537
+ },
1538
+ fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
1539
+ fontSize: 13,
1540
+ lineHeight: 1.4,
1541
+ cursorBlink: true,
1542
+ scrollback: 2000,
1543
+ });
1544
+
1545
+ const fitAddon = new FitAddon.FitAddon();
1546
+ termFit = fitAddon;
1547
+ term.loadAddon(fitAddon);
1548
+ term.open(document.getElementById('terminal'));
1549
+ fitAddon.fit();
1550
+
1551
+ // Connect WebSocket
1552
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
1553
+ termSocket = new WebSocket(`${proto}//${location.host}/terminal`);
1554
+
1555
+ termSocket.onopen = () => {
1556
+ term.write('\x1b[32m[ClaudeBoard Terminal]\x1b[0m Connected\r\n\r\n');
1557
+ };
1558
+
1559
+ termSocket.onmessage = (e) => {
1560
+ const msg = JSON.parse(e.data);
1561
+ if (msg.type === 'output') term.write(msg.data);
1562
+ if (msg.type === 'exit') term.write('\r\n\x1b[31m[process exited]\x1b[0m\r\n');
1563
+ };
1564
+
1565
+ termSocket.onclose = () => term.write('\r\n\x1b[33m[disconnected]\x1b[0m\r\n');
1566
+
1567
+ term.onData((data) => {
1568
+ if (termSocket?.readyState === 1) {
1569
+ termSocket.send(JSON.stringify({ type: 'input', data }));
1570
+ }
1571
+ });
1572
+
1573
+ // Resize on window resize
1574
+ window.addEventListener('resize', () => { if (termFit) termFit.fit(); });
1575
+ term.onResize(({ cols, rows }) => {
1576
+ if (termSocket?.readyState === 1) termSocket.send(JSON.stringify({ type: 'resize', cols, rows }));
1577
+ });
1578
+ }
1579
+
1060
1580
  // ── UTILS ─────────────────────────────────────────────────────────────────────
1061
1581
  function esc(s) {
1062
1582
  if (!s) return '';
@@ -1064,7 +1584,7 @@ function esc(s) {
1064
1584
  }
1065
1585
 
1066
1586
  document.addEventListener('keydown', e => {
1067
- if (e.key === 'Escape') closeModal();
1587
+ if (e.key === 'Escape') { closeModal(); closeRetry(); }
1068
1588
  if ((e.metaKey || e.ctrlKey) && e.key === 'n') { e.preventDefault(); openModal(); }
1069
1589
  });
1070
1590
 
@@ -1072,6 +1592,9 @@ document.addEventListener('keydown', e => {
1072
1592
  loadBoard();
1073
1593
  setInterval(loadBoard, 8000);
1074
1594
  connectWS();
1595
+
1596
+ // Load expo status
1597
+ fetch('/api/expo/status').then(r => r.json()).then(d => setExpoStatus(d.status, d.url));
1075
1598
  </script>
1076
1599
  </body>
1077
1600
  </html>
@@ -6,192 +6,310 @@ import { createClient } from "@supabase/supabase-js";
6
6
  import path from "path";
7
7
  import { fileURLToPath } from "url";
8
8
  import fs from "fs";
9
+ import { spawn } from "child_process";
10
+ import { createRequire } from "module";
9
11
 
10
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const require = createRequire(import.meta.url);
11
14
 
12
15
  const app = express();
13
16
  const server = createServer(app);
14
- const wss = new WebSocketServer({ server });
17
+
18
+ // Two WS servers: one for board events, one for terminal
19
+ const boardWss = new WebSocketServer({ noServer: true });
20
+ const termWss = new WebSocketServer({ noServer: true });
21
+
22
+ // Route upgrade requests
23
+ server.on("upgrade", (req, socket, head) => {
24
+ if (req.url === "/terminal") {
25
+ termWss.handleUpgrade(req, socket, head, (ws) => termWss.emit("connection", ws, req));
26
+ } else {
27
+ boardWss.handleUpgrade(req, socket, head, (ws) => boardWss.emit("connection", ws, req));
28
+ }
29
+ });
15
30
 
16
31
  app.use(cors());
17
32
  app.use(express.json());
18
33
 
19
- const PORT = process.env.PORT || 3131;
20
- const PROJECT = process.env.PROJECT_NAME || "default";
34
+ const PORT = process.env.PORT || 3131;
35
+ const PROJECT = process.env.PROJECT_NAME || "default";
36
+ const PROJECT_DIR = process.env.PROJECT_DIR || process.cwd();
37
+ const SUPABASE_URL = process.env.SUPABASE_URL;
38
+ const SUPABASE_KEY = process.env.SUPABASE_KEY;
21
39
 
22
- const supabase = createClient(
23
- process.env.SUPABASE_URL,
24
- process.env.SUPABASE_KEY
25
- );
40
+ const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
26
41
 
27
- // Broadcast to all WS clients
42
+ // ── STATE ─────────────────────────────────────────────────────────────────────
43
+ let expoProcess = null;
44
+ let expoStatus = "stopped"; // stopped | installing | starting | running | error
45
+ let expoQR = null;
46
+ let expoUrl = null;
47
+
48
+ // ── BOARD BROADCAST ───────────────────────────────────────────────────────────
28
49
  function broadcast(event, data) {
29
50
  const msg = JSON.stringify({ event, data, ts: Date.now() });
30
- wss.clients.forEach((client) => {
31
- if (client.readyState === 1) client.send(msg);
32
- });
51
+ boardWss.clients.forEach((c) => { if (c.readyState === 1) c.send(msg); });
33
52
  }
34
53
 
35
- // Subscribe to Supabase realtime
54
+ function broadcastExpoStatus() {
55
+ broadcast("expo_status", { status: expoStatus, qr: expoQR, url: expoUrl });
56
+ }
57
+
58
+ // ── SUPABASE REALTIME ─────────────────────────────────────────────────────────
36
59
  supabase
37
60
  .channel("cb_changes")
38
- .on("postgres_changes", { event: "*", schema: "public", table: "cb_tasks" }, (payload) => {
39
- broadcast("task_update", payload);
40
- })
41
- .on("postgres_changes", { event: "*", schema: "public", table: "cb_logs" }, (payload) => {
42
- broadcast("log", payload.new);
43
- })
61
+ .on("postgres_changes", { event: "*", schema: "public", table: "cb_tasks" }, (p) => broadcast("task_update", p))
62
+ .on("postgres_changes", { event: "*", schema: "public", table: "cb_logs" }, (p) => broadcast("log", p.new))
44
63
  .subscribe();
45
64
 
46
- // ─── API ROUTES ───────────────────────────────────────────────────────────────
65
+ // ── TERMINAL (xterm.js via WebSocket + node-pty) ──────────────────────────────
66
+ termWss.on("connection", (ws) => {
67
+ let pty = null;
68
+
69
+ try {
70
+ // Try node-pty for full PTY support
71
+ const nodePty = require("node-pty");
72
+ pty = nodePty.spawn(process.env.SHELL || "bash", [], {
73
+ name: "xterm-256color",
74
+ cols: 120,
75
+ rows: 40,
76
+ cwd: PROJECT_DIR,
77
+ env: {
78
+ ...process.env,
79
+ SUPABASE_URL,
80
+ SUPABASE_ACCESS_TOKEN: process.env.SUPABASE_ACCESS_TOKEN || "",
81
+ TERM: "xterm-256color",
82
+ },
83
+ });
84
+
85
+ pty.onData((data) => { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "output", data })); });
86
+ pty.onExit(() => { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "exit" })); });
87
+
88
+ ws.on("message", (raw) => {
89
+ try {
90
+ const msg = JSON.parse(raw);
91
+ if (msg.type === "input") pty.write(msg.data);
92
+ if (msg.type === "resize") pty.resize(msg.cols, msg.rows);
93
+ } catch {}
94
+ });
95
+
96
+ ws.on("close", () => { try { pty.kill(); } catch {} });
97
+
98
+ } catch {
99
+ // Fallback: simple shell without PTY (no colors but functional)
100
+ const shell = spawn(process.env.SHELL || "bash", [], {
101
+ cwd: PROJECT_DIR,
102
+ env: { ...process.env, SUPABASE_URL, TERM: "dumb" },
103
+ });
104
+
105
+ shell.stdout.on("data", (d) => ws.send(JSON.stringify({ type: "output", data: d.toString() })));
106
+ shell.stderr.on("data", (d) => ws.send(JSON.stringify({ type: "output", data: d.toString() })));
107
+ shell.on("close", () => ws.send(JSON.stringify({ type: "exit" })));
108
+
109
+ ws.on("message", (raw) => {
110
+ try {
111
+ const msg = JSON.parse(raw);
112
+ if (msg.type === "input") shell.stdin.write(msg.data);
113
+ } catch {}
114
+ });
115
+
116
+ ws.on("close", () => shell.kill());
117
+
118
+ // Send welcome message
119
+ ws.send(JSON.stringify({
120
+ type: "output",
121
+ data: `\r\n\x1b[33m[ClaudeBoard Terminal]\x1b[0m — Project: ${PROJECT_DIR}\r\n` +
122
+ `\x1b[2mTip: Run 'npx supabase ...' for Supabase CLI commands\x1b[0m\r\n\r\n`,
123
+ }));
124
+ }
125
+ });
126
+
127
+ // ── EXPO MANAGEMENT ───────────────────────────────────────────────────────────
128
+
129
+ // GET expo status
130
+ app.get("/api/expo/status", (req, res) => {
131
+ res.json({ status: expoStatus, qr: expoQR, url: expoUrl });
132
+ });
133
+
134
+ // POST expo/start — install deps + start Expo tunnel
135
+ app.post("/api/expo/start", async (req, res) => {
136
+ if (expoProcess) return res.json({ ok: false, error: "Expo already running" });
137
+
138
+ res.json({ ok: true, message: "Starting Expo..." });
139
+ _startExpo(PROJECT_DIR);
140
+ });
141
+
142
+ // POST expo/stop
143
+ app.post("/api/expo/stop", (req, res) => {
144
+ if (expoProcess) {
145
+ try { expoProcess.kill("SIGTERM"); } catch {}
146
+ expoProcess = null;
147
+ }
148
+ expoStatus = "stopped";
149
+ expoQR = null;
150
+ expoUrl = null;
151
+ broadcastExpoStatus();
152
+ res.json({ ok: true });
153
+ });
154
+
155
+ async function _startExpo(projectDir) {
156
+ // Step 1: npm install
157
+ expoStatus = "installing";
158
+ broadcastExpoStatus();
159
+ broadcast("expo_log", { message: "Installing dependencies..." });
160
+
161
+ await new Promise((resolve) => {
162
+ const install = spawn("npm", ["install"], { cwd: projectDir, stdio: "pipe" });
163
+ install.stdout.on("data", (d) => broadcast("expo_log", { message: d.toString().trim() }));
164
+ install.stderr.on("data", (d) => broadcast("expo_log", { message: d.toString().trim() }));
165
+ install.on("close", resolve);
166
+ });
167
+
168
+ broadcast("expo_log", { message: "Dependencies installed. Starting Expo..." });
169
+
170
+ // Step 2: expo start with tunnel
171
+ expoStatus = "starting";
172
+ broadcastExpoStatus();
173
+
174
+ const expo = spawn("npx", ["expo", "start", "--tunnel"], {
175
+ cwd: projectDir,
176
+ env: { ...process.env, CI: "false", EXPO_NO_DOTENV: "0" },
177
+ stdio: "pipe",
178
+ });
179
+
180
+ expoProcess = expo;
181
+
182
+ expo.stdout.on("data", (d) => {
183
+ const text = d.toString();
184
+ broadcast("expo_log", { message: text.trim() });
185
+
186
+ // Detect QR code URL (exp:// or https://expo.dev)
187
+ const expUrl = text.match(/exp:\/\/[^\s]+/);
188
+ if (expUrl) {
189
+ expoUrl = expUrl[0];
190
+ expoStatus = "running";
191
+ broadcastExpoStatus();
192
+ }
193
+
194
+ // Detect tunnel URL
195
+ const tunnel = text.match(/https:\/\/[a-z0-9-]+\.exp\.direct[^\s]*/);
196
+ if (tunnel) {
197
+ expoUrl = tunnel[0];
198
+ expoStatus = "running";
199
+ broadcastExpoStatus();
200
+ }
201
+
202
+ // Detect QR data from expo output
203
+ if (text.includes("QR")) {
204
+ broadcast("expo_log", { message: "📱 QR code ready — scan with Expo Go" });
205
+ }
206
+ });
207
+
208
+ expo.stderr.on("data", (d) => {
209
+ const text = d.toString().trim();
210
+ if (text) broadcast("expo_log", { message: text });
211
+ });
212
+
213
+ expo.on("close", (code) => {
214
+ expoProcess = null;
215
+ expoStatus = code === 0 ? "stopped" : "error";
216
+ expoQR = null;
217
+ broadcastExpoStatus();
218
+ broadcast("expo_log", { message: `Expo process exited (code ${code})` });
219
+ });
220
+ }
221
+
222
+ // ── SUPABASE QUERY API ────────────────────────────────────────────────────────
223
+ app.post("/api/supabase/query", async (req, res) => {
224
+ const { sql } = req.body;
225
+ if (!sql) return res.status(400).json({ error: "No SQL provided" });
226
+
227
+ try {
228
+ const { data, error } = await supabase.rpc("execute_sql", { query: sql });
229
+ if (error) return res.status(400).json({ error: error.message });
230
+ res.json({ data });
231
+ } catch (err) {
232
+ res.status(500).json({ error: err.message });
233
+ }
234
+ });
235
+
236
+ // ── BOARD API ROUTES (unchanged) ──────────────────────────────────────────────
47
237
 
48
- // GET all tasks grouped by epic
49
238
  app.get("/api/board", async (req, res) => {
50
239
  const { data: epics } = await supabase
51
- .from("cb_epics")
52
- .select("*, cb_tasks(*)")
53
- .eq("project", PROJECT)
54
- .order("created_at");
55
-
240
+ .from("cb_epics").select("*, cb_tasks(*)").eq("project", PROJECT).order("created_at");
56
241
  const { data: logs } = await supabase
57
- .from("cb_logs")
58
- .select("*")
59
- .eq("project", PROJECT)
60
- .order("created_at", { ascending: false })
61
- .limit(50);
62
-
242
+ .from("cb_logs").select("*").eq("project", PROJECT)
243
+ .order("created_at", { ascending: false }).limit(50);
63
244
  res.json({ epics: epics || [], logs: logs || [], project: PROJECT });
64
245
  });
65
246
 
66
- // GET next pending task
67
247
  app.get("/api/tasks/next", async (req, res) => {
68
- const { data } = await supabase
69
- .from("cb_tasks")
70
- .select("*")
71
- .eq("project", PROJECT)
72
- .eq("status", "todo")
73
- .order("priority_order", { ascending: true })
74
- .limit(1)
75
- .single();
76
-
248
+ const { data } = await supabase.from("cb_tasks").select("*")
249
+ .eq("project", PROJECT).eq("status", "todo")
250
+ .order("priority_order", { ascending: true }).limit(1).single();
77
251
  if (!data) return res.json({ task: null, message: "All tasks complete! 🎉" });
78
252
  res.json({ task: data });
79
253
  });
80
254
 
81
- // POST start task
82
255
  app.post("/api/tasks/:id/start", async (req, res) => {
83
256
  const { id } = req.params;
84
- const { log } = req.body;
85
-
86
- await supabase
87
- .from("cb_tasks")
88
- .update({ status: "in_progress", started_at: new Date().toISOString() })
89
- .eq("id", id);
90
-
91
- if (log) await addLog(id, log, "start");
257
+ await supabase.from("cb_tasks").update({ status: "in_progress", started_at: new Date().toISOString() }).eq("id", id);
258
+ if (req.body.log) await addLog(id, req.body.log, "start");
92
259
  broadcast("task_started", { id });
93
260
  res.json({ ok: true });
94
261
  });
95
262
 
96
- // POST log progress
97
263
  app.post("/api/tasks/:id/log", async (req, res) => {
98
- const { id } = req.params;
99
- const { message } = req.body;
100
- await addLog(id, message, "progress");
264
+ await addLog(req.params.id, req.body.message, "progress");
101
265
  res.json({ ok: true });
102
266
  });
103
267
 
104
- // POST complete task
105
268
  app.post("/api/tasks/:id/complete", async (req, res) => {
106
269
  const { id } = req.params;
107
- const { log } = req.body;
108
-
109
- await supabase
110
- .from("cb_tasks")
111
- .update({ status: "done", completed_at: new Date().toISOString() })
112
- .eq("id", id);
113
-
114
- if (log) await addLog(id, log, "complete");
270
+ await supabase.from("cb_tasks").update({ status: "done", completed_at: new Date().toISOString() }).eq("id", id);
271
+ if (req.body.log) await addLog(id, req.body.log, "complete");
115
272
  broadcast("task_complete", { id });
116
273
  res.json({ ok: true });
117
274
  });
118
275
 
119
- // POST fail task
120
276
  app.post("/api/tasks/:id/fail", async (req, res) => {
121
277
  const { id } = req.params;
122
- const { log } = req.body;
123
-
124
- await supabase
125
- .from("cb_tasks")
126
- .update({ status: "error" })
127
- .eq("id", id);
128
-
129
- if (log) await addLog(id, log, "error");
278
+ await supabase.from("cb_tasks").update({ status: "error" }).eq("id", id);
279
+ if (req.body.log) await addLog(id, req.body.log, "error");
130
280
  broadcast("task_failed", { id });
131
281
  res.json({ ok: true });
132
282
  });
133
283
 
134
- // POST add new task manually (from dashboard or agent)
135
284
  app.post("/api/tasks", async (req, res) => {
136
285
  const { title, description, priority, type, epic_id } = req.body;
137
-
138
286
  const priorityOrder = { high: 1, medium: 2, low: 3 };
139
-
140
- const { data, error } = await supabase
141
- .from("cb_tasks")
142
- .insert({
143
- project: PROJECT,
144
- epic_id: epic_id || null,
145
- title,
146
- description,
147
- priority: priority || "medium",
148
- priority_order: priorityOrder[priority] || 2,
149
- type: type || "feature",
150
- status: "todo",
151
- })
152
- .select()
153
- .single();
154
-
287
+ const { data, error } = await supabase.from("cb_tasks").insert({
288
+ project: PROJECT, epic_id: epic_id || null, title, description,
289
+ priority: priority || "medium", priority_order: priorityOrder[priority] || 2,
290
+ type: type || "feature", status: "todo",
291
+ }).select().single();
155
292
  if (error) return res.status(400).json({ error: error.message });
156
293
  broadcast("task_added", data);
157
294
  res.json({ task: data });
158
295
  });
159
296
 
160
- // PATCH update task status manually
161
297
  app.patch("/api/tasks/:id", async (req, res) => {
162
- const { id } = req.params;
163
- const updates = req.body;
164
-
165
- await supabase.from("cb_tasks").update(updates).eq("id", id);
166
- broadcast("task_update", { id, ...updates });
298
+ await supabase.from("cb_tasks").update(req.body).eq("id", req.params.id);
299
+ broadcast("task_update", { id: req.params.id, ...req.body });
167
300
  res.json({ ok: true });
168
301
  });
169
302
 
170
- // GET logs for a specific task
171
303
  app.get("/api/tasks/:id/logs", async (req, res) => {
172
- const { data } = await supabase
173
- .from("cb_logs")
174
- .select("*")
175
- .eq("task_id", req.params.id)
176
- .order("created_at");
304
+ const { data } = await supabase.from("cb_logs").select("*")
305
+ .eq("task_id", req.params.id).order("created_at");
177
306
  res.json({ logs: data || [] });
178
307
  });
179
308
 
180
- // Serve dashboard HTML
181
- app.get("*", (req, res) => {
182
- res.sendFile(path.join(__dirname, "index.html"));
183
- });
309
+ app.get("*", (req, res) => res.sendFile(path.join(__dirname, "index.html")));
184
310
 
185
- // ─── HELPERS ─────────────────────────────────────────────────────────────────
186
311
  async function addLog(taskId, message, type = "info") {
187
- await supabase.from("cb_logs").insert({
188
- project: PROJECT,
189
- task_id: taskId,
190
- message,
191
- type,
192
- });
312
+ await supabase.from("cb_logs").insert({ project: PROJECT, task_id: taskId, message, type });
193
313
  }
194
314
 
195
- server.listen(PORT, () => {
196
- console.log(`READY on port ${PORT}`);
197
- });
315
+ server.listen(PORT, () => console.log(`READY on port ${PORT}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeboard",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "AI engineering team — from PRD to working mobile app, autonomously",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,7 @@
22
22
  "dotenv": "^16.4.5",
23
23
  "enquirer": "^2.4.1",
24
24
  "express": "^4.18.3",
25
+ "node-pty": "^1.0.0",
25
26
  "open": "^10.1.0",
26
27
  "ora": "^8.0.1",
27
28
  "puppeteer": "^22.8.0",