helixevo 0.2.23 → 0.2.25

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.
@@ -3,7 +3,6 @@ import { spawn, type ChildProcess } from 'child_process'
3
3
 
4
4
  export const dynamic = 'force-dynamic'
5
5
 
6
- // Allowed commands — whitelist to prevent arbitrary execution
7
6
  const ALLOWED_COMMANDS: Record<string, { cmd: string; args: string[]; timeout: number }> = {
8
7
  'status': { cmd: 'helixevo', args: ['status'], timeout: 15000 },
9
8
  'health': { cmd: 'helixevo', args: ['health', '--verbose'], timeout: 120000 },
@@ -19,93 +18,103 @@ const ALLOWED_COMMANDS: Record<string, { cmd: string; args: string[]; timeout: n
19
18
  'report': { cmd: 'helixevo', args: ['report', '--days', '7'], timeout: 30000 },
20
19
  }
21
20
 
22
- // Track the currently running process so it can be stopped
23
21
  let activeProcess: ChildProcess | null = null
24
22
  let activeCommand: string | null = null
25
23
 
24
+ // POST — stream output via SSE
26
25
  export async function POST(request: Request) {
27
26
  const body = await request.json()
28
27
  const { command } = body as { command: string }
29
28
 
30
29
  const entry = ALLOWED_COMMANDS[command]
31
30
  if (!entry) {
32
- return NextResponse.json({
33
- success: false,
34
- error: `Unknown command: ${command}`,
35
- }, { status: 400 })
31
+ return NextResponse.json({ success: false, error: `Unknown command: ${command}` }, { status: 400 })
36
32
  }
37
33
 
38
- // Kill any existing process before starting a new one
34
+ // Kill any existing process
39
35
  if (activeProcess && !activeProcess.killed) {
40
36
  activeProcess.kill('SIGTERM')
41
37
  activeProcess = null
42
38
  activeCommand = null
43
39
  }
44
40
 
45
- return new Promise<Response>((resolve) => {
46
- let output = ''
47
- let timedOut = false
48
-
49
- const child = spawn(entry.cmd, entry.args, {
50
- env: { ...process.env },
51
- stdio: ['ignore', 'pipe', 'pipe'],
52
- })
53
-
54
- activeProcess = child
55
- activeCommand = command
56
-
57
- const timeout = setTimeout(() => {
58
- timedOut = true
59
- child.kill('SIGTERM')
60
- }, entry.timeout)
61
-
62
- child.stdout?.on('data', (data: Buffer) => {
63
- output += data.toString()
64
- })
65
-
66
- child.stderr?.on('data', (data: Buffer) => {
67
- output += data.toString()
68
- })
69
-
70
- child.on('close', (code, signal) => {
71
- clearTimeout(timeout)
72
- activeProcess = null
73
- activeCommand = null
74
-
75
- if (signal === 'SIGTERM' && !timedOut) {
76
- // Stopped by user
77
- resolve(NextResponse.json({
78
- success: false,
79
- stopped: true,
80
- output: output + '\n\n[Stopped by user]',
81
- }))
82
- } else if (timedOut) {
83
- resolve(NextResponse.json({
84
- success: false,
85
- output: output + '\n\n[Timed out]',
86
- }, { status: 500 }))
87
- } else {
88
- resolve(NextResponse.json({
89
- success: code === 0,
90
- command: `${entry.cmd} ${entry.args.join(' ')}`,
91
- output: output || 'No output',
92
- }, code === 0 ? {} : { status: 500 }))
41
+ const encoder = new TextEncoder()
42
+ let timedOut = false
43
+
44
+ const stream = new ReadableStream({
45
+ start(controller) {
46
+ const child = spawn(entry.cmd, entry.args, {
47
+ env: { ...process.env },
48
+ stdio: ['ignore', 'pipe', 'pipe'],
49
+ })
50
+
51
+ activeProcess = child
52
+ activeCommand = command
53
+
54
+ const timeout = setTimeout(() => {
55
+ timedOut = true
56
+ child.kill('SIGTERM')
57
+ }, entry.timeout)
58
+
59
+ const send = (event: string, data: string) => {
60
+ try {
61
+ controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`))
62
+ } catch {}
93
63
  }
94
- })
95
-
96
- child.on('error', (err) => {
97
- clearTimeout(timeout)
98
- activeProcess = null
99
- activeCommand = null
100
- resolve(NextResponse.json({
101
- success: false,
102
- output: err.message,
103
- }, { status: 500 }))
104
- })
64
+
65
+ child.stdout?.on('data', (chunk: Buffer) => {
66
+ send('output', chunk.toString())
67
+ })
68
+
69
+ child.stderr?.on('data', (chunk: Buffer) => {
70
+ send('output', chunk.toString())
71
+ })
72
+
73
+ child.on('close', (code, signal) => {
74
+ clearTimeout(timeout)
75
+ activeProcess = null
76
+ activeCommand = null
77
+
78
+ if (signal === 'SIGTERM' && !timedOut) {
79
+ send('done', JSON.stringify({ success: false, stopped: true }))
80
+ } else if (timedOut) {
81
+ send('done', JSON.stringify({ success: false, timedOut: true }))
82
+ } else {
83
+ send('done', JSON.stringify({ success: code === 0 }))
84
+ }
85
+
86
+ try { controller.close() } catch {}
87
+ })
88
+
89
+ child.on('error', (err) => {
90
+ clearTimeout(timeout)
91
+ activeProcess = null
92
+ activeCommand = null
93
+ send('output', `Error: ${err.message}`)
94
+ send('done', JSON.stringify({ success: false }))
95
+ try { controller.close() } catch {}
96
+ })
97
+ },
98
+
99
+ cancel() {
100
+ if (activeProcess && !activeProcess.killed) {
101
+ activeProcess.kill('SIGTERM')
102
+ activeProcess = null
103
+ activeCommand = null
104
+ }
105
+ },
106
+ })
107
+
108
+ return new Response(stream, {
109
+ headers: {
110
+ 'Content-Type': 'text/event-stream',
111
+ 'Cache-Control': 'no-cache',
112
+ 'Connection': 'keep-alive',
113
+ },
105
114
  })
106
115
  }
107
116
 
108
- // DELETE = stop the running command
117
+ // DELETE = stop
109
118
  export async function DELETE() {
110
119
  if (activeProcess && !activeProcess.killed) {
111
120
  const cmd = activeCommand
@@ -117,7 +126,7 @@ export async function DELETE() {
117
126
  return NextResponse.json({ stopped: false, message: 'No running command' })
118
127
  }
119
128
 
120
- // GET = check if something is running
129
+ // GET = check status
121
130
  export async function GET() {
122
131
  return NextResponse.json({
123
132
  running: activeProcess !== null && !activeProcess.killed,
@@ -1,26 +1,31 @@
1
+ import Link from 'next/link'
1
2
  import { getDashboardSummary, loadFrontier, loadHistory, loadGraph, loadFailures, listProjects } from '@/lib/data'
2
3
  import { OverviewActions } from '@/components/overview-actions'
3
4
 
4
5
  export const dynamic = 'force-dynamic'
5
6
 
6
- function StatCard({ value, label, color, sub, accent }: { value: string | number; label: string; color: string; sub?: string; accent?: string }) {
7
+ function StatCard({ value, label, color, sub, accent, href }: {
8
+ value: string | number; label: string; color: string; sub?: string; accent?: string; href: string
9
+ }) {
7
10
  return (
8
- <div className="stat-card">
9
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
10
- <div>
11
- <div className="stat-value" style={{ color }}>{value}</div>
12
- <div className="stat-label">{label}</div>
13
- {sub && <div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 2 }}>{sub}</div>}
11
+ <Link href={href} style={{ textDecoration: 'none', color: 'inherit' }}>
12
+ <div className="stat-card" style={{ cursor: 'pointer' }}>
13
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
14
+ <div>
15
+ <div className="stat-value" style={{ color }}>{value}</div>
16
+ <div className="stat-label">{label}</div>
17
+ {sub && <div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 2 }}>{sub}</div>}
18
+ </div>
19
+ {accent && (
20
+ <div style={{
21
+ width: 36, height: 36, borderRadius: 10,
22
+ background: `${color}12`, display: 'flex', alignItems: 'center', justifyContent: 'center',
23
+ color, fontSize: 16,
24
+ }}>{accent}</div>
25
+ )}
14
26
  </div>
15
- {accent && (
16
- <div style={{
17
- width: 36, height: 36, borderRadius: 10,
18
- background: `${color}12`, display: 'flex', alignItems: 'center', justifyContent: 'center',
19
- color, fontSize: 16,
20
- }}>{accent}</div>
21
- )}
22
27
  </div>
23
- </div>
28
+ </Link>
24
29
  )
25
30
  }
26
31
 
@@ -56,18 +61,18 @@ export default function Overview() {
56
61
  />
57
62
  </div>
58
63
 
59
- {/* Stats */}
64
+ {/* Stats — all clickable, linking to relevant pages */}
60
65
  <div className="grid-5" style={{ marginBottom: 28 }}>
61
- <StatCard value={s.skills.total} label="Total Skills" color="var(--purple)" sub={`${s.skills.evolved} evolved`} accent="◆" />
62
- <StatCard value={s.evolution.accepted} label="Accepted" color="var(--green)" sub={`${s.evolution.rejected} rejected`} accent="✓" />
63
- <StatCard value={s.failures.unresolved} label="Unresolved" color="var(--yellow)" sub={`of ${s.failures.total} total`} accent="!" />
64
- <StatCard value={s.buffer.discoveries} label="Discoveries" color="var(--blue)" sub={`${s.buffer.drafts} drafts`} accent="◎" />
65
- <StatCard value={frontier.programs.length} label="Frontier" color="var(--text-secondary)" sub={`/${frontier.capacity} capacity`} accent="▲" />
66
+ <StatCard value={s.skills.total} label="Total Skills" color="var(--purple)" sub={`${s.skills.evolved} evolved`} accent="◆" href="/network" />
67
+ <StatCard value={s.evolution.accepted} label="Accepted" color="var(--green)" sub={`${s.evolution.rejected} rejected`} accent="✓" href="/evolution" />
68
+ <StatCard value={s.failures.unresolved} label="Unresolved" color="var(--yellow)" sub={`of ${s.failures.total} total`} accent="!" href="#unresolved" />
69
+ <StatCard value={s.buffer.discoveries} label="Discoveries" color="var(--blue)" sub={`${s.buffer.drafts} drafts`} accent="◎" href="/research" />
70
+ <StatCard value={frontier.programs.length} label="Frontier" color="var(--text-secondary)" sub={`/${frontier.capacity} capacity`} accent="▲" href="/frontier" />
66
71
  </div>
67
72
 
68
73
  {/* Unresolved Failures */}
69
74
  {failures.filter(f => !f.resolved).length > 0 && (
70
- <div className="card" style={{ marginBottom: 20 }}>
75
+ <div id="unresolved" className="card" style={{ marginBottom: 20 }}>
71
76
  <div className="card-body">
72
77
  <div className="card-header-label">
73
78
  Unresolved Corrections ({failures.filter(f => !f.resolved).length})
@@ -110,98 +115,104 @@ export default function Overview() {
110
115
 
111
116
  <div className="grid-2">
112
117
  {/* Frontier */}
113
- <div className="card">
114
- <div className="card-body">
115
- <div className="card-header-label">Pareto Frontier</div>
116
- {frontier.programs.map((p, i) => (
117
- <div key={`${p.id}-${i}`} style={{
118
- padding: '12px 0',
119
- borderBottom: i < frontier.programs.length - 1 ? '1px solid var(--border-subtle)' : 'none',
120
- }}>
121
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
122
- <span style={{ fontSize: 13, fontWeight: 600 }}>{p.id}</span>
123
- <span style={{
124
- fontSize: 20, fontWeight: 800, lineHeight: 1,
125
- color: p.score >= 0.8 ? 'var(--green)' : 'var(--yellow)',
126
- }}>
127
- {(p.score * 100).toFixed(0)}
128
- </span>
129
- </div>
130
- <div style={{ display: 'flex', gap: 6, fontSize: 11 }}>
131
- <span className="score-pill" style={{ color: 'var(--green)' }}>T:{(p.scores.taskCompletion * 10).toFixed(0)}</span>
132
- <span className="score-pill" style={{ color: 'var(--blue)' }}>A:{(p.scores.correctionAlignment * 10).toFixed(0)}</span>
133
- <span className="score-pill" style={{ color: 'var(--purple)' }}>S:{(p.scores.sideEffectFree * 10).toFixed(0)}</span>
134
- </div>
135
- <div style={{ fontSize: 11, color: 'var(--text-dim)', marginTop: 4, lineHeight: 1.4 }}>
136
- {p.changesDescription.slice(0, 80)}
118
+ <Link href="/frontier" style={{ textDecoration: 'none', color: 'inherit' }}>
119
+ <div className="card" style={{ cursor: 'pointer', height: '100%' }}>
120
+ <div className="card-body">
121
+ <div className="card-header-label">Pareto Frontier</div>
122
+ {frontier.programs.map((p, i) => (
123
+ <div key={`${p.id}-${i}`} style={{
124
+ padding: '12px 0',
125
+ borderBottom: i < frontier.programs.length - 1 ? '1px solid var(--border-subtle)' : 'none',
126
+ }}>
127
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
128
+ <span style={{ fontSize: 13, fontWeight: 600 }}>{p.id}</span>
129
+ <span style={{
130
+ fontSize: 20, fontWeight: 800, lineHeight: 1,
131
+ color: p.score >= 0.8 ? 'var(--green)' : 'var(--yellow)',
132
+ }}>
133
+ {(p.score * 100).toFixed(0)}
134
+ </span>
135
+ </div>
136
+ <div style={{ display: 'flex', gap: 6, fontSize: 11 }}>
137
+ <span className="score-pill" style={{ color: 'var(--green)' }}>T:{(p.scores.taskCompletion * 10).toFixed(0)}</span>
138
+ <span className="score-pill" style={{ color: 'var(--blue)' }}>A:{(p.scores.correctionAlignment * 10).toFixed(0)}</span>
139
+ <span className="score-pill" style={{ color: 'var(--purple)' }}>S:{(p.scores.sideEffectFree * 10).toFixed(0)}</span>
140
+ </div>
141
+ <div style={{ fontSize: 11, color: 'var(--text-dim)', marginTop: 4, lineHeight: 1.4 }}>
142
+ {p.changesDescription.slice(0, 80)}
143
+ </div>
137
144
  </div>
138
- </div>
139
- ))}
145
+ ))}
146
+ </div>
140
147
  </div>
141
- </div>
148
+ </Link>
142
149
 
143
150
  {/* Recent Evolution */}
144
- <div className="card">
145
- <div className="card-body">
146
- <div className="card-header-label">Recent Evolution</div>
147
- {recent.map((iter, idx) => (
148
- <div key={`${iter.id}-${idx}`} style={{
149
- padding: '10px 0',
150
- borderBottom: '1px solid var(--border-subtle)',
151
- }}>
152
- <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, marginBottom: 8 }}>
153
- <span style={{ fontWeight: 600, color: 'var(--text)' }}>{iter.id}</span>
154
- <span style={{ color: 'var(--text-muted)' }}>{new Date(iter.timestamp).toLocaleDateString()}</span>
155
- </div>
156
- {iter.proposals.map(p => (
157
- <div key={p.id} style={{
158
- display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, marginBottom: 4,
159
- padding: '4px 0',
160
- }}>
161
- <span style={{
162
- width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
163
- background: p.outcome === 'accepted' ? 'var(--green)' : 'var(--red)',
164
- }} />
165
- <span style={{ fontWeight: 500 }}>{p.targetSkill}</span>
166
- <span className="badge badge-gray" style={{ fontSize: 9 }}>{p.action}</span>
151
+ <Link href="/evolution" style={{ textDecoration: 'none', color: 'inherit' }}>
152
+ <div className="card" style={{ cursor: 'pointer', height: '100%' }}>
153
+ <div className="card-body">
154
+ <div className="card-header-label">Recent Evolution</div>
155
+ {recent.map((iter, idx) => (
156
+ <div key={`${iter.id}-${idx}`} style={{
157
+ padding: '10px 0',
158
+ borderBottom: '1px solid var(--border-subtle)',
159
+ }}>
160
+ <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, marginBottom: 8 }}>
161
+ <span style={{ fontWeight: 600, color: 'var(--text)' }}>{iter.id}</span>
162
+ <span style={{ color: 'var(--text-muted)' }}>{new Date(iter.timestamp).toLocaleDateString()}</span>
167
163
  </div>
168
- ))}
169
- </div>
170
- ))}
164
+ {iter.proposals.map(p => (
165
+ <div key={p.id} style={{
166
+ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, marginBottom: 4,
167
+ padding: '4px 0',
168
+ }}>
169
+ <span style={{
170
+ width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
171
+ background: p.outcome === 'accepted' ? 'var(--green)' : 'var(--red)',
172
+ }} />
173
+ <span style={{ fontWeight: 500 }}>{p.targetSkill}</span>
174
+ <span className="badge badge-gray" style={{ fontSize: 9 }}>{p.action}</span>
175
+ </div>
176
+ ))}
177
+ </div>
178
+ ))}
179
+ </div>
171
180
  </div>
172
- </div>
181
+ </Link>
173
182
  </div>
174
183
 
175
184
  {/* All Skills Summary */}
176
- <div className="card" style={{ marginTop: 16 }}>
177
- <div className="card-body">
178
- <div className="card-header-label">All Skills</div>
179
- <div className="grid-2" style={{ gap: 6 }}>
180
- {graph.nodes.sort((a, b) => b.score - a.score).map(n => (
181
- <div key={n.id} style={{
182
- display: 'flex', alignItems: 'center', justifyContent: 'space-between',
183
- padding: '8px 12px', borderRadius: 8, background: 'var(--bg-section)',
184
- }}>
185
- <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
186
- <span style={{ fontSize: 13, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{n.name}</span>
187
- {n.generation > 0 && <span className="badge badge-green" style={{ fontSize: 9 }}>gen {n.generation}</span>}
188
- </div>
189
- <div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 90, flexShrink: 0 }}>
190
- <div className="score-track" style={{ width: 50 }}>
191
- <div className="score-fill" style={{
192
- width: `${n.score * 100}%`,
193
- background: n.score >= 0.8 ? 'var(--green)' : n.score >= 0.6 ? 'var(--yellow)' : 'var(--red)',
194
- }} />
185
+ <Link href="/network" style={{ textDecoration: 'none', color: 'inherit' }}>
186
+ <div className="card" style={{ marginTop: 16, cursor: 'pointer' }}>
187
+ <div className="card-body">
188
+ <div className="card-header-label">All Skills</div>
189
+ <div className="grid-2" style={{ gap: 6 }}>
190
+ {graph.nodes.sort((a, b) => b.score - a.score).map(n => (
191
+ <div key={n.id} style={{
192
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
193
+ padding: '8px 12px', borderRadius: 8, background: 'var(--bg-section)',
194
+ }}>
195
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
196
+ <span style={{ fontSize: 13, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{n.name}</span>
197
+ {n.generation > 0 && <span className="badge badge-green" style={{ fontSize: 9 }}>gen {n.generation}</span>}
198
+ </div>
199
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 90, flexShrink: 0 }}>
200
+ <div className="score-track" style={{ width: 50 }}>
201
+ <div className="score-fill" style={{
202
+ width: `${n.score * 100}%`,
203
+ background: n.score >= 0.8 ? 'var(--green)' : n.score >= 0.6 ? 'var(--yellow)' : 'var(--red)',
204
+ }} />
205
+ </div>
206
+ <span style={{ fontSize: 12, fontWeight: 600, color: n.score >= 0.8 ? 'var(--green)' : 'var(--yellow)' }}>
207
+ {(n.score * 100).toFixed(0)}
208
+ </span>
195
209
  </div>
196
- <span style={{ fontSize: 12, fontWeight: 600, color: n.score >= 0.8 ? 'var(--green)' : 'var(--yellow)' }}>
197
- {(n.score * 100).toFixed(0)}
198
- </span>
199
210
  </div>
200
- </div>
201
- ))}
211
+ ))}
212
+ </div>
202
213
  </div>
203
214
  </div>
204
- </div>
215
+ </Link>
205
216
  </div>
206
217
  )
207
218
  }
@@ -59,12 +59,13 @@ const PIPELINE_STEPS = [
59
59
 
60
60
  export default function ResearchClient({ buffer, projects }: Props) {
61
61
  const [runState, setRunState] = useState<RunState>('idle')
62
- const [output, setOutput] = useState<string | null>(null)
62
+ const [output, setOutput] = useState<string>('')
63
63
  const abortRef = useRef<AbortController | null>(null)
64
+ const outputRef = useRef<HTMLPreElement | null>(null)
64
65
 
65
66
  const handleRun = async () => {
66
67
  setRunState('running')
67
- setOutput(null)
68
+ setOutput('')
68
69
  const controller = new AbortController()
69
70
  abortRef.current = controller
70
71
 
@@ -75,21 +76,58 @@ export default function ResearchClient({ buffer, projects }: Props) {
75
76
  body: JSON.stringify({ command: 'research' }),
76
77
  signal: controller.signal,
77
78
  })
78
- const data = await res.json()
79
- if (data.stopped) {
80
- setOutput(data.output ?? 'Stopped')
81
- setRunState('stopped')
82
- } else {
83
- setOutput(data.output ?? data.error ?? 'No output')
84
- setRunState(data.success ? 'success' : 'error')
79
+
80
+ if (!res.body) {
81
+ setOutput('No response body')
82
+ setRunState('error')
83
+ return
84
+ }
85
+
86
+ const reader = res.body.getReader()
87
+ const decoder = new TextDecoder()
88
+ let sseBuffer = ''
89
+
90
+ while (true) {
91
+ const { done, value } = await reader.read()
92
+ if (done) break
93
+
94
+ sseBuffer += decoder.decode(value, { stream: true })
95
+ const events = sseBuffer.split('\n\n')
96
+ sseBuffer = events.pop() ?? ''
97
+
98
+ for (const event of events) {
99
+ const lines = event.split('\n')
100
+ let eventType = ''
101
+ let eventData = ''
102
+ for (const line of lines) {
103
+ if (line.startsWith('event: ')) eventType = line.slice(7)
104
+ if (line.startsWith('data: ')) eventData = line.slice(6)
105
+ }
106
+ if (eventType === 'output' && eventData) {
107
+ try {
108
+ const text = JSON.parse(eventData) as string
109
+ setOutput(prev => prev + text)
110
+ setTimeout(() => {
111
+ if (outputRef.current) outputRef.current.scrollTop = outputRef.current.scrollHeight
112
+ }, 10)
113
+ } catch {}
114
+ }
115
+ if (eventType === 'done' && eventData) {
116
+ try {
117
+ const result = JSON.parse(JSON.parse(eventData) as string) as { success: boolean; stopped?: boolean }
118
+ setRunState(result.stopped ? 'stopped' : result.success ? 'success' : 'error')
119
+ } catch {}
120
+ }
121
+ }
85
122
  }
123
+ if (runState === 'running') setRunState('success')
86
124
  } catch (err: unknown) {
87
125
  if (err instanceof Error && err.name === 'AbortError') {
88
126
  try { await fetch('/api/run', { method: 'DELETE' }) } catch {}
89
- setOutput('Stopped by user')
127
+ setOutput(prev => prev + '\n\n[Stopped by user]')
90
128
  setRunState('stopped')
91
129
  } else {
92
- setOutput('Network error')
130
+ setOutput(prev => prev || 'Network error')
93
131
  setRunState('error')
94
132
  }
95
133
  } finally {
@@ -220,29 +258,29 @@ export default function ResearchClient({ buffer, projects }: Props) {
220
258
  </div>
221
259
 
222
260
  {/* Output panel */}
223
- {runState !== 'idle' && output && (
261
+ {runState !== 'idle' && (
224
262
  <div className="card" style={{ marginBottom: 24, overflow: 'hidden' }}>
225
263
  <div style={{
226
264
  padding: '10px 16px',
227
- background: runState === 'success' ? 'var(--green-light)' : runState === 'error' ? 'var(--red-light)' : 'var(--bg-section)',
265
+ background: runState === 'success' ? 'var(--green-light)' : runState === 'error' ? 'var(--red-light)' : runState === 'stopped' ? 'var(--yellow-light)' : 'var(--bg-section)',
228
266
  borderBottom: '1px solid var(--border)',
229
267
  fontSize: 12, fontWeight: 600,
230
- color: runState === 'success' ? 'var(--green)' : runState === 'error' ? 'var(--red)' : 'var(--text-secondary)',
268
+ color: runState === 'success' ? 'var(--green)' : runState === 'error' ? 'var(--red)' : runState === 'stopped' ? 'var(--yellow)' : 'var(--text-secondary)',
231
269
  }}>
232
270
  {runState === 'running' && '● Running research pipeline...'}
233
271
  {runState === 'success' && '✓ Research completed'}
234
272
  {runState === 'error' && '✗ Research failed'}
235
273
  {runState === 'stopped' && '■ Research stopped'}
236
274
  </div>
237
- <pre style={{
275
+ <pre ref={outputRef} style={{
238
276
  padding: '14px 16px', margin: 0,
239
277
  fontSize: 11, lineHeight: 1.55,
240
278
  fontFamily: 'var(--font-mono)',
241
279
  color: 'var(--text-secondary)',
242
- maxHeight: 300, overflow: 'auto',
280
+ maxHeight: 400, overflow: 'auto',
243
281
  whiteSpace: 'pre-wrap', wordBreak: 'break-word',
244
282
  }}>
245
- {output}
283
+ {output || 'Starting research pipeline...'}
246
284
  </pre>
247
285
  </div>
248
286
  )}
@@ -14,11 +14,11 @@ interface OverviewActionsProps {
14
14
  // Small inline SVG flow diagrams for each action
15
15
  function FlowDiagram({ steps, color }: { steps: string[]; color: string }) {
16
16
  return (
17
- <div style={{ display: 'flex', alignItems: 'center', gap: 0, marginTop: 8, marginBottom: 2 }}>
17
+ <div style={{ display: 'flex', alignItems: 'center', gap: 0, marginTop: 8, marginBottom: 2, flexWrap: 'wrap', rowGap: 4 }}>
18
18
  {steps.map((step, i) => (
19
19
  <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 0 }}>
20
20
  <div style={{
21
- fontSize: 9, fontWeight: 600, padding: '2px 7px',
21
+ fontSize: 9, fontWeight: 600, padding: '2px 6px',
22
22
  background: i === steps.length - 1 ? `${color}18` : 'var(--bg-section)',
23
23
  color: i === steps.length - 1 ? color : 'var(--text-dim)',
24
24
  borderRadius: 4, whiteSpace: 'nowrap',
@@ -27,9 +27,7 @@ function FlowDiagram({ steps, color }: { steps: string[]; color: string }) {
27
27
  {step}
28
28
  </div>
29
29
  {i < steps.length - 1 && (
30
- <svg width="14" height="8" viewBox="0 0 14 8" style={{ flexShrink: 0 }}>
31
- <path d="M0 4h10M8 1l3 3-3 3" stroke={color} strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round" opacity="0.5" />
32
- </svg>
30
+ <span style={{ fontSize: 9, color: 'var(--text-muted)', padding: '0 2px' }}>&rarr;</span>
33
31
  )}
34
32
  </div>
35
33
  ))}
@@ -25,14 +25,15 @@ type RunState = 'idle' | 'running' | 'success' | 'error' | 'stopped'
25
25
  export function QuickActions({ actions, flowDiagram: FlowDiagram }: QuickActionsProps) {
26
26
  const [running, setRunning] = useState<string | null>(null)
27
27
  const [state, setState] = useState<RunState>('idle')
28
- const [output, setOutput] = useState<string | null>(null)
28
+ const [output, setOutput] = useState<string>('')
29
29
  const abortRef = useRef<AbortController | null>(null)
30
+ const outputRef = useRef<HTMLPreElement | null>(null)
30
31
 
31
32
  const handleRun = async (action: Action) => {
32
33
  if (action.disabled) return
33
34
  setRunning(action.id)
34
35
  setState('running')
35
- setOutput(null)
36
+ setOutput('')
36
37
 
37
38
  const controller = new AbortController()
38
39
  abortRef.current = controller
@@ -44,21 +45,76 @@ export function QuickActions({ actions, flowDiagram: FlowDiagram }: QuickActions
44
45
  body: JSON.stringify({ command: action.command }),
45
46
  signal: controller.signal,
46
47
  })
47
- const data = await res.json()
48
- if (data.stopped) {
49
- setOutput(data.output ?? 'Stopped')
50
- setState('stopped')
51
- } else {
52
- setOutput(data.output ?? data.error ?? 'No output')
53
- setState(data.success ? 'success' : 'error')
48
+
49
+ if (!res.body) {
50
+ setOutput('No response body')
51
+ setState('error')
52
+ return
53
+ }
54
+
55
+ const reader = res.body.getReader()
56
+ const decoder = new TextDecoder()
57
+ let buffer = ''
58
+
59
+ while (true) {
60
+ const { done, value } = await reader.read()
61
+ if (done) break
62
+
63
+ buffer += decoder.decode(value, { stream: true })
64
+
65
+ // Parse SSE events from buffer
66
+ const events = buffer.split('\n\n')
67
+ buffer = events.pop() ?? '' // keep incomplete event
68
+
69
+ for (const event of events) {
70
+ const lines = event.split('\n')
71
+ let eventType = ''
72
+ let eventData = ''
73
+
74
+ for (const line of lines) {
75
+ if (line.startsWith('event: ')) eventType = line.slice(7)
76
+ if (line.startsWith('data: ')) eventData = line.slice(6)
77
+ }
78
+
79
+ if (eventType === 'output' && eventData) {
80
+ try {
81
+ const text = JSON.parse(eventData) as string
82
+ setOutput(prev => prev + text)
83
+ // Auto-scroll to bottom
84
+ setTimeout(() => {
85
+ if (outputRef.current) {
86
+ outputRef.current.scrollTop = outputRef.current.scrollHeight
87
+ }
88
+ }, 10)
89
+ } catch {}
90
+ }
91
+
92
+ if (eventType === 'done' && eventData) {
93
+ try {
94
+ const result = JSON.parse(JSON.parse(eventData) as string) as { success: boolean; stopped?: boolean; timedOut?: boolean }
95
+ if (result.stopped) {
96
+ setOutput(prev => prev + '\n\n[Stopped by user]')
97
+ setState('stopped')
98
+ } else if (result.timedOut) {
99
+ setOutput(prev => prev + '\n\n[Timed out]')
100
+ setState('error')
101
+ } else {
102
+ setState(result.success ? 'success' : 'error')
103
+ }
104
+ } catch {}
105
+ }
106
+ }
54
107
  }
108
+
109
+ // If we reach here without a 'done' event, mark as complete
110
+ if (state === 'running') setState('success')
55
111
  } catch (err: unknown) {
56
112
  if (err instanceof Error && err.name === 'AbortError') {
57
113
  try { await fetch('/api/run', { method: 'DELETE' }) } catch {}
58
- setOutput('Stopped by user')
114
+ setOutput(prev => prev + '\n\n[Stopped by user]')
59
115
  setState('stopped')
60
116
  } else {
61
- setOutput('Network error')
117
+ setOutput(prev => prev || 'Network error')
62
118
  setState('error')
63
119
  }
64
120
  } finally {
@@ -227,7 +283,7 @@ export function QuickActions({ actions, flowDiagram: FlowDiagram }: QuickActions
227
283
  </div>
228
284
  </div>
229
285
 
230
- <pre style={{
286
+ <pre ref={outputRef} style={{
231
287
  padding: '12px 14px', margin: 0,
232
288
  fontSize: 11, lineHeight: 1.5,
233
289
  fontFamily: 'var(--font-mono)',
@@ -235,7 +291,7 @@ export function QuickActions({ actions, flowDiagram: FlowDiagram }: QuickActions
235
291
  maxHeight: 400, overflow: 'auto',
236
292
  whiteSpace: 'pre-wrap', wordBreak: 'break-word',
237
293
  }}>
238
- {output ?? 'Waiting for output...'}
294
+ {output || (state === 'running' ? 'Starting...' : 'No output')}
239
295
  </pre>
240
296
  </div>
241
297
  )}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helixevo",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
4
4
  "description": "Self-evolving skill ecosystem for AI agents. Skills and projects co-evolve through multi-judge evaluation and a Pareto frontier.",
5
5
  "type": "module",
6
6
  "bin": {