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.
@@ -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
+ '&': '&amp;',
1185
+ '<': '&lt;',
1186
+ '>': '&gt;',
1187
+ '"': '&quot;',
1188
+ "'": '&#39;'
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
+ }