lockstep-mcp 0.1.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/LICENSE +21 -0
- package/README.md +669 -0
- package/dist/cli.js +367 -0
- package/dist/config.js +48 -0
- package/dist/dashboard.js +1982 -0
- package/dist/install.js +252 -0
- package/dist/macos.js +55 -0
- package/dist/prompts.js +173 -0
- package/dist/server.js +1942 -0
- package/dist/storage.js +1235 -0
- package/dist/tmux.js +87 -0
- package/dist/utils.js +35 -0
- package/dist/worktree.js +356 -0
- package/package.json +66 -0
|
@@ -0,0 +1,1982 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import url from "node:url";
|
|
3
|
+
import { exec } from "node:child_process";
|
|
4
|
+
import { WebSocketServer } from "ws";
|
|
5
|
+
import { loadConfig } from "./config.js";
|
|
6
|
+
import { createStore } from "./storage.js";
|
|
7
|
+
// Focus a terminal window by name using AppleScript (macOS)
|
|
8
|
+
function focusTerminalWindow(windowName) {
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
if (process.platform !== "darwin") {
|
|
11
|
+
resolve({ success: false, error: "Focus only supported on macOS" });
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
// Try to find and focus a Terminal window that contains the implementer name
|
|
15
|
+
const script = `
|
|
16
|
+
tell application "Terminal"
|
|
17
|
+
set windowList to every window
|
|
18
|
+
repeat with w in windowList
|
|
19
|
+
try
|
|
20
|
+
set tabList to every tab of w
|
|
21
|
+
repeat with t in tabList
|
|
22
|
+
if custom title of t contains "${windowName}" then
|
|
23
|
+
set frontmost of w to true
|
|
24
|
+
activate
|
|
25
|
+
return "found"
|
|
26
|
+
end if
|
|
27
|
+
end repeat
|
|
28
|
+
end try
|
|
29
|
+
end repeat
|
|
30
|
+
end tell
|
|
31
|
+
return "not found"
|
|
32
|
+
`;
|
|
33
|
+
exec(`osascript -e '${script.replace(/'/g, "'\"'\"'")}'`, (error, stdout) => {
|
|
34
|
+
if (error) {
|
|
35
|
+
resolve({ success: false, error: error.message });
|
|
36
|
+
}
|
|
37
|
+
else if (stdout.trim() === "not found") {
|
|
38
|
+
resolve({ success: false, error: "Terminal window not found" });
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
resolve({ success: true });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// Check if a process is still running by PID
|
|
47
|
+
function isProcessRunning(pid) {
|
|
48
|
+
try {
|
|
49
|
+
process.kill(pid, 0); // Signal 0 doesn't kill, just checks if process exists
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Clean up implementers whose processes have died
|
|
57
|
+
async function cleanupDeadImplementers(store, implementers) {
|
|
58
|
+
const results = [];
|
|
59
|
+
for (const impl of implementers) {
|
|
60
|
+
if (impl.status === "active" && impl.pid) {
|
|
61
|
+
if (!isProcessRunning(impl.pid)) {
|
|
62
|
+
// Process is dead, mark as stopped
|
|
63
|
+
console.log(`Implementer ${impl.name} (PID ${impl.pid}) is dead, marking as stopped`);
|
|
64
|
+
const updated = await store.updateImplementer(impl.id, "stopped");
|
|
65
|
+
results.push(updated);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
results.push(impl);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
results.push(impl);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return results;
|
|
76
|
+
}
|
|
77
|
+
const DASHBOARD_HTML = `<!doctype html>
|
|
78
|
+
<html lang="en">
|
|
79
|
+
<head>
|
|
80
|
+
<meta charset="utf-8" />
|
|
81
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
82
|
+
<title>Lockstep MCP</title>
|
|
83
|
+
<style>
|
|
84
|
+
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
|
|
85
|
+
|
|
86
|
+
:root {
|
|
87
|
+
/* Palantir Blueprint Dark Theme */
|
|
88
|
+
--bg-base: #111418;
|
|
89
|
+
--bg-elevated: #1C2127;
|
|
90
|
+
--bg-card: #252A31;
|
|
91
|
+
--bg-hover: #2F343C;
|
|
92
|
+
--border: #383E47;
|
|
93
|
+
--border-light: #404854;
|
|
94
|
+
|
|
95
|
+
/* Text */
|
|
96
|
+
--text-primary: #F6F7F9;
|
|
97
|
+
--text-secondary: #ABB3BF;
|
|
98
|
+
--text-muted: #738091;
|
|
99
|
+
|
|
100
|
+
/* Accent Colors */
|
|
101
|
+
--blue: #4C90F0;
|
|
102
|
+
--blue-dim: #2D72D2;
|
|
103
|
+
--blue-glow: rgba(76, 144, 240, 0.15);
|
|
104
|
+
--green: #32A467;
|
|
105
|
+
--green-dim: #238551;
|
|
106
|
+
--green-glow: rgba(50, 164, 103, 0.15);
|
|
107
|
+
--orange: #EC9A3C;
|
|
108
|
+
--orange-glow: rgba(236, 154, 60, 0.15);
|
|
109
|
+
--red: #E76A6E;
|
|
110
|
+
--red-glow: rgba(231, 106, 110, 0.15);
|
|
111
|
+
--violet: #9D7FEA;
|
|
112
|
+
--violet-glow: rgba(157, 127, 234, 0.15);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
116
|
+
|
|
117
|
+
body {
|
|
118
|
+
font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
|
|
119
|
+
color: var(--text-primary);
|
|
120
|
+
background: var(--bg-base);
|
|
121
|
+
min-height: 100vh;
|
|
122
|
+
line-height: 1.5;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* Header */
|
|
126
|
+
header {
|
|
127
|
+
padding: 24px 32px;
|
|
128
|
+
border-bottom: 1px solid var(--border);
|
|
129
|
+
display: flex;
|
|
130
|
+
align-items: center;
|
|
131
|
+
justify-content: space-between;
|
|
132
|
+
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-base) 100%);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
h1 {
|
|
136
|
+
font-size: 20px;
|
|
137
|
+
font-weight: 600;
|
|
138
|
+
letter-spacing: -0.02em;
|
|
139
|
+
background: linear-gradient(135deg, var(--text-primary) 0%, var(--blue) 100%);
|
|
140
|
+
-webkit-background-clip: text;
|
|
141
|
+
-webkit-text-fill-color: transparent;
|
|
142
|
+
background-clip: text;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.status-badge {
|
|
146
|
+
display: flex;
|
|
147
|
+
align-items: center;
|
|
148
|
+
gap: 8px;
|
|
149
|
+
padding: 8px 14px;
|
|
150
|
+
background: var(--bg-card);
|
|
151
|
+
border: 1px solid var(--border);
|
|
152
|
+
border-radius: 20px;
|
|
153
|
+
font-size: 13px;
|
|
154
|
+
color: var(--text-secondary);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.status-badge.connected {
|
|
158
|
+
border-color: var(--green-dim);
|
|
159
|
+
background: var(--green-glow);
|
|
160
|
+
color: var(--green);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.status-dot {
|
|
164
|
+
width: 8px;
|
|
165
|
+
height: 8px;
|
|
166
|
+
border-radius: 50%;
|
|
167
|
+
background: var(--orange);
|
|
168
|
+
box-shadow: 0 0 8px var(--orange);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.status-badge.connected .status-dot {
|
|
172
|
+
background: var(--green);
|
|
173
|
+
box-shadow: 0 0 8px var(--green);
|
|
174
|
+
animation: pulse 2s ease-in-out infinite;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
@keyframes pulse {
|
|
178
|
+
0%, 100% { opacity: 1; }
|
|
179
|
+
50% { opacity: 0.5; }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* Stats Grid */
|
|
183
|
+
.stats {
|
|
184
|
+
display: grid;
|
|
185
|
+
grid-template-columns: repeat(4, 1fr);
|
|
186
|
+
gap: 16px;
|
|
187
|
+
padding: 24px 32px;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.stat {
|
|
191
|
+
background: var(--bg-elevated);
|
|
192
|
+
border: 1px solid var(--border);
|
|
193
|
+
border-radius: 12px;
|
|
194
|
+
padding: 20px;
|
|
195
|
+
transition: all 0.2s ease;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.stat:hover {
|
|
199
|
+
border-color: var(--border-light);
|
|
200
|
+
background: var(--bg-card);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.stat .label {
|
|
204
|
+
color: var(--text-muted);
|
|
205
|
+
font-size: 11px;
|
|
206
|
+
font-weight: 500;
|
|
207
|
+
text-transform: uppercase;
|
|
208
|
+
letter-spacing: 0.1em;
|
|
209
|
+
margin-bottom: 8px;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.stat .value {
|
|
213
|
+
font-size: 32px;
|
|
214
|
+
font-weight: 700;
|
|
215
|
+
letter-spacing: -0.02em;
|
|
216
|
+
background: linear-gradient(135deg, var(--text-primary) 0%, var(--text-secondary) 100%);
|
|
217
|
+
-webkit-background-clip: text;
|
|
218
|
+
-webkit-text-fill-color: transparent;
|
|
219
|
+
background-clip: text;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.stat .value.status-in_progress { color: var(--blue); -webkit-text-fill-color: var(--blue); }
|
|
223
|
+
.stat .value.status-complete { color: var(--green); -webkit-text-fill-color: var(--green); }
|
|
224
|
+
.stat .value.status-stopped { color: var(--red); -webkit-text-fill-color: var(--red); }
|
|
225
|
+
|
|
226
|
+
/* Main Grid */
|
|
227
|
+
.grid {
|
|
228
|
+
display: grid;
|
|
229
|
+
grid-template-columns: 1fr 1fr 1fr;
|
|
230
|
+
gap: 16px;
|
|
231
|
+
padding: 0 32px 32px;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.panel {
|
|
235
|
+
background: var(--bg-elevated);
|
|
236
|
+
border: 1px solid var(--border);
|
|
237
|
+
border-radius: 12px;
|
|
238
|
+
display: flex;
|
|
239
|
+
flex-direction: column;
|
|
240
|
+
overflow: hidden;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.panel.wide { grid-column: span 2; }
|
|
244
|
+
.panel.full { grid-column: span 3; }
|
|
245
|
+
|
|
246
|
+
.panel-header {
|
|
247
|
+
padding: 16px 20px;
|
|
248
|
+
border-bottom: 1px solid var(--border);
|
|
249
|
+
display: flex;
|
|
250
|
+
align-items: center;
|
|
251
|
+
justify-content: space-between;
|
|
252
|
+
background: var(--bg-card);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.panel-header h2 {
|
|
256
|
+
font-size: 14px;
|
|
257
|
+
font-weight: 600;
|
|
258
|
+
color: var(--text-primary);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.pill {
|
|
262
|
+
display: inline-flex;
|
|
263
|
+
align-items: center;
|
|
264
|
+
gap: 4px;
|
|
265
|
+
padding: 4px 10px;
|
|
266
|
+
border-radius: 12px;
|
|
267
|
+
font-size: 11px;
|
|
268
|
+
font-weight: 500;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.pill.blue { background: var(--blue-glow); color: var(--blue); }
|
|
272
|
+
.pill.green { background: var(--green-glow); color: var(--green); }
|
|
273
|
+
.pill.orange { background: var(--orange-glow); color: var(--orange); }
|
|
274
|
+
|
|
275
|
+
.list {
|
|
276
|
+
padding: 12px;
|
|
277
|
+
display: flex;
|
|
278
|
+
flex-direction: column;
|
|
279
|
+
gap: 8px;
|
|
280
|
+
max-height: 400px;
|
|
281
|
+
overflow-y: auto;
|
|
282
|
+
flex: 1;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.list::-webkit-scrollbar { width: 6px; }
|
|
286
|
+
.list::-webkit-scrollbar-track { background: transparent; }
|
|
287
|
+
.list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
288
|
+
|
|
289
|
+
/* Cards */
|
|
290
|
+
.card {
|
|
291
|
+
background: var(--bg-card);
|
|
292
|
+
border: 1px solid var(--border);
|
|
293
|
+
border-radius: 8px;
|
|
294
|
+
padding: 14px;
|
|
295
|
+
transition: all 0.15s ease;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.card:hover {
|
|
299
|
+
border-color: var(--border-light);
|
|
300
|
+
transform: translateY(-1px);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.card-title {
|
|
304
|
+
font-weight: 600;
|
|
305
|
+
font-size: 13px;
|
|
306
|
+
color: var(--text-primary);
|
|
307
|
+
margin-bottom: 6px;
|
|
308
|
+
display: flex;
|
|
309
|
+
align-items: center;
|
|
310
|
+
gap: 8px;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.card-desc {
|
|
314
|
+
font-size: 12px;
|
|
315
|
+
color: var(--text-muted);
|
|
316
|
+
margin-bottom: 10px;
|
|
317
|
+
display: -webkit-box;
|
|
318
|
+
-webkit-line-clamp: 2;
|
|
319
|
+
-webkit-box-orient: vertical;
|
|
320
|
+
overflow: hidden;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.card-meta {
|
|
324
|
+
display: flex;
|
|
325
|
+
flex-wrap: wrap;
|
|
326
|
+
gap: 6px;
|
|
327
|
+
align-items: center;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.tag {
|
|
331
|
+
padding: 3px 8px;
|
|
332
|
+
border-radius: 4px;
|
|
333
|
+
font-size: 10px;
|
|
334
|
+
font-weight: 500;
|
|
335
|
+
font-family: "JetBrains Mono", monospace;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.tag.todo { background: var(--bg-hover); color: var(--text-muted); }
|
|
339
|
+
.tag.in_progress { background: var(--blue-glow); color: var(--blue); }
|
|
340
|
+
.tag.review { background: var(--violet-glow); color: var(--violet); }
|
|
341
|
+
.tag.done { background: var(--green-glow); color: var(--green); }
|
|
342
|
+
.tag.active { background: var(--green-glow); color: var(--green); }
|
|
343
|
+
.tag.stopped { background: var(--red-glow); color: var(--red); }
|
|
344
|
+
.tag.terminated { background: var(--red-glow); color: var(--red); }
|
|
345
|
+
.tag.worktree { background: var(--violet-glow); color: var(--violet); }
|
|
346
|
+
.tag.shared { background: var(--bg-hover); color: var(--text-muted); }
|
|
347
|
+
.tag.branch { background: var(--violet-glow); color: var(--violet); font-size: 9px; }
|
|
348
|
+
|
|
349
|
+
/* Implementer task summary */
|
|
350
|
+
.impl-tasks {
|
|
351
|
+
margin-top: 10px;
|
|
352
|
+
padding-top: 10px;
|
|
353
|
+
border-top: 1px solid var(--border);
|
|
354
|
+
}
|
|
355
|
+
.impl-task {
|
|
356
|
+
display: flex;
|
|
357
|
+
align-items: center;
|
|
358
|
+
gap: 6px;
|
|
359
|
+
font-size: 11px;
|
|
360
|
+
margin-bottom: 4px;
|
|
361
|
+
}
|
|
362
|
+
.impl-task:last-child { margin-bottom: 0; }
|
|
363
|
+
.impl-task .task-title {
|
|
364
|
+
color: var(--text-secondary);
|
|
365
|
+
overflow: hidden;
|
|
366
|
+
text-overflow: ellipsis;
|
|
367
|
+
white-space: nowrap;
|
|
368
|
+
flex: 1;
|
|
369
|
+
}
|
|
370
|
+
.impl-task-summary {
|
|
371
|
+
font-size: 10px;
|
|
372
|
+
color: var(--text-muted);
|
|
373
|
+
margin-top: 4px;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/* Clickable implementer cards */
|
|
377
|
+
.card.clickable {
|
|
378
|
+
cursor: pointer;
|
|
379
|
+
position: relative;
|
|
380
|
+
}
|
|
381
|
+
.card.clickable:hover {
|
|
382
|
+
border-color: var(--blue);
|
|
383
|
+
background: var(--bg-hover);
|
|
384
|
+
}
|
|
385
|
+
.card.clickable::after {
|
|
386
|
+
content: "Click to focus";
|
|
387
|
+
position: absolute;
|
|
388
|
+
right: 10px;
|
|
389
|
+
top: 50%;
|
|
390
|
+
transform: translateY(-50%);
|
|
391
|
+
font-size: 10px;
|
|
392
|
+
color: var(--text-muted);
|
|
393
|
+
opacity: 0;
|
|
394
|
+
transition: opacity 0.15s ease;
|
|
395
|
+
}
|
|
396
|
+
.card.clickable:hover::after {
|
|
397
|
+
opacity: 1;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
.mono {
|
|
401
|
+
font-family: "JetBrains Mono", monospace;
|
|
402
|
+
font-size: 11px;
|
|
403
|
+
color: var(--text-muted);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
.empty {
|
|
407
|
+
color: var(--text-muted);
|
|
408
|
+
font-size: 13px;
|
|
409
|
+
text-align: center;
|
|
410
|
+
padding: 32px 16px;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/* Context Panel */
|
|
414
|
+
.context-content {
|
|
415
|
+
padding: 16px 20px;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.context-item {
|
|
419
|
+
margin-bottom: 12px;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
.context-label {
|
|
423
|
+
font-size: 10px;
|
|
424
|
+
font-weight: 600;
|
|
425
|
+
text-transform: uppercase;
|
|
426
|
+
letter-spacing: 0.1em;
|
|
427
|
+
color: var(--text-muted);
|
|
428
|
+
margin-bottom: 4px;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
.context-value {
|
|
432
|
+
font-size: 13px;
|
|
433
|
+
color: var(--text-secondary);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/* Note Cards */
|
|
437
|
+
.note-card {
|
|
438
|
+
background: var(--bg-card);
|
|
439
|
+
border-left: 3px solid var(--blue);
|
|
440
|
+
border-radius: 0 8px 8px 0;
|
|
441
|
+
padding: 12px 14px;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
.note-card.system {
|
|
445
|
+
border-left-color: var(--violet);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
.note-author {
|
|
449
|
+
font-size: 12px;
|
|
450
|
+
font-weight: 600;
|
|
451
|
+
color: var(--blue);
|
|
452
|
+
margin-bottom: 4px;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.note-card.system .note-author {
|
|
456
|
+
color: var(--violet);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
.note-text {
|
|
460
|
+
font-size: 12px;
|
|
461
|
+
color: var(--text-secondary);
|
|
462
|
+
line-height: 1.5;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
.note-time {
|
|
466
|
+
font-size: 10px;
|
|
467
|
+
color: var(--text-muted);
|
|
468
|
+
margin-top: 6px;
|
|
469
|
+
font-family: "JetBrains Mono", monospace;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/* Footer */
|
|
473
|
+
footer {
|
|
474
|
+
padding: 16px 32px;
|
|
475
|
+
border-top: 1px solid var(--border);
|
|
476
|
+
color: var(--text-muted);
|
|
477
|
+
font-size: 11px;
|
|
478
|
+
display: flex;
|
|
479
|
+
justify-content: space-between;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
@media (max-width: 1024px) {
|
|
483
|
+
.stats { grid-template-columns: repeat(2, 1fr); }
|
|
484
|
+
.grid { grid-template-columns: 1fr; }
|
|
485
|
+
.panel.wide, .panel.full { grid-column: span 1; }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/* Progress Bar */
|
|
489
|
+
.progress-section {
|
|
490
|
+
padding: 0 32px 16px;
|
|
491
|
+
display: flex;
|
|
492
|
+
align-items: center;
|
|
493
|
+
gap: 16px;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
.progress-bar-container {
|
|
497
|
+
flex: 1;
|
|
498
|
+
height: 8px;
|
|
499
|
+
background: var(--bg-card);
|
|
500
|
+
border-radius: 4px;
|
|
501
|
+
overflow: hidden;
|
|
502
|
+
border: 1px solid var(--border);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
.progress-bar {
|
|
506
|
+
height: 100%;
|
|
507
|
+
background: linear-gradient(90deg, var(--green-dim) 0%, var(--green) 100%);
|
|
508
|
+
border-radius: 4px;
|
|
509
|
+
transition: width 0.3s ease;
|
|
510
|
+
width: 0%;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
.progress-text {
|
|
514
|
+
font-size: 12px;
|
|
515
|
+
font-weight: 500;
|
|
516
|
+
color: var(--text-secondary);
|
|
517
|
+
min-width: 100px;
|
|
518
|
+
text-align: right;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/* Activity Feed */
|
|
522
|
+
.activity-item {
|
|
523
|
+
display: flex;
|
|
524
|
+
align-items: flex-start;
|
|
525
|
+
gap: 10px;
|
|
526
|
+
padding: 10px 12px;
|
|
527
|
+
background: var(--bg-card);
|
|
528
|
+
border-radius: 6px;
|
|
529
|
+
border-left: 3px solid var(--border);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.activity-item.task-claimed { border-left-color: var(--blue); }
|
|
533
|
+
.activity-item.task-completed { border-left-color: var(--green); }
|
|
534
|
+
.activity-item.task-review { border-left-color: var(--violet); }
|
|
535
|
+
.activity-item.lock-acquired { border-left-color: var(--orange); }
|
|
536
|
+
.activity-item.lock-released { border-left-color: var(--text-muted); }
|
|
537
|
+
|
|
538
|
+
.activity-icon {
|
|
539
|
+
width: 20px;
|
|
540
|
+
height: 20px;
|
|
541
|
+
border-radius: 50%;
|
|
542
|
+
display: flex;
|
|
543
|
+
align-items: center;
|
|
544
|
+
justify-content: center;
|
|
545
|
+
font-size: 10px;
|
|
546
|
+
flex-shrink: 0;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
.activity-icon.claim { background: var(--blue-glow); color: var(--blue); }
|
|
550
|
+
.activity-icon.complete { background: var(--green-glow); color: var(--green); }
|
|
551
|
+
.activity-icon.review { background: var(--violet-glow); color: var(--violet); }
|
|
552
|
+
.activity-icon.lock { background: var(--orange-glow); color: var(--orange); }
|
|
553
|
+
|
|
554
|
+
.activity-content {
|
|
555
|
+
flex: 1;
|
|
556
|
+
min-width: 0;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
.activity-text {
|
|
560
|
+
font-size: 12px;
|
|
561
|
+
color: var(--text-secondary);
|
|
562
|
+
line-height: 1.4;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
.activity-text strong {
|
|
566
|
+
color: var(--text-primary);
|
|
567
|
+
font-weight: 500;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
.activity-time {
|
|
571
|
+
font-size: 10px;
|
|
572
|
+
color: var(--text-muted);
|
|
573
|
+
font-family: "JetBrains Mono", monospace;
|
|
574
|
+
margin-top: 2px;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/* Filter Bar */
|
|
578
|
+
.filter-bar {
|
|
579
|
+
display: flex;
|
|
580
|
+
gap: 8px;
|
|
581
|
+
padding: 12px;
|
|
582
|
+
border-bottom: 1px solid var(--border);
|
|
583
|
+
flex-wrap: wrap;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
.filter-btn {
|
|
587
|
+
padding: 6px 12px;
|
|
588
|
+
border-radius: 6px;
|
|
589
|
+
font-size: 11px;
|
|
590
|
+
font-weight: 500;
|
|
591
|
+
background: var(--bg-card);
|
|
592
|
+
border: 1px solid var(--border);
|
|
593
|
+
color: var(--text-secondary);
|
|
594
|
+
cursor: pointer;
|
|
595
|
+
transition: all 0.15s ease;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
.filter-btn:hover {
|
|
599
|
+
border-color: var(--border-light);
|
|
600
|
+
color: var(--text-primary);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
.filter-btn.active {
|
|
604
|
+
background: var(--blue-glow);
|
|
605
|
+
border-color: var(--blue);
|
|
606
|
+
color: var(--blue);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
.filter-btn.active.green {
|
|
610
|
+
background: var(--green-glow);
|
|
611
|
+
border-color: var(--green);
|
|
612
|
+
color: var(--green);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/* Reset Button */
|
|
616
|
+
.reset-btn {
|
|
617
|
+
padding: 8px 16px;
|
|
618
|
+
border-radius: 6px;
|
|
619
|
+
font-size: 12px;
|
|
620
|
+
font-weight: 500;
|
|
621
|
+
background: var(--bg-card);
|
|
622
|
+
border: 1px solid var(--border);
|
|
623
|
+
color: var(--text-secondary);
|
|
624
|
+
cursor: pointer;
|
|
625
|
+
transition: all 0.15s ease;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
.reset-btn:hover {
|
|
629
|
+
border-color: var(--red);
|
|
630
|
+
color: var(--red);
|
|
631
|
+
background: var(--red-glow);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
.reset-btn:disabled {
|
|
635
|
+
opacity: 0.5;
|
|
636
|
+
cursor: not-allowed;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/* Confirmation Modal */
|
|
640
|
+
.modal-overlay {
|
|
641
|
+
position: fixed;
|
|
642
|
+
top: 0;
|
|
643
|
+
left: 0;
|
|
644
|
+
right: 0;
|
|
645
|
+
bottom: 0;
|
|
646
|
+
background: rgba(0, 0, 0, 0.7);
|
|
647
|
+
display: flex;
|
|
648
|
+
align-items: center;
|
|
649
|
+
justify-content: center;
|
|
650
|
+
z-index: 1000;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
.modal {
|
|
654
|
+
background: var(--bg-elevated);
|
|
655
|
+
border: 1px solid var(--border);
|
|
656
|
+
border-radius: 12px;
|
|
657
|
+
padding: 24px;
|
|
658
|
+
max-width: 400px;
|
|
659
|
+
width: 90%;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
.modal h3 {
|
|
663
|
+
font-size: 16px;
|
|
664
|
+
font-weight: 600;
|
|
665
|
+
margin-bottom: 12px;
|
|
666
|
+
color: var(--text-primary);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
.modal p {
|
|
670
|
+
font-size: 13px;
|
|
671
|
+
color: var(--text-secondary);
|
|
672
|
+
margin-bottom: 20px;
|
|
673
|
+
line-height: 1.5;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
.modal-actions {
|
|
677
|
+
display: flex;
|
|
678
|
+
gap: 12px;
|
|
679
|
+
justify-content: flex-end;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
.modal-btn {
|
|
683
|
+
padding: 8px 16px;
|
|
684
|
+
border-radius: 6px;
|
|
685
|
+
font-size: 12px;
|
|
686
|
+
font-weight: 500;
|
|
687
|
+
cursor: pointer;
|
|
688
|
+
transition: all 0.15s ease;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
.modal-btn.cancel {
|
|
692
|
+
background: var(--bg-card);
|
|
693
|
+
border: 1px solid var(--border);
|
|
694
|
+
color: var(--text-secondary);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
.modal-btn.cancel:hover {
|
|
698
|
+
border-color: var(--border-light);
|
|
699
|
+
color: var(--text-primary);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
.modal-btn.danger {
|
|
703
|
+
background: var(--red-glow);
|
|
704
|
+
border: 1px solid var(--red);
|
|
705
|
+
color: var(--red);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
.modal-btn.danger:hover {
|
|
709
|
+
background: var(--red);
|
|
710
|
+
color: white;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/* Card Action Buttons */
|
|
714
|
+
.card-actions {
|
|
715
|
+
display: flex;
|
|
716
|
+
gap: 6px;
|
|
717
|
+
margin-top: 10px;
|
|
718
|
+
padding-top: 10px;
|
|
719
|
+
border-top: 1px solid var(--border);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
.action-btn {
|
|
723
|
+
padding: 5px 10px;
|
|
724
|
+
border-radius: 4px;
|
|
725
|
+
font-size: 10px;
|
|
726
|
+
font-weight: 500;
|
|
727
|
+
cursor: pointer;
|
|
728
|
+
transition: all 0.15s ease;
|
|
729
|
+
border: 1px solid var(--border);
|
|
730
|
+
background: var(--bg-hover);
|
|
731
|
+
color: var(--text-secondary);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
.action-btn:hover {
|
|
735
|
+
border-color: var(--border-light);
|
|
736
|
+
color: var(--text-primary);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
.action-btn.approve {
|
|
740
|
+
border-color: var(--green-dim);
|
|
741
|
+
color: var(--green);
|
|
742
|
+
background: var(--green-glow);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
.action-btn.approve:hover {
|
|
746
|
+
background: var(--green);
|
|
747
|
+
color: white;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
.action-btn.reject {
|
|
751
|
+
border-color: var(--orange);
|
|
752
|
+
color: var(--orange);
|
|
753
|
+
background: var(--orange-glow);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
.action-btn.reject:hover {
|
|
757
|
+
background: var(--orange);
|
|
758
|
+
color: white;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
.action-btn.danger {
|
|
762
|
+
border-color: var(--red);
|
|
763
|
+
color: var(--red);
|
|
764
|
+
background: var(--red-glow);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
.action-btn.danger:hover {
|
|
768
|
+
background: var(--red);
|
|
769
|
+
color: white;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
.action-btn:disabled {
|
|
773
|
+
opacity: 0.5;
|
|
774
|
+
cursor: not-allowed;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/* Header Controls */
|
|
778
|
+
.header-controls {
|
|
779
|
+
display: flex;
|
|
780
|
+
align-items: center;
|
|
781
|
+
gap: 12px;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
.control-btn {
|
|
785
|
+
padding: 8px 14px;
|
|
786
|
+
border-radius: 6px;
|
|
787
|
+
font-size: 11px;
|
|
788
|
+
font-weight: 500;
|
|
789
|
+
cursor: pointer;
|
|
790
|
+
transition: all 0.15s ease;
|
|
791
|
+
border: 1px solid var(--border);
|
|
792
|
+
background: var(--bg-card);
|
|
793
|
+
color: var(--text-secondary);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
.control-btn:hover {
|
|
797
|
+
border-color: var(--border-light);
|
|
798
|
+
color: var(--text-primary);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
.control-btn.stop {
|
|
802
|
+
border-color: var(--orange);
|
|
803
|
+
color: var(--orange);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
.control-btn.stop:hover {
|
|
807
|
+
background: var(--orange-glow);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
.control-btn.complete {
|
|
811
|
+
border-color: var(--green-dim);
|
|
812
|
+
color: var(--green);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
.control-btn.complete:hover {
|
|
816
|
+
background: var(--green-glow);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
.control-btn:disabled {
|
|
820
|
+
opacity: 0.5;
|
|
821
|
+
cursor: not-allowed;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/* Toggle Switch */
|
|
825
|
+
.toggle-container {
|
|
826
|
+
display: flex;
|
|
827
|
+
align-items: center;
|
|
828
|
+
gap: 8px;
|
|
829
|
+
padding: 8px 12px;
|
|
830
|
+
border-bottom: 1px solid var(--border);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
.toggle-label {
|
|
834
|
+
font-size: 11px;
|
|
835
|
+
color: var(--text-muted);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
.toggle {
|
|
839
|
+
width: 36px;
|
|
840
|
+
height: 20px;
|
|
841
|
+
background: var(--bg-hover);
|
|
842
|
+
border-radius: 10px;
|
|
843
|
+
position: relative;
|
|
844
|
+
cursor: pointer;
|
|
845
|
+
transition: background 0.2s ease;
|
|
846
|
+
border: 1px solid var(--border);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
.toggle.active {
|
|
850
|
+
background: var(--blue-glow);
|
|
851
|
+
border-color: var(--blue);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
.toggle::after {
|
|
855
|
+
content: "";
|
|
856
|
+
position: absolute;
|
|
857
|
+
width: 14px;
|
|
858
|
+
height: 14px;
|
|
859
|
+
border-radius: 50%;
|
|
860
|
+
background: var(--text-muted);
|
|
861
|
+
top: 2px;
|
|
862
|
+
left: 2px;
|
|
863
|
+
transition: all 0.2s ease;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
.toggle.active::after {
|
|
867
|
+
left: 18px;
|
|
868
|
+
background: var(--blue);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
@media (max-width: 640px) {
|
|
872
|
+
header, .stats, .grid, footer, .progress-section { padding-left: 16px; padding-right: 16px; }
|
|
873
|
+
.stats { grid-template-columns: 1fr; }
|
|
874
|
+
}
|
|
875
|
+
</style>
|
|
876
|
+
</head>
|
|
877
|
+
<body>
|
|
878
|
+
<header>
|
|
879
|
+
<h1>Lockstep MCP</h1>
|
|
880
|
+
<div class="header-controls">
|
|
881
|
+
<button class="control-btn stop" id="stop-all-btn" title="Stop all implementers">⏹ Stop All</button>
|
|
882
|
+
<button class="control-btn complete" id="complete-btn" title="Mark project complete">✓ Complete</button>
|
|
883
|
+
<button class="reset-btn" id="reset-btn" title="Reset session for fresh start">Reset Session</button>
|
|
884
|
+
<div class="status-badge" id="status-badge">
|
|
885
|
+
<div class="status-dot" id="status-dot"></div>
|
|
886
|
+
<span id="status">Connecting</span>
|
|
887
|
+
</div>
|
|
888
|
+
</div>
|
|
889
|
+
</header>
|
|
890
|
+
|
|
891
|
+
<section class="stats">
|
|
892
|
+
<div class="stat">
|
|
893
|
+
<div class="label">Project Status</div>
|
|
894
|
+
<div class="value" id="project-status">--</div>
|
|
895
|
+
</div>
|
|
896
|
+
<div class="stat">
|
|
897
|
+
<div class="label">Total Tasks</div>
|
|
898
|
+
<div class="value" id="task-count">0</div>
|
|
899
|
+
</div>
|
|
900
|
+
<div class="stat">
|
|
901
|
+
<div class="label">Active Implementers</div>
|
|
902
|
+
<div class="value" id="implementer-count">0</div>
|
|
903
|
+
</div>
|
|
904
|
+
<div class="stat">
|
|
905
|
+
<div class="label">Active Locks</div>
|
|
906
|
+
<div class="value" id="lock-count">0</div>
|
|
907
|
+
</div>
|
|
908
|
+
</section>
|
|
909
|
+
|
|
910
|
+
<section class="progress-section">
|
|
911
|
+
<div class="progress-bar-container">
|
|
912
|
+
<div class="progress-bar" id="progress-bar"></div>
|
|
913
|
+
</div>
|
|
914
|
+
<div class="progress-text" id="progress-text">0% complete</div>
|
|
915
|
+
</section>
|
|
916
|
+
|
|
917
|
+
<section class="grid">
|
|
918
|
+
<div class="panel">
|
|
919
|
+
<div class="panel-header">
|
|
920
|
+
<h2>Project Context</h2>
|
|
921
|
+
</div>
|
|
922
|
+
<div id="project-context" class="context-content">
|
|
923
|
+
<div class="empty">No project context set</div>
|
|
924
|
+
</div>
|
|
925
|
+
</div>
|
|
926
|
+
<div class="panel">
|
|
927
|
+
<div class="panel-header">
|
|
928
|
+
<h2>Implementers</h2>
|
|
929
|
+
<span class="pill green" id="impl-meta">0 active</span>
|
|
930
|
+
</div>
|
|
931
|
+
<div class="list" id="implementer-list"></div>
|
|
932
|
+
</div>
|
|
933
|
+
<div class="panel">
|
|
934
|
+
<div class="panel-header">
|
|
935
|
+
<h2>Locks</h2>
|
|
936
|
+
<span class="pill orange" id="lock-meta">0 active</span>
|
|
937
|
+
</div>
|
|
938
|
+
<div class="toggle-container">
|
|
939
|
+
<span class="toggle-label">Show resolved</span>
|
|
940
|
+
<div class="toggle" id="show-resolved-toggle"></div>
|
|
941
|
+
</div>
|
|
942
|
+
<div class="list" id="lock-list"></div>
|
|
943
|
+
</div>
|
|
944
|
+
<div class="panel wide">
|
|
945
|
+
<div class="panel-header">
|
|
946
|
+
<h2>Tasks</h2>
|
|
947
|
+
<span class="pill blue" id="task-meta">0 total</span>
|
|
948
|
+
</div>
|
|
949
|
+
<div class="filter-bar">
|
|
950
|
+
<button class="filter-btn active" data-filter="all">All</button>
|
|
951
|
+
<button class="filter-btn" data-filter="in_progress">In Progress</button>
|
|
952
|
+
<button class="filter-btn" data-filter="review">Review</button>
|
|
953
|
+
<button class="filter-btn" data-filter="todo">Todo</button>
|
|
954
|
+
<button class="filter-btn green" data-filter="done">Done</button>
|
|
955
|
+
</div>
|
|
956
|
+
<div class="list" id="task-list"></div>
|
|
957
|
+
</div>
|
|
958
|
+
<div class="panel">
|
|
959
|
+
<div class="panel-header">
|
|
960
|
+
<h2>Activity</h2>
|
|
961
|
+
</div>
|
|
962
|
+
<div class="list" id="activity-list"></div>
|
|
963
|
+
</div>
|
|
964
|
+
<div class="panel full">
|
|
965
|
+
<div class="panel-header">
|
|
966
|
+
<h2>Notes</h2>
|
|
967
|
+
</div>
|
|
968
|
+
<div class="list" id="note-list"></div>
|
|
969
|
+
</div>
|
|
970
|
+
</section>
|
|
971
|
+
|
|
972
|
+
<footer>
|
|
973
|
+
<span>Lockstep MCP - Multi-agent coordination</span>
|
|
974
|
+
<span>Updates via WebSocket</span>
|
|
975
|
+
</footer>
|
|
976
|
+
|
|
977
|
+
<script>
|
|
978
|
+
const statusEl = document.getElementById("status");
|
|
979
|
+
const statusBadge = document.getElementById("status-badge");
|
|
980
|
+
const projectStatusEl = document.getElementById("project-status");
|
|
981
|
+
const projectContextEl = document.getElementById("project-context");
|
|
982
|
+
const implementerList = document.getElementById("implementer-list");
|
|
983
|
+
const taskList = document.getElementById("task-list");
|
|
984
|
+
const lockList = document.getElementById("lock-list");
|
|
985
|
+
const noteList = document.getElementById("note-list");
|
|
986
|
+
const activityList = document.getElementById("activity-list");
|
|
987
|
+
const taskCount = document.getElementById("task-count");
|
|
988
|
+
const implementerCount = document.getElementById("implementer-count");
|
|
989
|
+
const lockCount = document.getElementById("lock-count");
|
|
990
|
+
const taskMeta = document.getElementById("task-meta");
|
|
991
|
+
const lockMeta = document.getElementById("lock-meta");
|
|
992
|
+
const implMeta = document.getElementById("impl-meta");
|
|
993
|
+
const progressBar = document.getElementById("progress-bar");
|
|
994
|
+
const progressText = document.getElementById("progress-text");
|
|
995
|
+
const showResolvedToggle = document.getElementById("show-resolved-toggle");
|
|
996
|
+
const filterBtns = document.querySelectorAll(".filter-btn");
|
|
997
|
+
|
|
998
|
+
// State
|
|
999
|
+
let showResolvedLocks = false;
|
|
1000
|
+
let currentTaskFilter = "all";
|
|
1001
|
+
let allTasks = [];
|
|
1002
|
+
let allLocks = [];
|
|
1003
|
+
let activityLog = [];
|
|
1004
|
+
|
|
1005
|
+
// Toggle resolved locks
|
|
1006
|
+
showResolvedToggle.addEventListener("click", () => {
|
|
1007
|
+
showResolvedLocks = !showResolvedLocks;
|
|
1008
|
+
showResolvedToggle.classList.toggle("active", showResolvedLocks);
|
|
1009
|
+
renderLocks(allLocks);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
// Task filter buttons
|
|
1013
|
+
filterBtns.forEach(btn => {
|
|
1014
|
+
btn.addEventListener("click", () => {
|
|
1015
|
+
filterBtns.forEach(b => b.classList.remove("active"));
|
|
1016
|
+
btn.classList.add("active");
|
|
1017
|
+
currentTaskFilter = btn.dataset.filter;
|
|
1018
|
+
renderTasks(allTasks);
|
|
1019
|
+
});
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
// Reset session button
|
|
1023
|
+
const resetBtn = document.getElementById("reset-btn");
|
|
1024
|
+
resetBtn.addEventListener("click", () => {
|
|
1025
|
+
showResetModal();
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
function showResetModal() {
|
|
1029
|
+
const overlay = document.createElement("div");
|
|
1030
|
+
overlay.className = "modal-overlay";
|
|
1031
|
+
overlay.innerHTML = \`
|
|
1032
|
+
<div class="modal">
|
|
1033
|
+
<h3>Reset Session?</h3>
|
|
1034
|
+
<p>This will clear all tasks, locks, notes, and archive discussions. Use this when starting a new project or when data from previous sessions is cluttering the dashboard.</p>
|
|
1035
|
+
<label style="display: flex; align-items: center; gap: 8px; margin-bottom: 16px; font-size: 12px; color: var(--text-secondary);">
|
|
1036
|
+
<input type="checkbox" id="keep-context-checkbox">
|
|
1037
|
+
Keep project description (only reset tasks and data)
|
|
1038
|
+
</label>
|
|
1039
|
+
<div class="modal-actions">
|
|
1040
|
+
<button class="modal-btn cancel" id="cancel-reset">Cancel</button>
|
|
1041
|
+
<button class="modal-btn danger" id="confirm-reset">Reset Session</button>
|
|
1042
|
+
</div>
|
|
1043
|
+
</div>
|
|
1044
|
+
\`;
|
|
1045
|
+
document.body.appendChild(overlay);
|
|
1046
|
+
|
|
1047
|
+
document.getElementById("cancel-reset").addEventListener("click", () => {
|
|
1048
|
+
overlay.remove();
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
overlay.addEventListener("click", (e) => {
|
|
1052
|
+
if (e.target === overlay) overlay.remove();
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
document.getElementById("confirm-reset").addEventListener("click", async () => {
|
|
1056
|
+
const keepContext = document.getElementById("keep-context-checkbox").checked;
|
|
1057
|
+
overlay.remove();
|
|
1058
|
+
await resetSession(keepContext);
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
async function resetSession(keepProjectContext) {
|
|
1063
|
+
resetBtn.disabled = true;
|
|
1064
|
+
resetBtn.textContent = "Resetting...";
|
|
1065
|
+
try {
|
|
1066
|
+
const response = await fetch("/api/reset", {
|
|
1067
|
+
method: "POST",
|
|
1068
|
+
headers: { "Content-Type": "application/json" },
|
|
1069
|
+
body: JSON.stringify({ keepProjectContext })
|
|
1070
|
+
});
|
|
1071
|
+
const result = await response.json();
|
|
1072
|
+
if (result.success) {
|
|
1073
|
+
// Clear local state
|
|
1074
|
+
activityLog = [];
|
|
1075
|
+
prevTaskStates = {};
|
|
1076
|
+
prevLockStates = {};
|
|
1077
|
+
// Refresh the dashboard
|
|
1078
|
+
await fetchState();
|
|
1079
|
+
alert("Session reset complete!\\n\\n" + result.message);
|
|
1080
|
+
} else {
|
|
1081
|
+
alert("Reset failed: " + (result.error || "Unknown error"));
|
|
1082
|
+
}
|
|
1083
|
+
} catch (err) {
|
|
1084
|
+
alert("Reset failed: " + err.message);
|
|
1085
|
+
} finally {
|
|
1086
|
+
resetBtn.disabled = false;
|
|
1087
|
+
resetBtn.textContent = "Reset Session";
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Stop All Implementers button
|
|
1092
|
+
const stopAllBtn = document.getElementById("stop-all-btn");
|
|
1093
|
+
stopAllBtn.addEventListener("click", async () => {
|
|
1094
|
+
if (!confirm("Stop all active implementers?")) return;
|
|
1095
|
+
stopAllBtn.disabled = true;
|
|
1096
|
+
try {
|
|
1097
|
+
const response = await fetch("/api/stop-all", { method: "POST" });
|
|
1098
|
+
const result = await response.json();
|
|
1099
|
+
if (result.success) {
|
|
1100
|
+
await fetchState();
|
|
1101
|
+
} else {
|
|
1102
|
+
alert("Failed: " + (result.error || "Unknown error"));
|
|
1103
|
+
}
|
|
1104
|
+
} catch (err) {
|
|
1105
|
+
alert("Failed: " + err.message);
|
|
1106
|
+
} finally {
|
|
1107
|
+
stopAllBtn.disabled = false;
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
// Mark Project Complete button
|
|
1112
|
+
const completeBtn = document.getElementById("complete-btn");
|
|
1113
|
+
completeBtn.addEventListener("click", async () => {
|
|
1114
|
+
if (!confirm("Mark project as complete? This will signal all implementers to stop.")) return;
|
|
1115
|
+
completeBtn.disabled = true;
|
|
1116
|
+
try {
|
|
1117
|
+
const response = await fetch("/api/complete", { method: "POST" });
|
|
1118
|
+
const result = await response.json();
|
|
1119
|
+
if (result.success) {
|
|
1120
|
+
await fetchState();
|
|
1121
|
+
} else {
|
|
1122
|
+
alert("Failed: " + (result.error || "Unknown error"));
|
|
1123
|
+
}
|
|
1124
|
+
} catch (err) {
|
|
1125
|
+
alert("Failed: " + err.message);
|
|
1126
|
+
} finally {
|
|
1127
|
+
completeBtn.disabled = false;
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
// Task actions (approve/reject)
|
|
1132
|
+
async function approveTask(taskId) {
|
|
1133
|
+
try {
|
|
1134
|
+
const response = await fetch("/api/task/" + encodeURIComponent(taskId) + "/approve", { method: "POST" });
|
|
1135
|
+
const result = await response.json();
|
|
1136
|
+
if (result.success) {
|
|
1137
|
+
await fetchState();
|
|
1138
|
+
} else {
|
|
1139
|
+
alert("Failed: " + (result.error || "Unknown error"));
|
|
1140
|
+
}
|
|
1141
|
+
} catch (err) {
|
|
1142
|
+
alert("Failed: " + err.message);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
async function rejectTask(taskId) {
|
|
1147
|
+
const feedback = prompt("What changes are needed?");
|
|
1148
|
+
if (!feedback) return;
|
|
1149
|
+
try {
|
|
1150
|
+
const response = await fetch("/api/task/" + encodeURIComponent(taskId) + "/reject", {
|
|
1151
|
+
method: "POST",
|
|
1152
|
+
headers: { "Content-Type": "application/json" },
|
|
1153
|
+
body: JSON.stringify({ feedback })
|
|
1154
|
+
});
|
|
1155
|
+
const result = await response.json();
|
|
1156
|
+
if (result.success) {
|
|
1157
|
+
await fetchState();
|
|
1158
|
+
} else {
|
|
1159
|
+
alert("Failed: " + (result.error || "Unknown error"));
|
|
1160
|
+
}
|
|
1161
|
+
} catch (err) {
|
|
1162
|
+
alert("Failed: " + err.message);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Kill specific implementer
|
|
1167
|
+
async function killImplementer(implId, implName) {
|
|
1168
|
+
if (!confirm("Stop implementer '" + implName + "'?")) return;
|
|
1169
|
+
try {
|
|
1170
|
+
const response = await fetch("/api/implementer/" + encodeURIComponent(implId) + "/stop", { method: "POST" });
|
|
1171
|
+
const result = await response.json();
|
|
1172
|
+
if (result.success) {
|
|
1173
|
+
await fetchState();
|
|
1174
|
+
} else {
|
|
1175
|
+
alert("Failed: " + (result.error || "Unknown error"));
|
|
1176
|
+
}
|
|
1177
|
+
} catch (err) {
|
|
1178
|
+
alert("Failed: " + err.message);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function escapeHtml(text) {
|
|
1183
|
+
const map = {
|
|
1184
|
+
'&': '&',
|
|
1185
|
+
'<': '<',
|
|
1186
|
+
'>': '>',
|
|
1187
|
+
'"': '"',
|
|
1188
|
+
"'": '''
|
|
1189
|
+
};
|
|
1190
|
+
return text.replace(/[&<>"']/g, (char) => map[char]);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function formatTime(isoString) {
|
|
1194
|
+
const date = new Date(isoString);
|
|
1195
|
+
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function renderTasks(tasks) {
|
|
1199
|
+
allTasks = tasks; // Store for filtering
|
|
1200
|
+
taskList.innerHTML = "";
|
|
1201
|
+
|
|
1202
|
+
// Apply filter
|
|
1203
|
+
let filtered = tasks;
|
|
1204
|
+
if (currentTaskFilter !== "all") {
|
|
1205
|
+
filtered = tasks.filter(t => t.status === currentTaskFilter);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
if (!filtered.length) {
|
|
1209
|
+
const msg = currentTaskFilter === "all" ? "No tasks yet" : "No " + currentTaskFilter.replace("_", " ") + " tasks";
|
|
1210
|
+
taskList.innerHTML = '<div class="empty">' + msg + '</div>';
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
// Sort: in_progress first, then review, then todo, then done
|
|
1214
|
+
const order = { in_progress: 0, review: 1, todo: 2, done: 3 };
|
|
1215
|
+
const sorted = [...filtered].sort((a, b) => (order[a.status] || 99) - (order[b.status] || 99));
|
|
1216
|
+
sorted.forEach(task => {
|
|
1217
|
+
const card = document.createElement("div");
|
|
1218
|
+
card.className = "card";
|
|
1219
|
+
const desc = task.description
|
|
1220
|
+
? '<div class="card-desc">' + escapeHtml(task.description.substring(0, 150)) + (task.description.length > 150 ? '...' : '') + "</div>"
|
|
1221
|
+
: "";
|
|
1222
|
+
// Show isolation mode if set to worktree
|
|
1223
|
+
const isolationTag = task.isolation === "worktree"
|
|
1224
|
+
? '<span class="tag worktree">worktree</span>'
|
|
1225
|
+
: '';
|
|
1226
|
+
// Show review notes if in review status
|
|
1227
|
+
const reviewNotes = task.status === "review" && task.reviewNotes
|
|
1228
|
+
? '<div class="card-desc" style="margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border);"><strong>Review notes:</strong> ' + escapeHtml(task.reviewNotes.substring(0, 200)) + (task.reviewNotes.length > 200 ? '...' : '') + '</div>'
|
|
1229
|
+
: '';
|
|
1230
|
+
// Add action buttons for review tasks
|
|
1231
|
+
const actions = task.status === "review"
|
|
1232
|
+
? '<div class="card-actions"><button class="action-btn approve" data-task-id="' + task.id + '">✓ Approve</button><button class="action-btn reject" data-task-id="' + task.id + '">✗ Request Changes</button></div>'
|
|
1233
|
+
: '';
|
|
1234
|
+
card.innerHTML =
|
|
1235
|
+
'<div class="card-title">' +
|
|
1236
|
+
'<span class="tag ' + task.status + '">' + task.status.replace('_', ' ') + '</span>' +
|
|
1237
|
+
escapeHtml(task.title) +
|
|
1238
|
+
"</div>" +
|
|
1239
|
+
desc +
|
|
1240
|
+
'<div class="card-meta">' +
|
|
1241
|
+
(task.owner ? '<span class="mono">@' + escapeHtml(task.owner) + '</span>' : '') +
|
|
1242
|
+
(task.complexity ? '<span class="tag">' + task.complexity + '</span>' : '') +
|
|
1243
|
+
isolationTag +
|
|
1244
|
+
'<span class="mono">' + formatTime(task.updatedAt) + '</span>' +
|
|
1245
|
+
"</div>" +
|
|
1246
|
+
reviewNotes +
|
|
1247
|
+
actions;
|
|
1248
|
+
|
|
1249
|
+
// Add event listeners for action buttons
|
|
1250
|
+
if (task.status === "review") {
|
|
1251
|
+
const approveBtn = card.querySelector(".action-btn.approve");
|
|
1252
|
+
const rejectBtn = card.querySelector(".action-btn.reject");
|
|
1253
|
+
if (approveBtn) {
|
|
1254
|
+
approveBtn.addEventListener("click", (e) => {
|
|
1255
|
+
e.stopPropagation();
|
|
1256
|
+
approveTask(task.id);
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
if (rejectBtn) {
|
|
1260
|
+
rejectBtn.addEventListener("click", (e) => {
|
|
1261
|
+
e.stopPropagation();
|
|
1262
|
+
rejectTask(task.id);
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
taskList.appendChild(card);
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function renderLocks(locks) {
|
|
1272
|
+
allLocks = locks; // Store for toggle
|
|
1273
|
+
lockList.innerHTML = "";
|
|
1274
|
+
|
|
1275
|
+
// Filter based on toggle
|
|
1276
|
+
let filtered = showResolvedLocks ? locks : locks.filter(l => l.status === "active");
|
|
1277
|
+
|
|
1278
|
+
if (!filtered.length) {
|
|
1279
|
+
const msg = showResolvedLocks ? "No locks" : "No active locks";
|
|
1280
|
+
lockList.innerHTML = '<div class="empty">' + msg + '</div>';
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// Sort: active first, then by updatedAt desc
|
|
1285
|
+
filtered = [...filtered].sort((a, b) => {
|
|
1286
|
+
if (a.status === "active" && b.status !== "active") return -1;
|
|
1287
|
+
if (a.status !== "active" && b.status === "active") return 1;
|
|
1288
|
+
return b.updatedAt.localeCompare(a.updatedAt);
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
filtered.forEach(lock => {
|
|
1292
|
+
const card = document.createElement("div");
|
|
1293
|
+
card.className = "card";
|
|
1294
|
+
const fileName = lock.path.split('/').pop();
|
|
1295
|
+
card.innerHTML =
|
|
1296
|
+
'<div class="card-title">' +
|
|
1297
|
+
'<span class="tag ' + lock.status + '">' + lock.status + '</span>' +
|
|
1298
|
+
escapeHtml(fileName) +
|
|
1299
|
+
"</div>" +
|
|
1300
|
+
'<div class="card-desc mono">' + escapeHtml(lock.path) + '</div>' +
|
|
1301
|
+
'<div class="card-meta">' +
|
|
1302
|
+
(lock.owner ? '<span class="mono">@' + escapeHtml(lock.owner) + '</span>' : '') +
|
|
1303
|
+
'<span class="mono">' + formatTime(lock.updatedAt) + '</span>' +
|
|
1304
|
+
"</div>";
|
|
1305
|
+
lockList.appendChild(card);
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function renderNotes(notes) {
|
|
1310
|
+
noteList.innerHTML = "";
|
|
1311
|
+
if (!notes.length) {
|
|
1312
|
+
noteList.innerHTML = '<div class="empty">No notes yet</div>';
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
const sorted = [...notes].reverse().slice(0, 10);
|
|
1316
|
+
sorted.forEach(note => {
|
|
1317
|
+
const card = document.createElement("div");
|
|
1318
|
+
const isSystem = note.author === "system";
|
|
1319
|
+
card.className = "note-card" + (isSystem ? " system" : "");
|
|
1320
|
+
card.innerHTML =
|
|
1321
|
+
'<div class="note-author">' + (note.author ? escapeHtml(note.author) : "Anonymous") + '</div>' +
|
|
1322
|
+
'<div class="note-text">' + escapeHtml(note.text) + '</div>' +
|
|
1323
|
+
'<div class="note-time">' + formatTime(note.createdAt) + '</div>';
|
|
1324
|
+
noteList.appendChild(card);
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// Track previous state for activity detection
|
|
1329
|
+
let prevTaskStates = {};
|
|
1330
|
+
let prevLockStates = {};
|
|
1331
|
+
|
|
1332
|
+
function detectActivity(tasks, locks) {
|
|
1333
|
+
const newActivities = [];
|
|
1334
|
+
const now = new Date().toISOString();
|
|
1335
|
+
|
|
1336
|
+
// Detect task state changes
|
|
1337
|
+
tasks.forEach(task => {
|
|
1338
|
+
const prev = prevTaskStates[task.id];
|
|
1339
|
+
if (prev && prev !== task.status) {
|
|
1340
|
+
if (task.status === "in_progress" && prev === "todo") {
|
|
1341
|
+
newActivities.push({
|
|
1342
|
+
type: "task-claimed",
|
|
1343
|
+
icon: "claim",
|
|
1344
|
+
text: '<strong>' + escapeHtml(task.owner || "Someone") + '</strong> claimed <strong>' + escapeHtml(task.title) + '</strong>',
|
|
1345
|
+
time: now
|
|
1346
|
+
});
|
|
1347
|
+
} else if (task.status === "done") {
|
|
1348
|
+
newActivities.push({
|
|
1349
|
+
type: "task-completed",
|
|
1350
|
+
icon: "complete",
|
|
1351
|
+
text: '<strong>' + escapeHtml(task.title) + '</strong> completed',
|
|
1352
|
+
time: now
|
|
1353
|
+
});
|
|
1354
|
+
} else if (task.status === "review") {
|
|
1355
|
+
newActivities.push({
|
|
1356
|
+
type: "task-review",
|
|
1357
|
+
icon: "review",
|
|
1358
|
+
text: '<strong>' + escapeHtml(task.title) + '</strong> submitted for review',
|
|
1359
|
+
time: now
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
prevTaskStates[task.id] = task.status;
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
// Detect lock changes
|
|
1367
|
+
locks.forEach(lock => {
|
|
1368
|
+
const prev = prevLockStates[lock.path];
|
|
1369
|
+
if (!prev && lock.status === "active") {
|
|
1370
|
+
newActivities.push({
|
|
1371
|
+
type: "lock-acquired",
|
|
1372
|
+
icon: "lock",
|
|
1373
|
+
text: '<strong>' + escapeHtml(lock.owner || "Someone") + '</strong> locked <strong>' + escapeHtml(lock.path.split("/").pop()) + '</strong>',
|
|
1374
|
+
time: now
|
|
1375
|
+
});
|
|
1376
|
+
} else if (prev === "active" && lock.status === "resolved") {
|
|
1377
|
+
newActivities.push({
|
|
1378
|
+
type: "lock-released",
|
|
1379
|
+
icon: "lock",
|
|
1380
|
+
text: '<strong>' + escapeHtml(lock.path.split("/").pop()) + '</strong> released',
|
|
1381
|
+
time: now
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
prevLockStates[lock.path] = lock.status;
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
// Add to activity log (keep last 50)
|
|
1388
|
+
activityLog = [...newActivities, ...activityLog].slice(0, 50);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
function renderActivity() {
|
|
1392
|
+
activityList.innerHTML = "";
|
|
1393
|
+
if (!activityLog.length) {
|
|
1394
|
+
activityList.innerHTML = '<div class="empty">No recent activity</div>';
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
activityLog.slice(0, 15).forEach(activity => {
|
|
1398
|
+
const item = document.createElement("div");
|
|
1399
|
+
item.className = "activity-item " + activity.type;
|
|
1400
|
+
item.innerHTML =
|
|
1401
|
+
'<div class="activity-icon ' + activity.icon + '">' +
|
|
1402
|
+
(activity.icon === "claim" ? "→" : activity.icon === "complete" ? "✓" : activity.icon === "review" ? "?" : "🔒") +
|
|
1403
|
+
'</div>' +
|
|
1404
|
+
'<div class="activity-content">' +
|
|
1405
|
+
'<div class="activity-text">' + activity.text + '</div>' +
|
|
1406
|
+
'<div class="activity-time">' + formatTime(activity.time) + '</div>' +
|
|
1407
|
+
'</div>';
|
|
1408
|
+
activityList.appendChild(item);
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// Compute dynamic status based on tasks and implementers
|
|
1413
|
+
function computeDynamicStatus(context, tasks, implementers) {
|
|
1414
|
+
const activeImpls = (implementers || []).filter(i => i.status === "active").length;
|
|
1415
|
+
const todoTasks = tasks.filter(t => t.status === "todo" || t.status === "in_progress" || t.status === "review").length;
|
|
1416
|
+
const allDone = tasks.length > 0 && todoTasks === 0;
|
|
1417
|
+
|
|
1418
|
+
if (allDone) {
|
|
1419
|
+
return { text: "Complete", className: "status-complete" };
|
|
1420
|
+
}
|
|
1421
|
+
if (activeImpls === 0 && tasks.length > 0) {
|
|
1422
|
+
return { text: "Paused", className: "status-stopped" };
|
|
1423
|
+
}
|
|
1424
|
+
if (context && context.status) {
|
|
1425
|
+
return { text: context.status.replace('_', ' '), className: "status-" + context.status };
|
|
1426
|
+
}
|
|
1427
|
+
return { text: "--", className: "" };
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function renderProjectContext(context, tasks, implementers) {
|
|
1431
|
+
if (!context) {
|
|
1432
|
+
projectContextEl.innerHTML = '<div class="empty">No project context set</div>';
|
|
1433
|
+
const dynamicStatus = computeDynamicStatus(null, tasks || [], implementers || []);
|
|
1434
|
+
projectStatusEl.textContent = dynamicStatus.text;
|
|
1435
|
+
projectStatusEl.className = "value " + dynamicStatus.className;
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
const dynamicStatus = computeDynamicStatus(context, tasks || [], implementers || []);
|
|
1439
|
+
projectStatusEl.textContent = dynamicStatus.text;
|
|
1440
|
+
projectStatusEl.className = "value " + dynamicStatus.className;
|
|
1441
|
+
|
|
1442
|
+
let html = '';
|
|
1443
|
+
html += '<div class="context-item"><div class="context-label">Description</div>';
|
|
1444
|
+
html += '<div class="context-value">' + escapeHtml(context.description || "No description") + '</div></div>';
|
|
1445
|
+
|
|
1446
|
+
html += '<div class="context-item"><div class="context-label">End State</div>';
|
|
1447
|
+
html += '<div class="context-value">' + escapeHtml(context.endState || "Not defined") + '</div></div>';
|
|
1448
|
+
|
|
1449
|
+
if (context.techStack && context.techStack.length) {
|
|
1450
|
+
html += '<div class="context-item"><div class="context-label">Tech Stack</div>';
|
|
1451
|
+
html += '<div class="context-value">' + context.techStack.map(escapeHtml).join(", ") + '</div></div>';
|
|
1452
|
+
}
|
|
1453
|
+
if (context.implementationPlan && context.implementationPlan.length) {
|
|
1454
|
+
html += '<div class="context-item"><div class="context-label">Plan Steps</div>';
|
|
1455
|
+
html += '<div class="context-value">' + context.implementationPlan.length + ' steps defined</div></div>';
|
|
1456
|
+
}
|
|
1457
|
+
if (context.preferredImplementer) {
|
|
1458
|
+
html += '<div class="context-item"><div class="context-label">Implementer Type</div>';
|
|
1459
|
+
html += '<div class="context-value">' + context.preferredImplementer + '</div></div>';
|
|
1460
|
+
}
|
|
1461
|
+
projectContextEl.innerHTML = html;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
async function focusImplementer(implId) {
|
|
1465
|
+
try {
|
|
1466
|
+
const response = await fetch("/api/focus/" + encodeURIComponent(implId), { method: "POST" });
|
|
1467
|
+
const result = await response.json();
|
|
1468
|
+
if (!result.success) {
|
|
1469
|
+
console.error("Failed to focus:", result.error);
|
|
1470
|
+
}
|
|
1471
|
+
} catch (err) {
|
|
1472
|
+
console.error("Failed to focus implementer:", err);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
function renderImplementers(implementers, tasks) {
|
|
1477
|
+
implementerList.innerHTML = "";
|
|
1478
|
+
if (!implementers || !implementers.length) {
|
|
1479
|
+
implementerList.innerHTML = '<div class="empty">No implementers launched</div>';
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
tasks = tasks || [];
|
|
1483
|
+
implementers.forEach(impl => {
|
|
1484
|
+
const card = document.createElement("div");
|
|
1485
|
+
const isActive = impl.status === "active";
|
|
1486
|
+
const isWorktree = impl.isolation === "worktree";
|
|
1487
|
+
card.className = "card" + (isActive ? " clickable" : "");
|
|
1488
|
+
|
|
1489
|
+
// Build isolation/branch display
|
|
1490
|
+
let isolationHtml = '';
|
|
1491
|
+
if (isWorktree && impl.branchName) {
|
|
1492
|
+
isolationHtml = '<span class="tag worktree">worktree</span>' +
|
|
1493
|
+
'<span class="tag branch">' + escapeHtml(impl.branchName) + '</span>';
|
|
1494
|
+
} else if (impl.isolation) {
|
|
1495
|
+
isolationHtml = '<span class="tag ' + impl.isolation + '">' + impl.isolation + '</span>';
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// Get tasks for this implementer
|
|
1499
|
+
const implTasks = tasks.filter(t => t.owner === impl.name);
|
|
1500
|
+
const currentTask = implTasks.find(t => t.status === "in_progress");
|
|
1501
|
+
const reviewTasks = implTasks.filter(t => t.status === "review");
|
|
1502
|
+
const doneTasks = implTasks.filter(t => t.status === "done");
|
|
1503
|
+
|
|
1504
|
+
// Build task summary HTML
|
|
1505
|
+
let tasksHtml = '';
|
|
1506
|
+
if (implTasks.length > 0) {
|
|
1507
|
+
tasksHtml = '<div class="impl-tasks">';
|
|
1508
|
+
if (currentTask) {
|
|
1509
|
+
tasksHtml += '<div class="impl-task">' +
|
|
1510
|
+
'<span class="tag in_progress">working</span>' +
|
|
1511
|
+
'<span class="task-title">' + escapeHtml(currentTask.title) + '</span>' +
|
|
1512
|
+
'</div>';
|
|
1513
|
+
}
|
|
1514
|
+
reviewTasks.forEach(t => {
|
|
1515
|
+
tasksHtml += '<div class="impl-task">' +
|
|
1516
|
+
'<span class="tag review">review</span>' +
|
|
1517
|
+
'<span class="task-title">' + escapeHtml(t.title) + '</span>' +
|
|
1518
|
+
'</div>';
|
|
1519
|
+
});
|
|
1520
|
+
if (doneTasks.length > 0 && !currentTask && reviewTasks.length === 0) {
|
|
1521
|
+
// Show most recent done task if no active work
|
|
1522
|
+
const recentDone = doneTasks.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
|
|
1523
|
+
tasksHtml += '<div class="impl-task">' +
|
|
1524
|
+
'<span class="tag done">done</span>' +
|
|
1525
|
+
'<span class="task-title">' + escapeHtml(recentDone.title) + '</span>' +
|
|
1526
|
+
'</div>';
|
|
1527
|
+
}
|
|
1528
|
+
tasksHtml += '<div class="impl-task-summary">' + doneTasks.length + ' completed, ' +
|
|
1529
|
+
(implTasks.length - doneTasks.length) + ' remaining</div>';
|
|
1530
|
+
tasksHtml += '</div>';
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// Add stop button for active implementers
|
|
1534
|
+
const actionsHtml = isActive
|
|
1535
|
+
? '<div class="card-actions"><button class="action-btn danger" data-impl-id="' + impl.id + '" data-impl-name="' + escapeHtml(impl.name) + '">⏹ Stop</button></div>'
|
|
1536
|
+
: '';
|
|
1537
|
+
|
|
1538
|
+
card.innerHTML =
|
|
1539
|
+
'<div class="card-title">' +
|
|
1540
|
+
'<span class="tag ' + impl.status + '">' + impl.status + '</span>' +
|
|
1541
|
+
escapeHtml(impl.name) +
|
|
1542
|
+
'</div>' +
|
|
1543
|
+
'<div class="card-meta">' +
|
|
1544
|
+
'<span class="tag">' + impl.type + '</span>' +
|
|
1545
|
+
isolationHtml +
|
|
1546
|
+
'<span class="mono">' + formatTime(impl.createdAt) + '</span>' +
|
|
1547
|
+
"</div>" +
|
|
1548
|
+
tasksHtml +
|
|
1549
|
+
actionsHtml;
|
|
1550
|
+
|
|
1551
|
+
if (isActive) {
|
|
1552
|
+
// Add click to focus (but not on the stop button)
|
|
1553
|
+
card.addEventListener("click", (e) => {
|
|
1554
|
+
if (e.target.closest(".action-btn")) return;
|
|
1555
|
+
focusImplementer(impl.id);
|
|
1556
|
+
});
|
|
1557
|
+
// Add stop button handler
|
|
1558
|
+
const stopBtn = card.querySelector(".action-btn.danger");
|
|
1559
|
+
if (stopBtn) {
|
|
1560
|
+
stopBtn.addEventListener("click", (e) => {
|
|
1561
|
+
e.stopPropagation();
|
|
1562
|
+
killImplementer(impl.id, impl.name);
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
implementerList.appendChild(card);
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
function updateState(state, config, projectContext, implementers) {
|
|
1571
|
+
taskCount.textContent = state.tasks.length;
|
|
1572
|
+
const activeLocks = state.locks.filter(lock => lock.status === "active").length;
|
|
1573
|
+
lockCount.textContent = activeLocks; // Show ACTIVE locks only
|
|
1574
|
+
const activeImpls = (implementers || []).filter(i => i.status === "active").length;
|
|
1575
|
+
implementerCount.textContent = activeImpls;
|
|
1576
|
+
|
|
1577
|
+
const todoTasks = state.tasks.filter(t => t.status === "todo").length;
|
|
1578
|
+
const inProgressTasks = state.tasks.filter(t => t.status === "in_progress").length;
|
|
1579
|
+
const reviewTasks = state.tasks.filter(t => t.status === "review").length;
|
|
1580
|
+
const doneTasks = state.tasks.filter(t => t.status === "done").length;
|
|
1581
|
+
taskMeta.textContent = todoTasks + " todo / " + inProgressTasks + " active / " + reviewTasks + " review / " + doneTasks + " done";
|
|
1582
|
+
|
|
1583
|
+
lockMeta.textContent = activeLocks + " active" + (state.locks.length > activeLocks ? " / " + state.locks.length + " total" : "");
|
|
1584
|
+
implMeta.textContent = activeImpls + " active";
|
|
1585
|
+
|
|
1586
|
+
// Update progress bar
|
|
1587
|
+
const totalTasks = state.tasks.length;
|
|
1588
|
+
const completedTasks = doneTasks;
|
|
1589
|
+
const progressPercent = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
|
|
1590
|
+
progressBar.style.width = progressPercent + "%";
|
|
1591
|
+
progressText.textContent = progressPercent + "% complete (" + completedTasks + "/" + totalTasks + ")";
|
|
1592
|
+
|
|
1593
|
+
// Detect activity changes
|
|
1594
|
+
detectActivity(state.tasks, state.locks);
|
|
1595
|
+
renderActivity();
|
|
1596
|
+
|
|
1597
|
+
renderProjectContext(projectContext, state.tasks, implementers);
|
|
1598
|
+
renderImplementers(implementers, state.tasks);
|
|
1599
|
+
renderTasks(state.tasks);
|
|
1600
|
+
renderLocks(state.locks);
|
|
1601
|
+
renderNotes(state.notes);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
async function fetchState() {
|
|
1605
|
+
try {
|
|
1606
|
+
const response = await fetch("/api/state");
|
|
1607
|
+
const data = await response.json();
|
|
1608
|
+
console.log("Fetched state:", data);
|
|
1609
|
+
updateState(data.state, data.config, data.projectContext, data.implementers);
|
|
1610
|
+
} catch (err) {
|
|
1611
|
+
console.error("Failed to fetch state:", err);
|
|
1612
|
+
statusEl.textContent = "Error loading data - check console";
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
function connect() {
|
|
1617
|
+
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
1618
|
+
const wsUrl = protocol + "://" + window.location.host + "/ws";
|
|
1619
|
+
console.log("Connecting to WebSocket:", wsUrl);
|
|
1620
|
+
const socket = new WebSocket(wsUrl);
|
|
1621
|
+
|
|
1622
|
+
socket.addEventListener("open", () => {
|
|
1623
|
+
console.log("WebSocket connected");
|
|
1624
|
+
statusEl.textContent = "Live";
|
|
1625
|
+
statusBadge.classList.add("connected");
|
|
1626
|
+
});
|
|
1627
|
+
|
|
1628
|
+
socket.addEventListener("message", (event) => {
|
|
1629
|
+
const payload = JSON.parse(event.data);
|
|
1630
|
+
console.log("WebSocket message:", payload.type);
|
|
1631
|
+
if (payload.type === "snapshot" || payload.type === "state") {
|
|
1632
|
+
updateState(payload.state, payload.config, payload.projectContext, payload.implementers);
|
|
1633
|
+
}
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
socket.addEventListener("error", (event) => {
|
|
1637
|
+
console.error("WebSocket error:", event);
|
|
1638
|
+
statusBadge.classList.remove("connected");
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
socket.addEventListener("close", (event) => {
|
|
1642
|
+
console.log("WebSocket closed:", event.code, event.reason);
|
|
1643
|
+
statusEl.textContent = "Reconnecting";
|
|
1644
|
+
statusBadge.classList.remove("connected");
|
|
1645
|
+
setTimeout(connect, 2000);
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// Load initial data immediately, then connect for live updates
|
|
1650
|
+
fetchState().then(() => {
|
|
1651
|
+
console.log("Initial fetch complete");
|
|
1652
|
+
});
|
|
1653
|
+
connect();
|
|
1654
|
+
</script>
|
|
1655
|
+
</body>
|
|
1656
|
+
</html>
|
|
1657
|
+
`;
|
|
1658
|
+
export async function startDashboard(options = {}) {
|
|
1659
|
+
const config = loadConfig();
|
|
1660
|
+
const store = createStore(config);
|
|
1661
|
+
await store.init();
|
|
1662
|
+
const port = options.port ?? 8787;
|
|
1663
|
+
const host = options.host ?? "127.0.0.1";
|
|
1664
|
+
const pollMsIdle = options.pollMs ?? 1500;
|
|
1665
|
+
const pollMsActive = 500; // Faster polling during active work
|
|
1666
|
+
const server = http.createServer(async (req, res) => {
|
|
1667
|
+
const parsed = url.parse(req.url || "");
|
|
1668
|
+
// Handle focus implementer API
|
|
1669
|
+
const focusMatch = parsed.pathname?.match(/^\/api\/focus\/(.+)$/);
|
|
1670
|
+
if (focusMatch && req.method === "POST") {
|
|
1671
|
+
const implId = decodeURIComponent(focusMatch[1]);
|
|
1672
|
+
const implementers = await store.listImplementers();
|
|
1673
|
+
const impl = implementers.find(i => i.id === implId);
|
|
1674
|
+
if (!impl) {
|
|
1675
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1676
|
+
res.end(JSON.stringify({ success: false, error: "Implementer not found" }));
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
if (impl.status !== "active") {
|
|
1680
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1681
|
+
res.end(JSON.stringify({ success: false, error: "Implementer is not active" }));
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
const result = await focusTerminalWindow(impl.name);
|
|
1685
|
+
res.writeHead(result.success ? 200 : 400, { "Content-Type": "application/json" });
|
|
1686
|
+
res.end(JSON.stringify(result));
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
// Handle session reset API
|
|
1690
|
+
if (parsed.pathname === "/api/reset" && req.method === "POST") {
|
|
1691
|
+
let body = "";
|
|
1692
|
+
req.on("data", (chunk) => { body += chunk; });
|
|
1693
|
+
req.on("end", async () => {
|
|
1694
|
+
try {
|
|
1695
|
+
const data = JSON.parse(body || "{}");
|
|
1696
|
+
const keepProjectContext = data.keepProjectContext ?? false;
|
|
1697
|
+
const projectRoot = config.roots[0] ?? process.cwd();
|
|
1698
|
+
const result = await store.resetSession(projectRoot, { keepProjectContext });
|
|
1699
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1700
|
+
res.end(JSON.stringify({
|
|
1701
|
+
success: true,
|
|
1702
|
+
...result,
|
|
1703
|
+
message: `Cleared ${result.tasksCleared} tasks, ${result.locksCleared} locks, ${result.notesCleared} notes. Reset ${result.implementersReset} implementers, archived ${result.discussionsArchived} discussions.`
|
|
1704
|
+
}));
|
|
1705
|
+
}
|
|
1706
|
+
catch (error) {
|
|
1707
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1708
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1709
|
+
res.end(JSON.stringify({ success: false, error: message }));
|
|
1710
|
+
}
|
|
1711
|
+
});
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
// Handle stop all implementers API
|
|
1715
|
+
if (parsed.pathname === "/api/stop-all" && req.method === "POST") {
|
|
1716
|
+
try {
|
|
1717
|
+
const implementers = await store.listImplementers();
|
|
1718
|
+
let stoppedCount = 0;
|
|
1719
|
+
for (const impl of implementers) {
|
|
1720
|
+
if (impl.status === "active") {
|
|
1721
|
+
// Try to kill the process if we have a PID
|
|
1722
|
+
if (impl.pid) {
|
|
1723
|
+
try {
|
|
1724
|
+
process.kill(impl.pid, "SIGTERM");
|
|
1725
|
+
}
|
|
1726
|
+
catch {
|
|
1727
|
+
// Process may already be dead
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
await store.updateImplementer(impl.id, "stopped");
|
|
1731
|
+
stoppedCount++;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1735
|
+
res.end(JSON.stringify({ success: true, stoppedCount }));
|
|
1736
|
+
}
|
|
1737
|
+
catch (error) {
|
|
1738
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1739
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1740
|
+
res.end(JSON.stringify({ success: false, error: message }));
|
|
1741
|
+
}
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
// Handle mark project complete API
|
|
1745
|
+
if (parsed.pathname === "/api/complete" && req.method === "POST") {
|
|
1746
|
+
try {
|
|
1747
|
+
const projectRoot = config.roots[0] ?? process.cwd();
|
|
1748
|
+
const context = await store.getProjectContext(projectRoot);
|
|
1749
|
+
if (context) {
|
|
1750
|
+
await store.setProjectContext({
|
|
1751
|
+
...context,
|
|
1752
|
+
status: "complete"
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
// Stop all active implementers
|
|
1756
|
+
const implementers = await store.listImplementers();
|
|
1757
|
+
for (const impl of implementers) {
|
|
1758
|
+
if (impl.status === "active") {
|
|
1759
|
+
if (impl.pid) {
|
|
1760
|
+
try {
|
|
1761
|
+
process.kill(impl.pid, "SIGTERM");
|
|
1762
|
+
}
|
|
1763
|
+
catch {
|
|
1764
|
+
// Process may already be dead
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
await store.updateImplementer(impl.id, "stopped");
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1771
|
+
res.end(JSON.stringify({ success: true }));
|
|
1772
|
+
}
|
|
1773
|
+
catch (error) {
|
|
1774
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1775
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1776
|
+
res.end(JSON.stringify({ success: false, error: message }));
|
|
1777
|
+
}
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
// Handle task approve API
|
|
1781
|
+
const approveMatch = parsed.pathname?.match(/^\/api\/task\/(.+)\/approve$/);
|
|
1782
|
+
if (approveMatch && req.method === "POST") {
|
|
1783
|
+
try {
|
|
1784
|
+
const taskId = decodeURIComponent(approveMatch[1]);
|
|
1785
|
+
await store.approveTask({ id: taskId });
|
|
1786
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1787
|
+
res.end(JSON.stringify({ success: true }));
|
|
1788
|
+
}
|
|
1789
|
+
catch (error) {
|
|
1790
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1791
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1792
|
+
res.end(JSON.stringify({ success: false, error: message }));
|
|
1793
|
+
}
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
// Handle task reject API
|
|
1797
|
+
const rejectMatch = parsed.pathname?.match(/^\/api\/task\/(.+)\/reject$/);
|
|
1798
|
+
if (rejectMatch && req.method === "POST") {
|
|
1799
|
+
let body = "";
|
|
1800
|
+
req.on("data", (chunk) => { body += chunk; });
|
|
1801
|
+
req.on("end", async () => {
|
|
1802
|
+
try {
|
|
1803
|
+
const data = JSON.parse(body || "{}");
|
|
1804
|
+
const taskId = decodeURIComponent(rejectMatch[1]);
|
|
1805
|
+
const feedback = data.feedback || "Changes requested";
|
|
1806
|
+
// Mark task as in_progress with feedback
|
|
1807
|
+
await store.requestTaskChanges({ id: taskId, feedback });
|
|
1808
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1809
|
+
res.end(JSON.stringify({ success: true }));
|
|
1810
|
+
}
|
|
1811
|
+
catch (error) {
|
|
1812
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1813
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1814
|
+
res.end(JSON.stringify({ success: false, error: message }));
|
|
1815
|
+
}
|
|
1816
|
+
});
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
// Handle stop specific implementer API
|
|
1820
|
+
const stopImplMatch = parsed.pathname?.match(/^\/api\/implementer\/(.+)\/stop$/);
|
|
1821
|
+
if (stopImplMatch && req.method === "POST") {
|
|
1822
|
+
try {
|
|
1823
|
+
const implId = decodeURIComponent(stopImplMatch[1]);
|
|
1824
|
+
const implementers = await store.listImplementers();
|
|
1825
|
+
const impl = implementers.find(i => i.id === implId);
|
|
1826
|
+
if (!impl) {
|
|
1827
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1828
|
+
res.end(JSON.stringify({ success: false, error: "Implementer not found" }));
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
// Try to kill the process if we have a PID
|
|
1832
|
+
if (impl.pid) {
|
|
1833
|
+
try {
|
|
1834
|
+
process.kill(impl.pid, "SIGTERM");
|
|
1835
|
+
}
|
|
1836
|
+
catch {
|
|
1837
|
+
// Process may already be dead
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
await store.updateImplementer(impl.id, "stopped");
|
|
1841
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1842
|
+
res.end(JSON.stringify({ success: true }));
|
|
1843
|
+
}
|
|
1844
|
+
catch (error) {
|
|
1845
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1846
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1847
|
+
res.end(JSON.stringify({ success: false, error: message }));
|
|
1848
|
+
}
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
if (parsed.pathname === "/api/state") {
|
|
1852
|
+
const state = await store.status();
|
|
1853
|
+
// Get ALL project contexts and implementers (not filtered by root)
|
|
1854
|
+
const allContexts = await store.listAllProjectContexts();
|
|
1855
|
+
// Use the most recently updated context, or first one
|
|
1856
|
+
const projectContext = allContexts.length > 0
|
|
1857
|
+
? allContexts.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0]
|
|
1858
|
+
: null;
|
|
1859
|
+
// Get implementers and clean up any dead processes
|
|
1860
|
+
const rawImplementers = await store.listImplementers();
|
|
1861
|
+
const implementers = await cleanupDeadImplementers(store, rawImplementers);
|
|
1862
|
+
const payload = {
|
|
1863
|
+
state,
|
|
1864
|
+
projectContext,
|
|
1865
|
+
allContexts,
|
|
1866
|
+
implementers,
|
|
1867
|
+
config: {
|
|
1868
|
+
mode: config.mode,
|
|
1869
|
+
storage: config.storage,
|
|
1870
|
+
roots: config.roots,
|
|
1871
|
+
dataDir: config.dataDir,
|
|
1872
|
+
logDir: config.logDir,
|
|
1873
|
+
},
|
|
1874
|
+
};
|
|
1875
|
+
res.writeHead(200, {
|
|
1876
|
+
"Content-Type": "application/json",
|
|
1877
|
+
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
1878
|
+
"Pragma": "no-cache",
|
|
1879
|
+
"Expires": "0"
|
|
1880
|
+
});
|
|
1881
|
+
res.end(JSON.stringify(payload));
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
res.writeHead(200, {
|
|
1885
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1886
|
+
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
1887
|
+
"Pragma": "no-cache",
|
|
1888
|
+
"Expires": "0"
|
|
1889
|
+
});
|
|
1890
|
+
res.end(DASHBOARD_HTML);
|
|
1891
|
+
});
|
|
1892
|
+
const wss = new WebSocketServer({ server, path: "/ws" });
|
|
1893
|
+
const broadcast = (payload) => {
|
|
1894
|
+
const message = JSON.stringify(payload);
|
|
1895
|
+
for (const client of wss.clients) {
|
|
1896
|
+
if (client.readyState === 1) {
|
|
1897
|
+
client.send(message);
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
};
|
|
1901
|
+
const sendSnapshot = async () => {
|
|
1902
|
+
const state = await store.status();
|
|
1903
|
+
const allContexts = await store.listAllProjectContexts();
|
|
1904
|
+
const projectContext = allContexts.length > 0
|
|
1905
|
+
? allContexts.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0]
|
|
1906
|
+
: null;
|
|
1907
|
+
const rawImplementers = await store.listImplementers();
|
|
1908
|
+
const implementers = await cleanupDeadImplementers(store, rawImplementers);
|
|
1909
|
+
broadcast({
|
|
1910
|
+
type: "snapshot",
|
|
1911
|
+
state,
|
|
1912
|
+
projectContext,
|
|
1913
|
+
allContexts,
|
|
1914
|
+
implementers,
|
|
1915
|
+
config: {
|
|
1916
|
+
mode: config.mode,
|
|
1917
|
+
storage: config.storage,
|
|
1918
|
+
roots: config.roots,
|
|
1919
|
+
dataDir: config.dataDir,
|
|
1920
|
+
logDir: config.logDir,
|
|
1921
|
+
},
|
|
1922
|
+
});
|
|
1923
|
+
};
|
|
1924
|
+
wss.on("connection", () => {
|
|
1925
|
+
sendSnapshot().catch(() => undefined);
|
|
1926
|
+
});
|
|
1927
|
+
let lastHash = "";
|
|
1928
|
+
let lastActiveCount = 0;
|
|
1929
|
+
let pollInterval = null;
|
|
1930
|
+
const poll = async () => {
|
|
1931
|
+
const state = await store.status();
|
|
1932
|
+
const allContexts = await store.listAllProjectContexts();
|
|
1933
|
+
const projectContext = allContexts.length > 0
|
|
1934
|
+
? allContexts.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0]
|
|
1935
|
+
: null;
|
|
1936
|
+
const rawImplementers = await store.listImplementers();
|
|
1937
|
+
const implementers = await cleanupDeadImplementers(store, rawImplementers);
|
|
1938
|
+
// Check if we should use fast or slow polling
|
|
1939
|
+
const activeImpls = implementers.filter(i => i.status === "active").length;
|
|
1940
|
+
const activeTasks = state.tasks.filter(t => t.status === "in_progress" || t.status === "review").length;
|
|
1941
|
+
const activeLocks = state.locks.filter(l => l.status === "active").length;
|
|
1942
|
+
const isActive = activeImpls > 0 || activeTasks > 0 || activeLocks > 0;
|
|
1943
|
+
// Adjust poll interval if activity level changed
|
|
1944
|
+
if ((isActive && lastActiveCount === 0) || (!isActive && lastActiveCount > 0)) {
|
|
1945
|
+
const newPollMs = isActive ? pollMsActive : pollMsIdle;
|
|
1946
|
+
if (pollInterval) {
|
|
1947
|
+
clearInterval(pollInterval);
|
|
1948
|
+
}
|
|
1949
|
+
pollInterval = setInterval(() => {
|
|
1950
|
+
poll().catch(() => undefined);
|
|
1951
|
+
}, newPollMs);
|
|
1952
|
+
console.log(`Poll interval: ${newPollMs}ms (${isActive ? "active" : "idle"})`);
|
|
1953
|
+
}
|
|
1954
|
+
lastActiveCount = isActive ? 1 : 0;
|
|
1955
|
+
const next = JSON.stringify({ state, projectContext, implementers });
|
|
1956
|
+
if (next !== lastHash) {
|
|
1957
|
+
lastHash = next;
|
|
1958
|
+
broadcast({
|
|
1959
|
+
type: "state",
|
|
1960
|
+
state,
|
|
1961
|
+
projectContext,
|
|
1962
|
+
allContexts,
|
|
1963
|
+
implementers,
|
|
1964
|
+
config: {
|
|
1965
|
+
mode: config.mode,
|
|
1966
|
+
storage: config.storage,
|
|
1967
|
+
roots: config.roots,
|
|
1968
|
+
dataDir: config.dataDir,
|
|
1969
|
+
logDir: config.logDir,
|
|
1970
|
+
},
|
|
1971
|
+
});
|
|
1972
|
+
}
|
|
1973
|
+
};
|
|
1974
|
+
await poll();
|
|
1975
|
+
// Start with idle polling, will switch to active if needed
|
|
1976
|
+
pollInterval = setInterval(() => {
|
|
1977
|
+
poll().catch(() => undefined);
|
|
1978
|
+
}, pollMsIdle);
|
|
1979
|
+
server.listen(port, host, () => {
|
|
1980
|
+
process.stdout.write(`Dashboard running at http://${host}:${port}\n`);
|
|
1981
|
+
});
|
|
1982
|
+
}
|