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.
- package/agents/claude-api.js +24 -7
- package/bin/cli.js +2 -0
- package/dashboard/index.html +524 -1
- package/dashboard/server.js +234 -116
- package/package.json +2 -1
package/agents/claude-api.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const MODEL = "claude-sonnet-4-20250514";
|
|
7
|
-
const MAX_TOKENS =
|
|
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
|
-
*
|
|
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
|
|
70
|
+
const clean = text.replace(/```json\n?|```/g, "").trim();
|
|
70
71
|
return JSON.parse(clean);
|
|
71
|
-
} catch
|
|
72
|
-
|
|
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
|
});
|
package/dashboard/index.html
CHANGED
|
@@ -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>
|
package/dashboard/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
20
|
-
const PROJECT
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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" }, (
|
|
39
|
-
|
|
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
|
-
//
|
|
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
|
-
.
|
|
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
|
-
.
|
|
70
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
+
"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",
|