pocketcoder-a1 0.1.0__py3-none-any.whl

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.
a1/dashboard.py ADDED
@@ -0,0 +1,2589 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ PocketCoder-A1 Dashboard — Full-featured Web UI
4
+ """
5
+
6
+ import html as html_mod
7
+ import json
8
+ import socket
9
+ import threading
10
+ import webbrowser
11
+ from datetime import datetime
12
+ from http.server import BaseHTTPRequestHandler, HTTPServer
13
+ from pathlib import Path
14
+ from string import Template
15
+ from urllib.parse import parse_qs, urlparse
16
+
17
+ from .checkpoint import CheckpointManager
18
+ from .tasks import TaskManager
19
+
20
+ PROJECT_DIR = None
21
+ AGENT_RUNNING = False
22
+ AGENT_LOOP = None # Reference to SessionLoop for stop control
23
+ ACTIVITY_LOG = [] # Live activity log
24
+ AGENT_LOG_BUFFER = [] # Live agent output lines for dashboard
25
+
26
+
27
+ def esc(text: str) -> str:
28
+ """Escape HTML to prevent XSS"""
29
+ return html_mod.escape(str(text)) if text else ""
30
+
31
+
32
+ def log_activity(action: str, details: str = "", status: str = "info"):
33
+ """Log activity for dashboard"""
34
+ ACTIVITY_LOG.append({
35
+ "time": datetime.now().strftime("%H:%M:%S"),
36
+ "action": action,
37
+ "details": details,
38
+ "status": status # info, success, error, warning
39
+ })
40
+ # Keep last 100 entries
41
+ if len(ACTIVITY_LOG) > 100:
42
+ ACTIVITY_LOG.pop(0)
43
+
44
+
45
+ def _classify_line(line: str) -> str:
46
+ """Classify a Claude output line into a tool type for icon display"""
47
+ lower = line.lower().strip()
48
+ if any(kw in lower for kw in ["read(", "reading file", "read file", "reading ", "read "]):
49
+ return "read"
50
+ elif any(kw in lower for kw in ["edit(", "editing file", "edit file", "editing "]):
51
+ return "edit"
52
+ elif any(kw in lower for kw in ["write(", "writing file", "write file", "creating file", "created "]):
53
+ return "write"
54
+ elif any(kw in lower for kw in ["bash(", "running:", "$ ", "command", "terminal", "pytest", "ruff "]):
55
+ return "bash"
56
+ elif any(kw in lower for kw in ["let me", "i'll", "i need", "thinking", "analyzing", "looking"]):
57
+ return "thinking"
58
+ return "text"
59
+
60
+
61
+ def _on_agent_line(line: str, event_type: str = None):
62
+ """Parse agent output line and add to live buffer.
63
+ event_type: pre-classified type from stream-json parser
64
+ (read/edit/write/bash/thinking/text/metric/verify)
65
+ If not provided, falls back to heuristic classifier."""
66
+ stripped = line.rstrip("\n")
67
+ if not stripped:
68
+ return
69
+ entry = {
70
+ "time": datetime.now().strftime("%H:%M:%S"),
71
+ "line": stripped,
72
+ "type": event_type if event_type else _classify_line(line),
73
+ }
74
+ AGENT_LOG_BUFFER.append(entry)
75
+ if len(AGENT_LOG_BUFFER) > 500:
76
+ AGENT_LOG_BUFFER.pop(0)
77
+
78
+
79
+ CSS = '''
80
+ :root {
81
+ --bg-primary: #f8f9fa;
82
+ --bg-secondary: #ffffff;
83
+ --bg-tertiary: #e9ecef;
84
+ --text-primary: #212529;
85
+ --text-secondary: #6c757d;
86
+ --border-color: #dee2e6;
87
+ --accent: #6366f1;
88
+ --accent-light: #818cf8;
89
+ --success: #10b981;
90
+ --warning: #f59e0b;
91
+ --danger: #ef4444;
92
+ }
93
+
94
+ [data-theme="dark"] {
95
+ --bg-primary: #1a1a2e;
96
+ --bg-secondary: #16213e;
97
+ --bg-tertiary: #0f0f23;
98
+ --text-primary: #f8f9fa;
99
+ --text-secondary: #9ca3af;
100
+ --border-color: #374151;
101
+ }
102
+
103
+ * { box-sizing: border-box; margin: 0; padding: 0; }
104
+
105
+ body {
106
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
107
+ background: var(--bg-primary);
108
+ color: var(--text-primary);
109
+ min-height: 100vh;
110
+ }
111
+
112
+ .layout {
113
+ display: grid;
114
+ grid-template-columns: 240px 1fr;
115
+ min-height: 100vh;
116
+ }
117
+
118
+ /* Sidebar */
119
+ .sidebar {
120
+ background: var(--bg-secondary);
121
+ border-right: 1px solid var(--border-color);
122
+ padding: 20px;
123
+ position: sticky;
124
+ top: 0;
125
+ height: 100vh;
126
+ overflow-y: auto;
127
+ }
128
+
129
+ .logo {
130
+ font-size: 18px;
131
+ font-weight: bold;
132
+ margin-bottom: 8px;
133
+ display: flex;
134
+ align-items: center;
135
+ gap: 8px;
136
+ }
137
+
138
+ .logo-sub {
139
+ font-size: 12px;
140
+ color: var(--text-secondary);
141
+ margin-bottom: 30px;
142
+ }
143
+
144
+ .nav-section {
145
+ margin-bottom: 24px;
146
+ }
147
+
148
+ .nav-title {
149
+ font-size: 11px;
150
+ text-transform: uppercase;
151
+ letter-spacing: 1px;
152
+ color: var(--text-secondary);
153
+ margin-bottom: 12px;
154
+ }
155
+
156
+ .nav-item {
157
+ display: flex;
158
+ align-items: center;
159
+ gap: 10px;
160
+ padding: 10px 12px;
161
+ border-radius: 8px;
162
+ color: var(--text-primary);
163
+ text-decoration: none;
164
+ margin-bottom: 4px;
165
+ transition: background 0.2s;
166
+ }
167
+
168
+ .nav-item:hover {
169
+ background: var(--bg-tertiary);
170
+ }
171
+
172
+ .nav-item.active {
173
+ background: var(--accent);
174
+ color: white;
175
+ }
176
+
177
+ .nav-item i {
178
+ width: 18px;
179
+ text-align: center;
180
+ }
181
+
182
+ /* Main content */
183
+ .main {
184
+ padding: 24px 32px;
185
+ overflow-y: auto;
186
+ }
187
+
188
+ .header {
189
+ display: flex;
190
+ justify-content: space-between;
191
+ align-items: center;
192
+ margin-bottom: 24px;
193
+ }
194
+
195
+ .page-title {
196
+ font-size: 24px;
197
+ font-weight: 600;
198
+ }
199
+
200
+ .header-actions {
201
+ display: flex;
202
+ gap: 12px;
203
+ align-items: center;
204
+ }
205
+
206
+ /* Status badge */
207
+ .status {
208
+ display: inline-flex;
209
+ align-items: center;
210
+ gap: 6px;
211
+ padding: 6px 14px;
212
+ border-radius: 20px;
213
+ font-size: 13px;
214
+ font-weight: 500;
215
+ }
216
+
217
+ .status-running { background: var(--success); color: white; }
218
+ .status-stopped { background: var(--danger); color: white; }
219
+ .status-completed { background: var(--accent); color: white; }
220
+
221
+ /* Cards */
222
+ .cards {
223
+ display: grid;
224
+ grid-template-columns: repeat(3, 1fr);
225
+ gap: 16px;
226
+ margin-bottom: 24px;
227
+ }
228
+
229
+ .card {
230
+ background: var(--bg-secondary);
231
+ border: 1px solid var(--border-color);
232
+ border-radius: 12px;
233
+ padding: 20px;
234
+ transition: transform 0.2s, box-shadow 0.2s;
235
+ }
236
+ .card:hover {
237
+ transform: translateY(-2px);
238
+ box-shadow: 0 8px 25px rgba(0,0,0,0.08);
239
+ }
240
+ [data-theme="dark"] .card:hover {
241
+ box-shadow: 0 8px 25px rgba(0,0,0,0.3);
242
+ }
243
+
244
+ .card-title {
245
+ font-size: 12px;
246
+ text-transform: uppercase;
247
+ letter-spacing: 0.5px;
248
+ color: var(--text-secondary);
249
+ margin-bottom: 8px;
250
+ display: flex;
251
+ align-items: center;
252
+ gap: 6px;
253
+ }
254
+
255
+ .card-value {
256
+ font-size: 32px;
257
+ font-weight: 600;
258
+ }
259
+
260
+ .card-sub {
261
+ font-size: 13px;
262
+ color: var(--text-secondary);
263
+ margin-top: 4px;
264
+ }
265
+
266
+ /* Progress bar */
267
+ .progress {
268
+ height: 6px;
269
+ background: var(--bg-tertiary);
270
+ border-radius: 3px;
271
+ margin-top: 12px;
272
+ overflow: hidden;
273
+ }
274
+
275
+ .progress-fill {
276
+ height: 100%;
277
+ background: linear-gradient(90deg, var(--accent), var(--success));
278
+ border-radius: 3px;
279
+ transition: width 0.3s;
280
+ }
281
+
282
+ /* Tasks list */
283
+ .task-list {
284
+ background: var(--bg-secondary);
285
+ border: 1px solid var(--border-color);
286
+ border-radius: 12px;
287
+ overflow: hidden;
288
+ }
289
+
290
+ .task-header {
291
+ padding: 16px 20px;
292
+ border-bottom: 1px solid var(--border-color);
293
+ display: flex;
294
+ justify-content: space-between;
295
+ align-items: center;
296
+ }
297
+
298
+ .task-header h3 {
299
+ font-size: 16px;
300
+ font-weight: 600;
301
+ }
302
+
303
+ .task {
304
+ display: flex;
305
+ align-items: center;
306
+ padding: 14px 20px;
307
+ border-bottom: 1px solid var(--border-color);
308
+ transition: background 0.2s;
309
+ }
310
+
311
+ .task:last-child {
312
+ border-bottom: none;
313
+ }
314
+
315
+ .task:hover {
316
+ background: var(--bg-tertiary);
317
+ }
318
+
319
+ .task-check {
320
+ width: 22px;
321
+ height: 22px;
322
+ border-radius: 50%;
323
+ border: 2px solid var(--border-color);
324
+ margin-right: 14px;
325
+ display: flex;
326
+ align-items: center;
327
+ justify-content: center;
328
+ font-size: 12px;
329
+ flex-shrink: 0;
330
+ }
331
+
332
+ .task-check.done {
333
+ background: var(--success);
334
+ border-color: var(--success);
335
+ color: white;
336
+ }
337
+
338
+ .task-check.progress {
339
+ background: var(--warning);
340
+ border-color: var(--warning);
341
+ color: white;
342
+ }
343
+
344
+ .task-content {
345
+ flex: 1;
346
+ }
347
+
348
+ .task-title {
349
+ font-weight: 500;
350
+ margin-bottom: 4px;
351
+ }
352
+
353
+ .task-meta {
354
+ font-size: 12px;
355
+ color: var(--text-secondary);
356
+ }
357
+
358
+ .task-phase {
359
+ background: var(--bg-tertiary);
360
+ padding: 4px 10px;
361
+ border-radius: 12px;
362
+ font-size: 11px;
363
+ color: var(--text-secondary);
364
+ }
365
+
366
+ /* Activity log */
367
+ .activity {
368
+ background: var(--bg-secondary);
369
+ border: 1px solid var(--border-color);
370
+ border-radius: 12px;
371
+ margin-top: 24px;
372
+ }
373
+
374
+ .activity-header {
375
+ padding: 16px 20px;
376
+ border-bottom: 1px solid var(--border-color);
377
+ font-weight: 600;
378
+ }
379
+
380
+ .activity-list {
381
+ max-height: 300px;
382
+ overflow-y: auto;
383
+ }
384
+
385
+ .activity-item {
386
+ display: flex;
387
+ gap: 12px;
388
+ padding: 12px 20px;
389
+ border-bottom: 1px solid var(--border-color);
390
+ font-size: 13px;
391
+ }
392
+
393
+ .activity-item:last-child {
394
+ border-bottom: none;
395
+ }
396
+
397
+ .activity-time {
398
+ color: var(--text-secondary);
399
+ font-family: monospace;
400
+ flex-shrink: 0;
401
+ }
402
+
403
+ .activity-dot {
404
+ width: 8px;
405
+ height: 8px;
406
+ border-radius: 50%;
407
+ margin-top: 5px;
408
+ flex-shrink: 0;
409
+ }
410
+
411
+ .activity-dot.info { background: var(--accent); }
412
+ .activity-dot.success { background: var(--success); }
413
+ .activity-dot.warning { background: var(--warning); }
414
+ .activity-dot.error { background: var(--danger); }
415
+
416
+ .activity-text {
417
+ flex: 1;
418
+ }
419
+
420
+ .activity-details {
421
+ color: var(--text-secondary);
422
+ }
423
+
424
+ /* Forms */
425
+ .form-section {
426
+ background: var(--bg-secondary);
427
+ border: 1px solid var(--border-color);
428
+ border-radius: 12px;
429
+ padding: 20px;
430
+ margin-top: 24px;
431
+ }
432
+
433
+ .form-section h3 {
434
+ font-size: 16px;
435
+ margin-bottom: 16px;
436
+ }
437
+
438
+ .form-row {
439
+ display: flex;
440
+ gap: 12px;
441
+ margin-bottom: 12px;
442
+ }
443
+
444
+ input[type="text"] {
445
+ flex: 1;
446
+ padding: 10px 14px;
447
+ border: 1px solid var(--border-color);
448
+ border-radius: 8px;
449
+ background: var(--bg-primary);
450
+ color: var(--text-primary);
451
+ font-size: 14px;
452
+ }
453
+
454
+ input[type="text"]:focus {
455
+ outline: none;
456
+ border-color: var(--accent);
457
+ box-shadow: 0 0 0 3px rgba(99,102,241,0.15);
458
+ }
459
+
460
+ input[type="text"]::placeholder {
461
+ color: var(--text-secondary);
462
+ }
463
+
464
+ textarea {
465
+ width: 100%;
466
+ padding: 10px 14px;
467
+ border: 1px solid var(--border-color);
468
+ border-radius: 8px;
469
+ background: var(--bg-primary);
470
+ color: var(--text-primary);
471
+ font-size: 14px;
472
+ font-family: inherit;
473
+ resize: vertical;
474
+ min-height: 60px;
475
+ box-sizing: border-box;
476
+ }
477
+ textarea:focus {
478
+ outline: none;
479
+ border-color: var(--accent);
480
+ box-shadow: 0 0 0 3px rgba(99,102,241,0.15);
481
+ }
482
+ textarea::placeholder {
483
+ color: var(--text-secondary);
484
+ }
485
+
486
+ .priority-badge {
487
+ display: inline-block;
488
+ background: var(--accent);
489
+ color: white;
490
+ padding: 1px 7px;
491
+ border-radius: 10px;
492
+ font-size: 11px;
493
+ font-weight: 600;
494
+ margin-left: 4px;
495
+ }
496
+
497
+ .task[draggable="true"] {
498
+ cursor: grab;
499
+ }
500
+ .task[draggable="true"]:active {
501
+ cursor: grabbing;
502
+ }
503
+ .task.drag-over {
504
+ border-top: 2px solid var(--accent);
505
+ }
506
+
507
+ /* Terminal-style log panel */
508
+ .log-panel {
509
+ background: #1e1e2e;
510
+ border-radius: 12px;
511
+ margin-top: 24px;
512
+ overflow: hidden;
513
+ box-shadow: 0 4px 24px rgba(0,0,0,0.3);
514
+ border: 1px solid #313244;
515
+ }
516
+ .log-panel-header {
517
+ padding: 10px 16px;
518
+ background: #181825;
519
+ border-bottom: 1px solid #313244;
520
+ display: flex;
521
+ justify-content: space-between;
522
+ align-items: center;
523
+ }
524
+ .terminal-dots {
525
+ display: flex;
526
+ gap: 6px;
527
+ align-items: center;
528
+ }
529
+ .terminal-dots span {
530
+ width: 12px;
531
+ height: 12px;
532
+ border-radius: 50%;
533
+ display: inline-block;
534
+ }
535
+ .terminal-dots .dot-red { background: #f38ba8; }
536
+ .terminal-dots .dot-yellow { background: #f9e2af; }
537
+ .terminal-dots .dot-green { background: #a6e3a1; }
538
+ .terminal-title {
539
+ font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;
540
+ font-size: 12px;
541
+ color: #6c7086;
542
+ letter-spacing: 0.5px;
543
+ }
544
+ .log-feed {
545
+ max-height: 340px;
546
+ overflow-y: auto;
547
+ padding: 4px 0;
548
+ scrollbar-width: thin;
549
+ scrollbar-color: #45475a #1e1e2e;
550
+ }
551
+ .log-feed::-webkit-scrollbar { width: 6px; }
552
+ .log-feed::-webkit-scrollbar-track { background: #1e1e2e; }
553
+ .log-feed::-webkit-scrollbar-thumb { background: #45475a; border-radius: 3px; }
554
+ .log-entry {
555
+ display: flex;
556
+ gap: 8px;
557
+ padding: 3px 16px;
558
+ font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;
559
+ font-size: 12px;
560
+ align-items: flex-start;
561
+ line-height: 1.5;
562
+ transition: background 0.15s;
563
+ }
564
+ .log-entry:hover {
565
+ background: rgba(69, 71, 90, 0.3);
566
+ }
567
+ .log-entry i {
568
+ width: 16px;
569
+ text-align: center;
570
+ flex-shrink: 0;
571
+ font-size: 11px;
572
+ margin-top: 2px;
573
+ }
574
+ .log-time {
575
+ color: #585b70;
576
+ font-size: 11px;
577
+ flex-shrink: 0;
578
+ user-select: none;
579
+ }
580
+ .log-text {
581
+ flex: 1;
582
+ overflow: hidden;
583
+ text-overflow: ellipsis;
584
+ white-space: nowrap;
585
+ color: #cdd6f4;
586
+ }
587
+ .log-prompt {
588
+ color: #a6e3a1;
589
+ flex-shrink: 0;
590
+ user-select: none;
591
+ }
592
+ .log-cursor {
593
+ display: inline-block;
594
+ width: 7px;
595
+ height: 14px;
596
+ background: #a6e3a1;
597
+ animation: blink 1s step-end infinite;
598
+ margin-left: 4px;
599
+ vertical-align: text-bottom;
600
+ }
601
+ @keyframes blink {
602
+ 0%, 100% { opacity: 1; }
603
+ 50% { opacity: 0; }
604
+ }
605
+ .log-empty {
606
+ padding: 20px 16px;
607
+ color: #585b70;
608
+ font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;
609
+ font-size: 12px;
610
+ text-align: center;
611
+ }
612
+ .raw-log {
613
+ max-height: 300px;
614
+ overflow-y: auto;
615
+ padding: 12px 16px;
616
+ font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;
617
+ font-size: 11px;
618
+ white-space: pre-wrap;
619
+ word-break: break-all;
620
+ background: #11111b;
621
+ color: #a6adc8;
622
+ border-top: 1px solid #313244;
623
+ }
624
+ .log-toggle {
625
+ font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;
626
+ font-size: 11px;
627
+ color: #89b4fa;
628
+ cursor: pointer;
629
+ background: none;
630
+ border: none;
631
+ padding: 2px 8px;
632
+ border-radius: 4px;
633
+ transition: background 0.15s;
634
+ }
635
+ .log-toggle:hover {
636
+ background: rgba(137, 180, 250, 0.1);
637
+ }
638
+ /* Pixel art indicator */
639
+ .px-icon {
640
+ display: inline-block;
641
+ width: 6px;
642
+ height: 6px;
643
+ border-radius: 1px;
644
+ flex-shrink: 0;
645
+ margin-top: 5px;
646
+ image-rendering: pixelated;
647
+ box-shadow: 1px 0 0 0 currentColor, 0 1px 0 0 currentColor;
648
+ }
649
+ .log-label {
650
+ font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;
651
+ font-size: 10px;
652
+ font-weight: 600;
653
+ letter-spacing: 0.5px;
654
+ min-width: 44px;
655
+ flex-shrink: 0;
656
+ text-transform: uppercase;
657
+ }
658
+
659
+ button {
660
+ padding: 10px 20px;
661
+ border: none;
662
+ border-radius: 8px;
663
+ font-size: 14px;
664
+ font-weight: 500;
665
+ cursor: pointer;
666
+ display: inline-flex;
667
+ align-items: center;
668
+ gap: 6px;
669
+ transition: transform 0.1s, opacity 0.2s;
670
+ }
671
+
672
+ button:hover { filter: brightness(1.1); }
673
+ button:active { transform: scale(0.98); }
674
+
675
+ .btn-primary { background: var(--accent); color: white; }
676
+ .btn-primary:hover { box-shadow: 0 4px 12px rgba(99,102,241,0.3); }
677
+ .btn-success { background: var(--success); color: white; }
678
+ .btn-success:hover { box-shadow: 0 4px 12px rgba(16,185,129,0.3); }
679
+ .btn-danger { background: var(--danger); color: white; }
680
+ .btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); }
681
+
682
+ /* Global select styling */
683
+ select {
684
+ padding: 10px 14px;
685
+ border: 1px solid var(--border-color);
686
+ border-radius: 8px;
687
+ background: var(--bg-primary);
688
+ color: var(--text-primary);
689
+ font-size: 14px;
690
+ cursor: pointer;
691
+ width: 100%;
692
+ transition: border-color 0.2s, box-shadow 0.2s;
693
+ }
694
+ select:focus {
695
+ outline: none;
696
+ border-color: var(--accent);
697
+ box-shadow: 0 0 0 3px rgba(99,102,241,0.15);
698
+ }
699
+ input[type="number"] {
700
+ padding: 10px 14px;
701
+ border: 1px solid var(--border-color);
702
+ border-radius: 8px;
703
+ background: var(--bg-primary);
704
+ color: var(--text-primary);
705
+ font-size: 14px;
706
+ width: 100%;
707
+ transition: border-color 0.2s, box-shadow 0.2s;
708
+ }
709
+ input[type="number"]:focus {
710
+ outline: none;
711
+ border-color: var(--accent);
712
+ box-shadow: 0 0 0 3px rgba(99,102,241,0.15);
713
+ }
714
+ input[type="password"] {
715
+ padding: 10px 14px;
716
+ border: 1px solid var(--border-color);
717
+ border-radius: 8px;
718
+ background: var(--bg-primary);
719
+ color: var(--text-primary);
720
+ font-size: 14px;
721
+ font-family: monospace;
722
+ width: 100%;
723
+ transition: border-color 0.2s, box-shadow 0.2s;
724
+ }
725
+ input[type="password"]:focus {
726
+ outline: none;
727
+ border-color: var(--accent);
728
+ box-shadow: 0 0 0 3px rgba(99,102,241,0.15);
729
+ }
730
+
731
+ /* Styled scrollbars */
732
+ .activity-list::-webkit-scrollbar { width: 6px; }
733
+ .activity-list::-webkit-scrollbar-track { background: transparent; }
734
+ .activity-list::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
735
+
736
+ /* Toast notification */
737
+ .toast {
738
+ position: fixed;
739
+ bottom: 24px;
740
+ right: 24px;
741
+ padding: 12px 20px;
742
+ background: var(--success);
743
+ color: white;
744
+ border-radius: 8px;
745
+ font-size: 14px;
746
+ font-weight: 500;
747
+ box-shadow: 0 4px 12px rgba(0,0,0,0.2);
748
+ z-index: 1000;
749
+ animation: toast-in 0.3s ease, toast-out 0.3s ease 2s forwards;
750
+ }
751
+ @keyframes toast-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
752
+ @keyframes toast-out { from { opacity: 1; } to { opacity: 0; transform: translateY(10px); } }
753
+
754
+ /* Settings-specific styles */
755
+ .settings-label {
756
+ font-size: 12px;
757
+ color: var(--text-secondary);
758
+ margin-bottom: 4px;
759
+ display: block;
760
+ }
761
+ .settings-grid {
762
+ display: grid;
763
+ grid-template-columns: 1fr 1fr;
764
+ gap: 12px;
765
+ margin-top: 8px;
766
+ }
767
+ .settings-hint {
768
+ color: var(--text-secondary);
769
+ font-size: 12px;
770
+ margin-top: 6px;
771
+ }
772
+
773
+ /* Commit styling */
774
+ .commit-hash {
775
+ font-family: 'SF Mono', 'Fira Code', monospace;
776
+ font-size: 12px;
777
+ color: var(--text-secondary);
778
+ background: var(--bg-tertiary);
779
+ padding: 2px 8px;
780
+ border-radius: 4px;
781
+ }
782
+ .commit-msg {
783
+ font-weight: 500;
784
+ }
785
+ .commit-time {
786
+ font-size: 12px;
787
+ color: var(--text-secondary);
788
+ }
789
+
790
+ /* Control buttons */
791
+ .controls {
792
+ display: flex;
793
+ gap: 12px;
794
+ margin-top: 24px;
795
+ }
796
+
797
+ .controls form {
798
+ flex: 1;
799
+ }
800
+
801
+ .controls button {
802
+ width: 100%;
803
+ padding: 14px;
804
+ font-size: 15px;
805
+ }
806
+
807
+ /* Theme toggle */
808
+ .theme-toggle {
809
+ background: none;
810
+ border: 1px solid var(--border-color);
811
+ padding: 8px 12px;
812
+ cursor: pointer;
813
+ border-radius: 8px;
814
+ color: var(--text-primary);
815
+ }
816
+
817
+ /* Session details */
818
+ .session-card {
819
+ background: var(--bg-secondary);
820
+ border: 1px solid var(--border-color);
821
+ border-radius: 12px;
822
+ padding: 20px;
823
+ margin-bottom: 16px;
824
+ }
825
+
826
+ .session-header {
827
+ display: flex;
828
+ justify-content: space-between;
829
+ align-items: center;
830
+ margin-bottom: 16px;
831
+ }
832
+
833
+ .session-title {
834
+ font-weight: 600;
835
+ font-size: 16px;
836
+ }
837
+
838
+ .session-meta {
839
+ display: grid;
840
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
841
+ gap: 16px;
842
+ }
843
+
844
+ .meta-item {
845
+ display: flex;
846
+ flex-direction: column;
847
+ gap: 4px;
848
+ }
849
+
850
+ .meta-label {
851
+ font-size: 11px;
852
+ text-transform: uppercase;
853
+ color: var(--text-secondary);
854
+ }
855
+
856
+ .meta-value {
857
+ font-weight: 500;
858
+ }
859
+
860
+ /* Empty state */
861
+ .empty {
862
+ text-align: center;
863
+ padding: 40px;
864
+ color: var(--text-secondary);
865
+ }
866
+
867
+ .empty i {
868
+ font-size: 48px;
869
+ margin-bottom: 16px;
870
+ opacity: 0.5;
871
+ }
872
+
873
+ /* Pulsing indicator for running state */
874
+ .pulse-dot {
875
+ display: inline-block;
876
+ width: 8px;
877
+ height: 8px;
878
+ border-radius: 50%;
879
+ background: #fff;
880
+ margin: 0 2px;
881
+ animation: pulse-anim 1.5s ease-in-out infinite;
882
+ }
883
+
884
+ @keyframes pulse-anim {
885
+ 0%, 100% { opacity: 1; transform: scale(1); }
886
+ 50% { opacity: 0.4; transform: scale(0.7); }
887
+ }
888
+
889
+ /* Live timer styling */
890
+ .live-timer {
891
+ font-family: monospace;
892
+ font-variant-numeric: tabular-nums;
893
+ }
894
+
895
+ @media (max-width: 1200px) {
896
+ .cards {
897
+ grid-template-columns: repeat(2, 1fr);
898
+ }
899
+ }
900
+
901
+ @media (max-width: 768px) {
902
+ .layout {
903
+ grid-template-columns: 1fr;
904
+ }
905
+ .sidebar {
906
+ position: fixed;
907
+ left: -260px;
908
+ z-index: 100;
909
+ transition: left 0.3s;
910
+ width: 240px;
911
+ }
912
+ .sidebar.open {
913
+ left: 0;
914
+ }
915
+ .hamburger {
916
+ display: block !important;
917
+ }
918
+ .cards {
919
+ grid-template-columns: 1fr;
920
+ }
921
+ .log-feed {
922
+ max-height: none;
923
+ }
924
+ }
925
+
926
+ .hamburger {
927
+ display: none;
928
+ background: none;
929
+ border: 1px solid var(--border-color);
930
+ padding: 8px 12px;
931
+ cursor: pointer;
932
+ border-radius: 8px;
933
+ color: var(--text-primary);
934
+ font-size: 18px;
935
+ }
936
+
937
+ /* Task detail expandable — slide transition */
938
+ .task-detail {
939
+ max-height: 0;
940
+ overflow: hidden;
941
+ padding: 0 20px 0 56px;
942
+ border-bottom: 1px solid var(--border-color);
943
+ transition: max-height 0.3s ease, padding 0.3s ease;
944
+ }
945
+ .task-detail.open {
946
+ max-height: 400px;
947
+ padding: 12px 20px 16px 56px;
948
+ }
949
+
950
+ .task-stages {
951
+ display: flex;
952
+ gap: 4px;
953
+ margin-bottom: 12px;
954
+ }
955
+
956
+ .stage-step {
957
+ flex: 1;
958
+ height: 6px;
959
+ border-radius: 3px;
960
+ background: var(--bg-tertiary);
961
+ }
962
+
963
+ .stage-step.active {
964
+ background: var(--warning);
965
+ }
966
+
967
+ .stage-step.done {
968
+ background: var(--success);
969
+ }
970
+
971
+ .task-detail-meta {
972
+ display: grid;
973
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
974
+ gap: 8px;
975
+ font-size: 12px;
976
+ color: var(--text-secondary);
977
+ }
978
+
979
+ .task-detail-meta dt {
980
+ font-weight: 600;
981
+ color: var(--text-primary);
982
+ }
983
+
984
+ .task-criteria {
985
+ margin-top: 8px;
986
+ padding: 8px 12px;
987
+ background: var(--bg-tertiary);
988
+ border-radius: 8px;
989
+ font-size: 12px;
990
+ }
991
+
992
+ .task-clickable {
993
+ cursor: pointer;
994
+ }
995
+ '''
996
+
997
+ HTML_TEMPLATE = Template('''<!DOCTYPE html>
998
+ <html lang="en" data-theme="$theme">
999
+ <head>
1000
+ <meta charset="UTF-8">
1001
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1002
+ <title>PocketCoder-A1 Dashboard</title>
1003
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
1004
+ <style>''' + CSS + '''</style>
1005
+ </head>
1006
+ <body>
1007
+ <div class="layout">
1008
+ <aside class="sidebar">
1009
+ <div class="logo">
1010
+ <i class="bi bi-robot"></i> PocketCoder-A1
1011
+ </div>
1012
+ <div class="logo-sub">Autonomous Gnome v0.1.0</div>
1013
+
1014
+ <nav>
1015
+ <div class="nav-section">
1016
+ <div class="nav-title">Overview</div>
1017
+ <a href="/" class="nav-item $nav_dashboard">
1018
+ <i class="bi bi-speedometer2"></i> Dashboard
1019
+ </a>
1020
+ <a href="/tasks" class="nav-item $nav_tasks">
1021
+ <i class="bi bi-list-check"></i> Tasks
1022
+ </a>
1023
+ <a href="/sessions" class="nav-item $nav_sessions">
1024
+ <i class="bi bi-terminal"></i> Sessions
1025
+ </a>
1026
+ </div>
1027
+
1028
+ <div class="nav-section">
1029
+ <div class="nav-title">Activity</div>
1030
+ <a href="/log" class="nav-item $nav_log">
1031
+ <i class="bi bi-journal-text"></i> Activity Log
1032
+ </a>
1033
+ <a href="/commits" class="nav-item $nav_commits">
1034
+ <i class="bi bi-git"></i> Commits
1035
+ </a>
1036
+ <a href="/transform" class="nav-item $nav_transform">
1037
+ <i class="bi bi-magic"></i> Transform
1038
+ </a>
1039
+ </div>
1040
+
1041
+ <div class="nav-section">
1042
+ <div class="nav-title">Settings</div>
1043
+ <a href="/settings" class="nav-item $nav_settings">
1044
+ <i class="bi bi-gear"></i> Settings
1045
+ </a>
1046
+ </div>
1047
+ </nav>
1048
+ </aside>
1049
+
1050
+ <main class="main">
1051
+ <button class="hamburger" onclick="document.querySelector('.sidebar').classList.toggle('open')">
1052
+ <i class="bi bi-list"></i>
1053
+ </button>
1054
+ $content
1055
+ </main>
1056
+ </div>
1057
+
1058
+ <script>
1059
+ // Theme toggle
1060
+ function toggleTheme() {
1061
+ const html = document.documentElement;
1062
+ const current = html.getAttribute('data-theme');
1063
+ const next = current === 'dark' ? 'light' : 'dark';
1064
+ html.setAttribute('data-theme', next);
1065
+ localStorage.setItem('theme', next);
1066
+ }
1067
+
1068
+ // Load saved theme
1069
+ const saved = localStorage.getItem('theme');
1070
+ if (saved) {
1071
+ document.documentElement.setAttribute('data-theme', saved);
1072
+ }
1073
+
1074
+ // Escape HTML
1075
+ function escHtml(s) {
1076
+ const d = document.createElement('div');
1077
+ d.textContent = s;
1078
+ return d.innerHTML;
1079
+ }
1080
+
1081
+ // --- AJAX polling (dashboard page only) ---
1082
+ const pageName = '$page_name';
1083
+ let logIndex = 0;
1084
+
1085
+ function fmtTokens(n) {
1086
+ if (!n || n === 0) return '0';
1087
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
1088
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
1089
+ return n.toString();
1090
+ }
1091
+
1092
+ function fmtDuration(s) {
1093
+ if (!s || s <= 0) return '0s';
1094
+ if (s < 60) return s + 's';
1095
+ const m = Math.floor(s / 60);
1096
+ const sec = s % 60;
1097
+ return m + 'm ' + sec + 's';
1098
+ }
1099
+
1100
+ function estimateCost(tokensIn, tokensOut, cacheRead) {
1101
+ // Claude Opus pricing (approximate): 15/M input, 75/M output, 1.5/M cache read
1102
+ const costIn = (tokensIn - (cacheRead || 0)) * 15 / 1000000;
1103
+ const costCache = (cacheRead || 0) * 1.5 / 1000000;
1104
+ const costOut = tokensOut * 75 / 1000000;
1105
+ return Math.max(0, costIn + costCache + costOut);
1106
+ }
1107
+
1108
+ function updateStatus() {
1109
+ fetch('/api/status')
1110
+ .then(r => r.json())
1111
+ .then(data => {
1112
+ const badge = document.getElementById('status-badge');
1113
+ if (badge) {
1114
+ if (data.running) {
1115
+ badge.className = 'status status-running';
1116
+ badge.innerHTML = '<i class="bi bi-play-circle-fill"></i> <span class="pulse-dot"></span> Running';
1117
+ } else if (data.checkpoint && data.checkpoint.status === 'COMPLETED') {
1118
+ badge.className = 'status status-completed';
1119
+ badge.innerHTML = '<i class="bi bi-check-circle-fill"></i> Completed';
1120
+ } else {
1121
+ badge.className = 'status status-stopped';
1122
+ badge.innerHTML = '<i class="bi bi-stop-circle-fill"></i> Stopped';
1123
+ }
1124
+ }
1125
+ const tc = document.getElementById('task-count');
1126
+ if (tc && data.progress) {
1127
+ tc.textContent = data.progress[0] + '/' + data.progress[1];
1128
+ }
1129
+ const sc = document.getElementById('session-count');
1130
+ if (sc && data.checkpoint) {
1131
+ sc.textContent = '#' + data.checkpoint.session;
1132
+ }
1133
+ const fc = document.getElementById('files-count');
1134
+ if (fc && data.checkpoint) {
1135
+ fc.textContent = (data.checkpoint.files_modified || []).length;
1136
+ }
1137
+ // Token metrics
1138
+ const m = data.metrics || {};
1139
+ const tokC = document.getElementById('tokens-count');
1140
+ if (tokC) {
1141
+ tokC.textContent = fmtTokens(m.tokens_in || 0) + ' / ' + fmtTokens(m.tokens_out || 0);
1142
+ }
1143
+ const tokSub = document.getElementById('tokens-sub');
1144
+ if (tokSub && m.cache_read) {
1145
+ tokSub.textContent = 'cache: ' + fmtTokens(m.cache_read);
1146
+ }
1147
+ const tokBar = document.getElementById('tokens-bar');
1148
+ if (tokBar) {
1149
+ const pct = Math.min(100, (m.context_percent || 0) * 100);
1150
+ tokBar.style.width = pct + '%';
1151
+ // Change color at threshold
1152
+ if (pct >= 70) tokBar.style.background = '#ef4444';
1153
+ else if (pct >= 50) tokBar.style.background = '#f59e0b';
1154
+ else tokBar.style.background = 'var(--accent)';
1155
+ }
1156
+ // Context percent text
1157
+ const ctxPct = document.getElementById('context-pct');
1158
+ if (ctxPct) {
1159
+ const pct = Math.round((m.context_percent || 0) * 100);
1160
+ ctxPct.textContent = pct + '%';
1161
+ }
1162
+ // Cost
1163
+ const costEl = document.getElementById('cost-value');
1164
+ if (costEl) {
1165
+ const cost = estimateCost(m.tokens_in || 0, m.tokens_out || 0, m.cache_read || 0);
1166
+ costEl.textContent = '$$' + cost.toFixed(3);
1167
+ }
1168
+ // Duration
1169
+ const durEl = document.getElementById('duration-value');
1170
+ if (durEl) {
1171
+ durEl.textContent = fmtDuration(m.session_duration || 0);
1172
+ }
1173
+ // Queue message section
1174
+ const qms = document.getElementById('queue-msg-section');
1175
+ if (qms) {
1176
+ qms.style.display = data.running ? 'block' : 'none';
1177
+ }
1178
+ })
1179
+ .catch(() => {});
1180
+ }
1181
+
1182
+ function updateLog() {
1183
+ fetch('/api/log?since=' + logIndex)
1184
+ .then(r => r.json())
1185
+ .then(data => {
1186
+ if (data.entries && data.entries.length > 0) {
1187
+ const feed = document.getElementById('action-feed');
1188
+ const rawlog = document.getElementById('raw-log');
1189
+ if (feed && rawlog) {
1190
+ data.entries.forEach(e => {
1191
+ const labelMap = {read:'READ', edit:'EDIT', write:'WRITE', bash:'BASH', thinking:'THINK', text:'OUT', metric:'METRIC', verify:'CHECK'};
1192
+ const colorMap = {read:'#89b4fa', edit:'#fab387', write:'#a6e3a1', bash:'#cba6f7', thinking:'#f9e2af', text:'#6c7086', metric:'#89dceb', verify:'#a6e3a1'};
1193
+ const label = labelMap[e.type] || 'LOG';
1194
+ const color = colorMap[e.type] || '#6c7086';
1195
+ const pixelIcon = '<span class="px-icon" style="background:' + color + '"></span>';
1196
+ feed.innerHTML += '<div class="log-entry">' + pixelIcon + '<span class="log-time">' + e.time + '</span><span class="log-label" style="color:' + color + '">' + label + '</span><span class="log-text">' + escHtml(e.line.substring(0,150)) + '</span></div>';
1197
+ rawlog.textContent += e.line + '\\n';
1198
+ });
1199
+ feed.scrollTop = feed.scrollHeight;
1200
+ rawlog.scrollTop = rawlog.scrollHeight;
1201
+ }
1202
+ logIndex = data.total;
1203
+ }
1204
+ })
1205
+ .catch(() => {});
1206
+ }
1207
+
1208
+ function sendQueueMessage(e) {
1209
+ e.preventDefault();
1210
+ const input = document.getElementById('queue-msg-input');
1211
+ const status = document.getElementById('queue-msg-status');
1212
+ if (!input.value.trim()) return;
1213
+ fetch('/queue-message', {
1214
+ method: 'POST',
1215
+ headers: {'Content-Type': 'application/x-www-form-urlencoded'},
1216
+ body: 'message=' + encodeURIComponent(input.value)
1217
+ }).then(r => r.json()).then(data => {
1218
+ status.textContent = 'Message queued at ' + new Date().toLocaleTimeString();
1219
+ input.value = '';
1220
+ }).catch(() => { status.textContent = 'Failed to send'; });
1221
+ }
1222
+
1223
+ // Live timer tick (updates every second when running)
1224
+ let sessionStartTime = 0;
1225
+ let agentRunning = false;
1226
+
1227
+ function tickTimer() {
1228
+ if (!agentRunning || !sessionStartTime) return;
1229
+ const elapsed = Math.floor(Date.now() / 1000 - sessionStartTime);
1230
+ const durEl = document.getElementById('duration-value');
1231
+ if (durEl) durEl.textContent = fmtDuration(elapsed);
1232
+ }
1233
+
1234
+ // Toggle task detail on tasks page
1235
+ function toggleTaskDetail(taskId) {
1236
+ const el = document.getElementById('detail-' + taskId);
1237
+ if (el) el.classList.toggle('open');
1238
+ }
1239
+
1240
+ if (pageName === 'dashboard') {
1241
+ // Wrap updateStatus to also track timer state
1242
+ const _origUpdateStatus = updateStatus;
1243
+ updateStatus = function() {
1244
+ fetch('/api/status')
1245
+ .then(r => r.json())
1246
+ .then(data => {
1247
+ agentRunning = data.running;
1248
+ if (data.metrics && data.metrics.session_start) {
1249
+ sessionStartTime = data.metrics.session_start;
1250
+ }
1251
+ }).catch(() => {});
1252
+ _origUpdateStatus();
1253
+ };
1254
+ setInterval(updateStatus, 3000);
1255
+ setInterval(updateLog, 2000);
1256
+ setInterval(tickTimer, 1000);
1257
+ updateStatus();
1258
+ } else if (pageName === 'tasks') {
1259
+ // No auto-reload on tasks page (drag-drop needs stable DOM)
1260
+ } else {
1261
+ setTimeout(() => location.reload(), 5000);
1262
+ }
1263
+
1264
+ // --- Drag and Drop (tasks page) ---
1265
+ if (pageName === 'tasks') {
1266
+ let dragSrc = null;
1267
+ document.querySelectorAll('.task[draggable]').forEach(el => {
1268
+ el.addEventListener('dragstart', e => {
1269
+ dragSrc = el;
1270
+ el.style.opacity = '0.4';
1271
+ e.dataTransfer.effectAllowed = 'move';
1272
+ });
1273
+ el.addEventListener('dragover', e => {
1274
+ e.preventDefault();
1275
+ e.dataTransfer.dropEffect = 'move';
1276
+ el.classList.add('drag-over');
1277
+ });
1278
+ el.addEventListener('dragleave', () => {
1279
+ el.classList.remove('drag-over');
1280
+ });
1281
+ el.addEventListener('drop', e => {
1282
+ e.preventDefault();
1283
+ el.classList.remove('drag-over');
1284
+ if (dragSrc && dragSrc !== el) {
1285
+ el.parentNode.insertBefore(dragSrc, el);
1286
+ const order = [...document.querySelectorAll('.task[data-task-id]')]
1287
+ .map(t => t.dataset.taskId);
1288
+ fetch('/api/reorder', {
1289
+ method: 'POST',
1290
+ headers: {'Content-Type': 'application/json'},
1291
+ body: JSON.stringify({order})
1292
+ }).then(() => location.reload());
1293
+ }
1294
+ });
1295
+ el.addEventListener('dragend', () => {
1296
+ el.style.opacity = '';
1297
+ });
1298
+ });
1299
+ }
1300
+ </script>
1301
+ </body>
1302
+ </html>
1303
+ ''')
1304
+
1305
+
1306
+ class DashboardHandler(BaseHTTPRequestHandler):
1307
+ def log_message(self, format, *args):
1308
+ pass
1309
+
1310
+ def do_GET(self):
1311
+ path = urlparse(self.path).path
1312
+
1313
+ if path == '/' or path == '/index.html':
1314
+ self.send_page('dashboard')
1315
+ elif path == '/tasks':
1316
+ self.send_page('tasks')
1317
+ elif path == '/sessions':
1318
+ self.send_page('sessions')
1319
+ elif path == '/log':
1320
+ self.send_page('log')
1321
+ elif path == '/commits':
1322
+ self.send_page('commits')
1323
+ elif path == '/settings':
1324
+ self.send_page('settings')
1325
+ elif path == '/transform':
1326
+ self.send_page('transform')
1327
+ elif path == '/api/status':
1328
+ self.send_json_status()
1329
+ elif path.startswith('/api/log'):
1330
+ self.send_json_log()
1331
+ elif path == '/api/config':
1332
+ self.send_json_config()
1333
+ else:
1334
+ self.send_error(404)
1335
+
1336
+ def do_POST(self):
1337
+ content_length = int(self.headers.get('Content-Length', 0))
1338
+ post_data = self.rfile.read(content_length).decode('utf-8')
1339
+ params = parse_qs(post_data)
1340
+
1341
+ if self.path == '/add-task':
1342
+ task_text = params.get('task', [''])[0]
1343
+ task_desc = params.get('description', [''])[0]
1344
+ if task_text:
1345
+ tasks = TaskManager(PROJECT_DIR)
1346
+ tasks.add_task(task_text, description=task_desc)
1347
+ log_activity("Task added", task_text, "success")
1348
+ self.redirect('/')
1349
+
1350
+ elif self.path == '/add-thought':
1351
+ thought = params.get('thought', [''])[0]
1352
+ if thought:
1353
+ tasks = TaskManager(PROJECT_DIR)
1354
+ tasks.add_raw_thought(thought)
1355
+ log_activity("Thought added", thought, "info")
1356
+ self.redirect('/')
1357
+
1358
+ elif self.path == '/start':
1359
+ self.start_agent()
1360
+ self.redirect('/')
1361
+
1362
+ elif self.path == '/stop':
1363
+ self.stop_agent()
1364
+ self.redirect('/')
1365
+
1366
+ elif self.path == '/toggle-theme':
1367
+ self.redirect('/')
1368
+ elif self.path == '/transform':
1369
+ raw_text = params.get('text', [''])[0]
1370
+ if raw_text:
1371
+ result = self._transform_text(raw_text)
1372
+ self.send_response(200)
1373
+ self.send_header('Content-Type', 'application/json')
1374
+ self.end_headers()
1375
+ self.wfile.write(json.dumps(result).encode('utf-8'))
1376
+ else:
1377
+ self.send_response(400)
1378
+ self.end_headers()
1379
+
1380
+ elif self.path == '/transform-confirm':
1381
+ try:
1382
+ body = json.loads(post_data)
1383
+ tasks_list = body.get('tasks', [])
1384
+ tasks_mgr = TaskManager(PROJECT_DIR)
1385
+ added = 0
1386
+ for t in tasks_list:
1387
+ if t.get('title'):
1388
+ tasks_mgr.add_task(t['title'], description=t.get('description', ''))
1389
+ added += 1
1390
+ log_activity("Transform confirmed", f"{added} tasks added", "success")
1391
+ self.send_response(200)
1392
+ self.send_header('Content-Type', 'application/json')
1393
+ self.end_headers()
1394
+ self.wfile.write(json.dumps({"ok": True, "added": added}).encode('utf-8'))
1395
+ except Exception:
1396
+ self.send_response(400)
1397
+ self.end_headers()
1398
+
1399
+ elif self.path == '/queue-message':
1400
+ msg_text = params.get('message', [''])[0]
1401
+ if msg_text:
1402
+ queue_file = PROJECT_DIR / '.a1' / 'queue.json'
1403
+ queue_data = {"messages": []}
1404
+ if queue_file.exists():
1405
+ try:
1406
+ queue_data = json.loads(queue_file.read_text())
1407
+ except (json.JSONDecodeError, IOError):
1408
+ pass
1409
+ queue_data["messages"].append({
1410
+ "text": msg_text,
1411
+ "added_at": datetime.now().isoformat(),
1412
+ "read": False,
1413
+ })
1414
+ queue_file.write_text(json.dumps(queue_data, indent=2, ensure_ascii=False))
1415
+ log_activity("Message queued", msg_text[:50], "info")
1416
+ self.send_response(200)
1417
+ self.send_header('Content-Type', 'application/json')
1418
+ self.end_headers()
1419
+ self.wfile.write(b'{"ok": true}')
1420
+
1421
+ elif self.path == '/api/config':
1422
+ try:
1423
+ body = json.loads(post_data)
1424
+ from .config import Config
1425
+ config = Config(PROJECT_DIR)
1426
+ for key, value in body.items():
1427
+ config.set(key, value)
1428
+ log_activity("Config updated", ", ".join(body.keys()), "info")
1429
+ self.send_response(200)
1430
+ self.send_header('Content-Type', 'application/json')
1431
+ self.end_headers()
1432
+ self.wfile.write(json.dumps({"ok": True}).encode('utf-8'))
1433
+ except Exception as e:
1434
+ self.send_response(400)
1435
+ self.send_header('Content-Type', 'application/json')
1436
+ self.end_headers()
1437
+ self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
1438
+
1439
+ elif self.path == '/api/reorder':
1440
+ try:
1441
+ body = json.loads(post_data)
1442
+ task_ids = body.get('order', [])
1443
+ if task_ids:
1444
+ tasks_mgr = TaskManager(PROJECT_DIR)
1445
+ tasks_mgr.reorder_tasks(task_ids)
1446
+ log_activity("Tasks reordered", f"{len(task_ids)} tasks", "info")
1447
+ self.send_response(200)
1448
+ self.send_header('Content-Type', 'application/json')
1449
+ self.end_headers()
1450
+ self.wfile.write(b'{"ok": true}')
1451
+ except Exception:
1452
+ self.send_response(400)
1453
+ self.end_headers()
1454
+ else:
1455
+ self.send_error(404)
1456
+
1457
+ def redirect(self, location):
1458
+ self.send_response(302)
1459
+ self.send_header('Location', location)
1460
+ self.end_headers()
1461
+
1462
+ def send_page(self, page):
1463
+ theme = 'light' # Default light theme
1464
+ content = self.build_content(page)
1465
+
1466
+ nav_active = {
1467
+ 'nav_dashboard': 'active' if page == 'dashboard' else '',
1468
+ 'nav_tasks': 'active' if page == 'tasks' else '',
1469
+ 'nav_sessions': 'active' if page == 'sessions' else '',
1470
+ 'nav_log': 'active' if page == 'log' else '',
1471
+ 'nav_commits': 'active' if page == 'commits' else '',
1472
+ 'nav_transform': 'active' if page == 'transform' else '',
1473
+ 'nav_settings': 'active' if page == 'settings' else '',
1474
+ }
1475
+
1476
+ html = HTML_TEMPLATE.substitute(
1477
+ theme=theme,
1478
+ content=content,
1479
+ page_name=page,
1480
+ **nav_active
1481
+ )
1482
+
1483
+ self.send_response(200)
1484
+ self.send_header('Content-Type', 'text/html; charset=utf-8')
1485
+ self.end_headers()
1486
+ self.wfile.write(html.encode('utf-8'))
1487
+
1488
+ def build_content(self, page):
1489
+ if page == 'dashboard':
1490
+ return self.build_dashboard()
1491
+ elif page == 'tasks':
1492
+ return self.build_tasks_page()
1493
+ elif page == 'sessions':
1494
+ return self.build_sessions_page()
1495
+ elif page == 'log':
1496
+ return self.build_log_page()
1497
+ elif page == 'commits':
1498
+ return self.build_commits_page()
1499
+ elif page == 'settings':
1500
+ return self.build_settings_page()
1501
+ elif page == 'transform':
1502
+ return self.build_transform_page()
1503
+ return ''
1504
+
1505
+ def build_dashboard(self):
1506
+ checkpoint = CheckpointManager(PROJECT_DIR)
1507
+ tasks_mgr = TaskManager(PROJECT_DIR)
1508
+
1509
+ cp = checkpoint.load()
1510
+ all_tasks = tasks_mgr.get_tasks()
1511
+ done, total = tasks_mgr.get_progress()
1512
+ progress = int((done / total * 100)) if total > 0 else 0
1513
+
1514
+ # Status
1515
+ if AGENT_RUNNING:
1516
+ status_class = 'status-running'
1517
+ status_text = '<i class="bi bi-play-circle-fill"></i> Running'
1518
+ elif cp.get('status') == 'COMPLETED':
1519
+ status_class = 'status-completed'
1520
+ status_text = '<i class="bi bi-check-circle-fill"></i> Completed'
1521
+ else:
1522
+ status_class = 'status-stopped'
1523
+ status_text = '<i class="bi bi-stop-circle-fill"></i> Stopped'
1524
+
1525
+ # Tasks HTML (last 5, sorted by priority)
1526
+ sorted_tasks = sorted(all_tasks, key=lambda t: (t.status == 'done', t.priority))
1527
+ tasks_html = ''
1528
+ for t in sorted_tasks[:5]:
1529
+ if t.status == 'done':
1530
+ check_class = 'done'
1531
+ check_icon = '<i class="bi bi-check"></i>'
1532
+ elif t.status == 'in_progress':
1533
+ check_class = 'progress'
1534
+ check_icon = '<i class="bi bi-arrow-repeat"></i>'
1535
+ else:
1536
+ check_class = ''
1537
+ check_icon = ''
1538
+
1539
+ pri = getattr(t, 'priority', 0)
1540
+ pri_badge = f'<span class="priority-badge">#{pri}</span>' if pri and t.status != 'done' else ''
1541
+
1542
+ tasks_html += f'''
1543
+ <div class="task">
1544
+ <div class="task-check {check_class}">{check_icon}</div>
1545
+ <div class="task-content">
1546
+ <div class="task-title">{esc(t.title)}</div>
1547
+ <div class="task-meta">{esc(t.id)} {pri_badge}</div>
1548
+ </div>
1549
+ </div>
1550
+ '''
1551
+
1552
+ if not tasks_html:
1553
+ tasks_html = '<div class="empty"><i class="bi bi-inbox"></i><p>No tasks yet</p></div>'
1554
+
1555
+ # Activity HTML (last 5) with status icons
1556
+ status_icons = {
1557
+ 'success': '<i class="bi bi-check-circle-fill" style="color:var(--success)"></i>',
1558
+ 'error': '<i class="bi bi-x-circle-fill" style="color:var(--danger)"></i>',
1559
+ 'warning': '<i class="bi bi-exclamation-triangle-fill" style="color:var(--warning)"></i>',
1560
+ 'info': '<i class="bi bi-info-circle-fill" style="color:var(--accent)"></i>',
1561
+ }
1562
+ activity_html = ''
1563
+ for a in reversed(ACTIVITY_LOG[-5:]):
1564
+ icon = status_icons.get(a['status'], status_icons['info'])
1565
+ activity_html += f'''
1566
+ <div class="activity-item">
1567
+ <span class="activity-time">{esc(a['time'])}</span>
1568
+ <span style="flex-shrink:0">{icon}</span>
1569
+ <span class="activity-text"><strong>{esc(a['action'])}</strong> <span class="activity-details">{esc(a['details'])}</span></span>
1570
+ </div>
1571
+ '''
1572
+
1573
+ if not activity_html:
1574
+ activity_html = '<div class="empty" style="padding:20px"><i class="bi bi-clock-history"></i><p>No activity yet</p></div>'
1575
+
1576
+ # Control buttons
1577
+ if AGENT_RUNNING:
1578
+ control_html = '''
1579
+ <form method="POST" action="/stop">
1580
+ <button type="submit" class="btn-danger"><i class="bi bi-stop-fill"></i> Stop Agent</button>
1581
+ </form>
1582
+ '''
1583
+ else:
1584
+ control_html = '''
1585
+ <form method="POST" action="/start">
1586
+ <button type="submit" class="btn-success"><i class="bi bi-play-fill"></i> Start Agent</button>
1587
+ </form>
1588
+ '''
1589
+
1590
+ return f'''
1591
+ <div class="header">
1592
+ <h1 class="page-title">Dashboard</h1>
1593
+ <div class="header-actions">
1594
+ <span class="status {status_class}" id="status-badge">{status_text}</span>
1595
+ <button class="theme-toggle" onclick="toggleTheme()">
1596
+ <i class="bi bi-moon-stars"></i>
1597
+ </button>
1598
+ </div>
1599
+ </div>
1600
+
1601
+ <div class="cards">
1602
+ <div class="card">
1603
+ <div class="card-title"><i class="bi bi-check2-square"></i> Tasks</div>
1604
+ <div class="card-value" id="task-count">{done}/{total}</div>
1605
+ <div class="card-sub">completed</div>
1606
+ <div class="progress">
1607
+ <div class="progress-fill" style="width: {progress}%"></div>
1608
+ </div>
1609
+ </div>
1610
+ <div class="card">
1611
+ <div class="card-title"><i class="bi bi-terminal"></i> Session</div>
1612
+ <div class="card-value" id="session-count">#{cp.get('session', 0)}</div>
1613
+ <div class="card-sub">{cp.get('status', 'Not started')}</div>
1614
+ </div>
1615
+ <div class="card">
1616
+ <div class="card-title"><i class="bi bi-lightning-charge" style="color:#f59e0b"></i> Tokens</div>
1617
+ <div class="card-value" id="tokens-count" style="font-size:22px">0 / 0</div>
1618
+ <div class="card-sub" id="tokens-sub">in / out</div>
1619
+ <div class="progress">
1620
+ <div class="progress-fill" id="tokens-bar" style="width: 0%"></div>
1621
+ </div>
1622
+ <div class="card-sub" style="margin-top:4px">Context: <strong id="context-pct">0%</strong> <span style="color:var(--muted);font-size:11px">(auto-save at 70%)</span></div>
1623
+ </div>
1624
+ <div class="card">
1625
+ <div class="card-title"><i class="bi bi-currency-dollar" style="color:#10b981"></i> Cost</div>
1626
+ <div class="card-value" id="cost-value" style="font-size:22px">$$0.00</div>
1627
+ <div class="card-sub">this session</div>
1628
+ </div>
1629
+ <div class="card">
1630
+ <div class="card-title"><i class="bi bi-stopwatch" style="color:#6366f1"></i> Duration</div>
1631
+ <div class="card-value" id="duration-value">0s</div>
1632
+ <div class="card-sub" id="duration-sub">current session</div>
1633
+ </div>
1634
+ <div class="card">
1635
+ <div class="card-title"><i class="bi bi-file-earmark-code"></i> Files</div>
1636
+ <div class="card-value" id="files-count">{len(cp.get('files_modified', []))}</div>
1637
+ <div class="card-sub">modified</div>
1638
+ </div>
1639
+ </div>
1640
+
1641
+ <div class="task-list">
1642
+ <div class="task-header">
1643
+ <h3>Recent Tasks</h3>
1644
+ <a href="/tasks" class="btn-secondary" style="text-decoration:none">View All</a>
1645
+ </div>
1646
+ {tasks_html}
1647
+ </div>
1648
+
1649
+ <div class="form-section">
1650
+ <h3>Quick Add</h3>
1651
+ <form method="POST" action="/add-task">
1652
+ <div class="form-row">
1653
+ <input type="text" name="task" placeholder="Task title..." required>
1654
+ <button type="submit" class="btn-primary"><i class="bi bi-plus"></i> Add</button>
1655
+ </div>
1656
+ <textarea name="description" placeholder="Description (optional)..." rows="2"></textarea>
1657
+ </form>
1658
+ </div>
1659
+
1660
+ <div class="controls">
1661
+ {control_html}
1662
+ </div>
1663
+
1664
+ <div class="form-section" id="queue-msg-section" style="{'display:block' if AGENT_RUNNING else 'display:none'}">
1665
+ <h3><i class="bi bi-envelope"></i> Message to Agent</h3>
1666
+ <form onsubmit="sendQueueMessage(event)">
1667
+ <div class="form-row">
1668
+ <input type="text" id="queue-msg-input" placeholder="Send instruction to agent (next session)..." required>
1669
+ <button type="submit" class="btn-primary"><i class="bi bi-send"></i> Send</button>
1670
+ </div>
1671
+ </form>
1672
+ <div id="queue-msg-status" style="font-size:12px;color:var(--text-secondary);margin-top:6px"></div>
1673
+ </div>
1674
+
1675
+ <div class="log-panel">
1676
+ <div class="log-panel-header">
1677
+ <div style="display:flex;align-items:center;gap:12px">
1678
+ <div class="terminal-dots">
1679
+ <span class="dot-red"></span>
1680
+ <span class="dot-yellow"></span>
1681
+ <span class="dot-green"></span>
1682
+ </div>
1683
+ <span class="terminal-title">agent@pocketcoder ~ live-log</span>
1684
+ </div>
1685
+ <button class="log-toggle" onclick="document.getElementById('raw-log-wrap').style.display = document.getElementById('raw-log-wrap').style.display === 'none' ? 'block' : 'none'">raw</button>
1686
+ </div>
1687
+ <div class="log-feed" id="action-feed">
1688
+ {self._render_log_entries()}
1689
+ </div>
1690
+ <div id="raw-log-wrap" style="display:none">
1691
+ <div class="raw-log" id="raw-log">{self._render_raw_log()}</div>
1692
+ </div>
1693
+ </div>
1694
+
1695
+ <div class="activity">
1696
+ <div class="activity-header">Recent Activity</div>
1697
+ <div class="activity-list">
1698
+ {activity_html}
1699
+ </div>
1700
+ </div>
1701
+ '''
1702
+
1703
+ def build_tasks_page(self):
1704
+ tasks_mgr = TaskManager(PROJECT_DIR)
1705
+ all_tasks = tasks_mgr.get_tasks()
1706
+ all_tasks.sort(key=lambda t: (t.status == 'done', t.priority))
1707
+ thoughts = tasks_mgr.get_raw_thoughts()
1708
+
1709
+ tasks_html = ''
1710
+ for t in all_tasks:
1711
+ if t.status == 'done':
1712
+ check_class = 'done'
1713
+ check_icon = '<i class="bi bi-check"></i>'
1714
+ elif t.status == 'in_progress':
1715
+ check_class = 'progress'
1716
+ check_icon = '<i class="bi bi-arrow-repeat"></i>'
1717
+ else:
1718
+ check_class = ''
1719
+ check_icon = ''
1720
+
1721
+ desc = t.description[:100] if t.description else ''
1722
+ pri = getattr(t, 'priority', 0)
1723
+ pri_badge = f'<span class="priority-badge">#{pri}</span>' if pri and t.status != 'done' else ''
1724
+ draggable = 'draggable="true"' if t.status != 'done' else ''
1725
+
1726
+ # Stage progress bar
1727
+ stage_steps = ''
1728
+ if t.status == 'done':
1729
+ stage_steps = '<div class="stage-step done"></div><div class="stage-step done"></div><div class="stage-step done"></div>'
1730
+ elif t.status == 'in_progress':
1731
+ stage_steps = '<div class="stage-step done"></div><div class="stage-step active"></div><div class="stage-step"></div>'
1732
+ else:
1733
+ stage_steps = '<div class="stage-step"></div><div class="stage-step"></div><div class="stage-step"></div>'
1734
+
1735
+ # Task detail metadata
1736
+ phase_text = getattr(t, 'phase', '') or ''
1737
+ criteria = getattr(t, 'success_criteria', '') or ''
1738
+ created = getattr(t, 'created_at', '') or ''
1739
+ completed = getattr(t, 'completed_at', '') or ''
1740
+
1741
+ detail_html = f'''
1742
+ <div class="task-detail" id="detail-{t.id}">
1743
+ <div class="task-stages">{stage_steps}</div>
1744
+ <div class="task-detail-meta">
1745
+ <div><dt>Status</dt><dd>{esc(t.status)}</dd></div>
1746
+ <div><dt>Phase</dt><dd>{esc(phase_text) if phase_text else 'N/A'}</dd></div>
1747
+ <div><dt>Created</dt><dd>{esc(created[:10]) if created else 'N/A'}</dd></div>
1748
+ <div><dt>Completed</dt><dd>{esc(completed[:10]) if completed else '—'}</dd></div>
1749
+ </div>
1750
+ {f'<div class="task-criteria"><strong>Criteria:</strong> {esc(criteria)}</div>' if criteria else ''}
1751
+ {f'<div style="margin-top:6px;font-size:12px;color:var(--text-secondary)">{esc(desc)}</div>' if desc else ''}
1752
+ </div>
1753
+ '''
1754
+
1755
+ tasks_html += f'''
1756
+ <div class="task task-clickable" {draggable} data-task-id="{t.id}" onclick="toggleTaskDetail('{t.id}')">
1757
+ <div class="task-check {check_class}">{check_icon}</div>
1758
+ <div class="task-content">
1759
+ <div class="task-title">{esc(t.title)}</div>
1760
+ <div class="task-meta">{esc(t.id)} {pri_badge}</div>
1761
+ </div>
1762
+ <i class="bi bi-chevron-down" style="color:var(--text-secondary);font-size:14px"></i>
1763
+ </div>
1764
+ {detail_html}
1765
+ '''
1766
+
1767
+ thoughts_html = ''
1768
+ if thoughts:
1769
+ for th in thoughts:
1770
+ thoughts_html += f'''
1771
+ <div class="task">
1772
+ <div class="task-check"><i class="bi bi-lightbulb"></i></div>
1773
+ <div class="task-content">
1774
+ <div class="task-title">{esc(th['text'])}</div>
1775
+ <div class="task-meta">Raw thought</div>
1776
+ </div>
1777
+ </div>
1778
+ '''
1779
+
1780
+ return f'''
1781
+ <div class="header">
1782
+ <h1 class="page-title">Tasks</h1>
1783
+ <button class="theme-toggle" onclick="toggleTheme()">
1784
+ <i class="bi bi-moon-stars"></i>
1785
+ </button>
1786
+ </div>
1787
+
1788
+ <div class="task-list">
1789
+ <div class="task-header">
1790
+ <h3>All Tasks ({len(all_tasks)})</h3>
1791
+ </div>
1792
+ {tasks_html if tasks_html else '<div class="empty"><i class="bi bi-inbox"></i><p>No tasks yet</p></div>'}
1793
+ </div>
1794
+
1795
+ {('<div class="task-list" style="margin-top:24px"><div class="task-header"><h3>Raw Thoughts</h3></div>' + thoughts_html + '</div>') if thoughts_html else ''}
1796
+
1797
+ <div class="form-section">
1798
+ <h3>Add Task</h3>
1799
+ <form method="POST" action="/add-task">
1800
+ <div class="form-row">
1801
+ <input type="text" name="task" placeholder="Task title..." required>
1802
+ <button type="submit" class="btn-primary"><i class="bi bi-plus"></i> Add Task</button>
1803
+ </div>
1804
+ <textarea name="description" placeholder="Description (optional)..." rows="3"></textarea>
1805
+ </form>
1806
+ <form method="POST" action="/add-thought" style="margin-top:12px">
1807
+ <div class="form-row">
1808
+ <input type="text" name="thought" placeholder="Quick thought or idea...">
1809
+ <button type="submit" class="btn-secondary"><i class="bi bi-lightbulb"></i> Add Thought</button>
1810
+ </div>
1811
+ </form>
1812
+ </div>
1813
+ '''
1814
+
1815
+ def build_sessions_page(self):
1816
+ checkpoint = CheckpointManager(PROJECT_DIR)
1817
+ cp = checkpoint.load()
1818
+
1819
+ sessions_html = ''
1820
+ checkpoints_dir = PROJECT_DIR / '.a1' / 'checkpoints'
1821
+ if checkpoints_dir.exists():
1822
+ for f in sorted(checkpoints_dir.glob('session_*.json'), reverse=True)[:10]:
1823
+ try:
1824
+ data = json.loads(f.read_text())
1825
+ session_num = data.get('session', '?')
1826
+ status = data.get('status', 'Unknown')
1827
+ files = len(data.get('files_modified', []))
1828
+
1829
+ # Status badge color
1830
+ if status == 'COMPLETED':
1831
+ badge_cls = 'status-completed'
1832
+ elif status == 'WORKING':
1833
+ badge_cls = 'status-running'
1834
+ else:
1835
+ badge_cls = 'status-stopped'
1836
+
1837
+ # Extract metrics from checkpoint data
1838
+ sm = data.get('session_metrics', {})
1839
+ tok_in = sm.get('tokens_in', 0)
1840
+ tok_out = sm.get('tokens_out', 0)
1841
+ duration = sm.get('session_duration', 0)
1842
+ tools = sm.get('tools_used', 0)
1843
+
1844
+ # Format duration
1845
+ dur_str = f'{duration // 60}m {duration % 60}s' if duration >= 60 else f'{duration}s'
1846
+
1847
+ # Format tokens
1848
+ def _fmt_tok(n):
1849
+ if n >= 1000000: return f'{n/1000000:.1f}M'
1850
+ if n >= 1000: return f'{n/1000:.1f}K'
1851
+ return str(n)
1852
+
1853
+ sessions_html += f'''
1854
+ <div class="session-card">
1855
+ <div class="session-header">
1856
+ <span class="session-title">Session #{session_num}</span>
1857
+ <span class="status {badge_cls}">{esc(status)}</span>
1858
+ </div>
1859
+ <div class="session-meta">
1860
+ <div class="meta-item">
1861
+ <span class="meta-label"><i class="bi bi-file-earmark-code"></i> Files</span>
1862
+ <span class="meta-value">{files}</span>
1863
+ </div>
1864
+ <div class="meta-item">
1865
+ <span class="meta-label"><i class="bi bi-check2-square"></i> Task</span>
1866
+ <span class="meta-value">{esc(data.get('current_task', 'N/A'))}</span>
1867
+ </div>
1868
+ <div class="meta-item">
1869
+ <span class="meta-label"><i class="bi bi-lightning-charge"></i> Tokens</span>
1870
+ <span class="meta-value">{_fmt_tok(tok_in)} / {_fmt_tok(tok_out)}</span>
1871
+ </div>
1872
+ <div class="meta-item">
1873
+ <span class="meta-label"><i class="bi bi-stopwatch"></i> Duration</span>
1874
+ <span class="meta-value">{dur_str}</span>
1875
+ </div>
1876
+ <div class="meta-item">
1877
+ <span class="meta-label"><i class="bi bi-tools"></i> Tool Calls</span>
1878
+ <span class="meta-value">{tools}</span>
1879
+ </div>
1880
+ </div>
1881
+ </div>
1882
+ '''
1883
+ except Exception:
1884
+ pass
1885
+
1886
+ # Current session status badge
1887
+ if AGENT_RUNNING:
1888
+ cur_badge = 'status-running'
1889
+ cur_label = '<i class="bi bi-play-circle-fill"></i> Running'
1890
+ elif cp.get('status') == 'COMPLETED':
1891
+ cur_badge = 'status-completed'
1892
+ cur_label = '<i class="bi bi-check-circle-fill"></i> Completed'
1893
+ elif cp.get('status') == 'WORKING':
1894
+ cur_badge = 'status-running'
1895
+ cur_label = '<i class="bi bi-arrow-repeat"></i> Working'
1896
+ else:
1897
+ cur_badge = 'status-stopped'
1898
+ cur_label = '<i class="bi bi-stop-circle-fill"></i> Idle'
1899
+
1900
+ return f'''
1901
+ <div class="header">
1902
+ <h1 class="page-title"><i class="bi bi-terminal"></i> Sessions</h1>
1903
+ <button class="theme-toggle" onclick="toggleTheme()">
1904
+ <i class="bi bi-moon-stars"></i>
1905
+ </button>
1906
+ </div>
1907
+
1908
+ <div class="session-card" style="border-left: 3px solid var(--accent)">
1909
+ <div class="session-header">
1910
+ <span class="session-title">Current Session #{cp.get('session', 0)}</span>
1911
+ <span class="status {cur_badge}">{cur_label}</span>
1912
+ </div>
1913
+ <div class="session-meta">
1914
+ <div class="meta-item">
1915
+ <span class="meta-label"><i class="bi bi-flag"></i> Status</span>
1916
+ <span class="meta-value">{esc(cp.get('status', 'Not started'))}</span>
1917
+ </div>
1918
+ <div class="meta-item">
1919
+ <span class="meta-label"><i class="bi bi-pie-chart"></i> Context</span>
1920
+ <span class="meta-value">{cp.get('context_percent', 0)}%</span>
1921
+ </div>
1922
+ <div class="meta-item">
1923
+ <span class="meta-label"><i class="bi bi-check2-square"></i> Current Task</span>
1924
+ <span class="meta-value">{esc(cp.get('current_task', 'None'))}</span>
1925
+ </div>
1926
+ <div class="meta-item">
1927
+ <span class="meta-label"><i class="bi bi-file-earmark-code"></i> Files Modified</span>
1928
+ <span class="meta-value">{len(cp.get('files_modified', []))}</span>
1929
+ </div>
1930
+ </div>
1931
+ </div>
1932
+
1933
+ <h3 style="margin: 24px 0 16px; color: var(--text-secondary); font-size: 14px; text-transform: uppercase; letter-spacing: 1px">Previous Sessions</h3>
1934
+ {sessions_html if sessions_html else '<div class="empty"><i class="bi bi-clock-history"></i><p>No previous sessions</p></div>'}
1935
+ '''
1936
+
1937
+ def build_log_page(self):
1938
+ # Icons per status type
1939
+ status_icons = {
1940
+ 'success': '<i class="bi bi-check-circle-fill" style="color:var(--success)"></i>',
1941
+ 'error': '<i class="bi bi-x-circle-fill" style="color:var(--danger)"></i>',
1942
+ 'warning': '<i class="bi bi-exclamation-triangle-fill" style="color:var(--warning)"></i>',
1943
+ 'info': '<i class="bi bi-info-circle-fill" style="color:var(--accent)"></i>',
1944
+ }
1945
+
1946
+ activity_html = ''
1947
+ for a in reversed(ACTIVITY_LOG):
1948
+ icon = status_icons.get(a['status'], status_icons['info'])
1949
+ activity_html += f'''
1950
+ <div class="activity-item">
1951
+ <span class="activity-time">{esc(a['time'])}</span>
1952
+ <span style="flex-shrink:0">{icon}</span>
1953
+ <span class="activity-text">
1954
+ <strong>{esc(a['action'])}</strong>
1955
+ <span class="activity-details">{esc(a['details'])}</span>
1956
+ </span>
1957
+ </div>
1958
+ '''
1959
+
1960
+ return f'''
1961
+ <div class="header">
1962
+ <h1 class="page-title"><i class="bi bi-journal-text"></i> Activity Log</h1>
1963
+ <button class="theme-toggle" onclick="toggleTheme()">
1964
+ <i class="bi bi-moon-stars"></i>
1965
+ </button>
1966
+ </div>
1967
+
1968
+ <div class="activity">
1969
+ <div class="activity-header">
1970
+ All Activity
1971
+ <span style="font-weight:400;color:var(--text-secondary);font-size:13px;margin-left:8px">{len(ACTIVITY_LOG)} entries</span>
1972
+ </div>
1973
+ <div class="activity-list" style="max-height:none">
1974
+ {activity_html if activity_html else '<div class="empty"><i class="bi bi-clock-history"></i><p>No activity yet</p></div>'}
1975
+ </div>
1976
+ </div>
1977
+ '''
1978
+
1979
+ def build_commits_page(self):
1980
+ # Get git log with dates and full formatting
1981
+ import subprocess
1982
+ commits_html = ''
1983
+ try:
1984
+ result = subprocess.run(
1985
+ ['git', 'log', '--format=%h|%s|%cr|%an', '-20'],
1986
+ cwd=PROJECT_DIR,
1987
+ capture_output=True,
1988
+ text=True
1989
+ )
1990
+ if result.returncode == 0:
1991
+ for line in result.stdout.strip().split('\n'):
1992
+ if line and '|' in line:
1993
+ parts = line.split('|', 3)
1994
+ hash_short = parts[0] if len(parts) > 0 else ''
1995
+ msg = parts[1] if len(parts) > 1 else ''
1996
+ rel_time = parts[2] if len(parts) > 2 else ''
1997
+ author = parts[3] if len(parts) > 3 else ''
1998
+
1999
+ # Split commit message: first line = title, rest = body
2000
+ msg_title = esc(msg)
2001
+
2002
+ # Icon based on conventional commit prefix
2003
+ if msg.startswith('feat'):
2004
+ icon_cls = 'bi-plus-circle'
2005
+ icon_color = 'var(--success)'
2006
+ elif msg.startswith('fix'):
2007
+ icon_cls = 'bi-bug'
2008
+ icon_color = 'var(--danger)'
2009
+ elif msg.startswith('docs') or msg.startswith('doc'):
2010
+ icon_cls = 'bi-file-text'
2011
+ icon_color = 'var(--accent)'
2012
+ elif msg.startswith('refactor') or msg.startswith('chore'):
2013
+ icon_cls = 'bi-arrow-repeat'
2014
+ icon_color = 'var(--warning)'
2015
+ elif msg.startswith('test'):
2016
+ icon_cls = 'bi-check2-circle'
2017
+ icon_color = '#89dceb'
2018
+ else:
2019
+ icon_cls = 'bi-git'
2020
+ icon_color = 'var(--text-secondary)'
2021
+
2022
+ commits_html += f'''
2023
+ <div class="task">
2024
+ <div class="task-check" style="border-color:{icon_color};color:{icon_color}"><i class="bi {icon_cls}"></i></div>
2025
+ <div class="task-content">
2026
+ <div class="commit-msg">{msg_title}</div>
2027
+ <div style="display:flex;gap:12px;align-items:center;margin-top:4px">
2028
+ <span class="commit-hash">{esc(hash_short)}</span>
2029
+ <span class="commit-time">{esc(rel_time)}</span>
2030
+ <span class="commit-time">{esc(author)}</span>
2031
+ </div>
2032
+ </div>
2033
+ </div>
2034
+ '''
2035
+ except Exception:
2036
+ pass
2037
+
2038
+ # Branch info
2039
+ branch = ''
2040
+ try:
2041
+ result = subprocess.run(
2042
+ ['git', 'branch', '--show-current'],
2043
+ cwd=PROJECT_DIR, capture_output=True, text=True
2044
+ )
2045
+ if result.returncode == 0:
2046
+ branch = result.stdout.strip()
2047
+ except Exception:
2048
+ pass
2049
+
2050
+ return f'''
2051
+ <div class="header">
2052
+ <h1 class="page-title"><i class="bi bi-git"></i> Git Commits</h1>
2053
+ <div class="header-actions">
2054
+ {f'<span style="font-size:13px;color:var(--text-secondary)"><i class="bi bi-diagram-2"></i> {esc(branch)}</span>' if branch else ''}
2055
+ <button class="theme-toggle" onclick="toggleTheme()">
2056
+ <i class="bi bi-moon-stars"></i>
2057
+ </button>
2058
+ </div>
2059
+ </div>
2060
+
2061
+ <div class="task-list">
2062
+ <div class="task-header">
2063
+ <h3>Recent Commits</h3>
2064
+ </div>
2065
+ {commits_html if commits_html else '<div class="empty"><i class="bi bi-git"></i><p>No commits found</p></div>'}
2066
+ </div>
2067
+ '''
2068
+
2069
+ def build_settings_page(self):
2070
+ from .config import Config
2071
+ config = Config(PROJECT_DIR)
2072
+ data = config.get_all()
2073
+ provider = esc(data.get("provider", "claude-max"))
2074
+ api_key_masked = esc(config.mask_api_key(data.get("api_key")) or "")
2075
+ ollama_host = esc(data.get("ollama_host", "http://localhost:11434"))
2076
+ ollama_model = esc(data.get("ollama_model", "qwen3:30b-a3b"))
2077
+ max_sessions = data.get("max_sessions", 100)
2078
+ max_turns = data.get("max_turns", 25)
2079
+ session_delay = data.get("session_delay", 5)
2080
+ context_threshold = data.get("context_threshold", 0.70)
2081
+
2082
+ # Provider options with selected state
2083
+ providers = [
2084
+ ("claude-max", "claude-max (Claude Code CLI)"),
2085
+ ("claude-api", "claude-api [EXPERIMENTAL]"),
2086
+ ("ollama", "ollama [EXPERIMENTAL]"),
2087
+ ]
2088
+ options_html = ""
2089
+ for val, label in providers:
2090
+ sel = ' selected' if val == provider else ''
2091
+ options_html += f'<option value="{val}"{sel}>{label}</option>'
2092
+
2093
+ return f'''
2094
+ <div class="header">
2095
+ <h1 class="page-title"><i class="bi bi-gear"></i> Settings</h1>
2096
+ <button class="theme-toggle" onclick="toggleTheme()">
2097
+ <i class="bi bi-moon-stars"></i>
2098
+ </button>
2099
+ </div>
2100
+
2101
+ <div class="card" style="margin-bottom:16px">
2102
+ <div class="card-title"><i class="bi bi-cpu"></i> Provider</div>
2103
+ <select id="cfg-provider" onchange="onProviderChange(this.value)" style="margin-top:8px">
2104
+ {options_html}
2105
+ </select>
2106
+ <div id="provider-badge" class="settings-hint">
2107
+ {self._provider_badge(provider)}
2108
+ </div>
2109
+ </div>
2110
+
2111
+ <div class="card" id="card-apikey" style="margin-bottom:16px;{'display:none' if provider != 'claude-api' else ''}">
2112
+ <div class="card-title"><i class="bi bi-key"></i> API Key</div>
2113
+ <div style="display:flex;gap:8px;margin-top:8px">
2114
+ <input type="password" id="cfg-apikey" placeholder="sk-ant-api03-..."
2115
+ value="{api_key_masked}" style="flex:1">
2116
+ <button onclick="saveApiKey()" class="btn-primary" style="white-space:nowrap">
2117
+ <i class="bi bi-check-lg"></i> Save
2118
+ </button>
2119
+ </div>
2120
+ <div class="settings-hint">
2121
+ Or set <code style="background:var(--bg-tertiary);padding:2px 6px;border-radius:4px">ANTHROPIC_API_KEY</code> environment variable
2122
+ </div>
2123
+ </div>
2124
+
2125
+ <div class="card" id="card-ollama" style="margin-bottom:16px;{'display:none' if provider != 'ollama' else ''}">
2126
+ <div class="card-title"><i class="bi bi-hdd-network"></i> Ollama</div>
2127
+ <div style="margin-top:8px">
2128
+ <label class="settings-label">Host URL</label>
2129
+ <input type="text" id="cfg-ollama-host" value="{ollama_host}" style="font-family:monospace;width:100%">
2130
+ </div>
2131
+ <div style="margin-top:12px">
2132
+ <label class="settings-label">Model</label>
2133
+ <input type="text" id="cfg-ollama-model" value="{ollama_model}" style="font-family:monospace;width:100%">
2134
+ </div>
2135
+ <button onclick="saveOllamaConfig()" class="btn-primary" style="margin-top:12px">
2136
+ <i class="bi bi-check-lg"></i> Save
2137
+ </button>
2138
+ </div>
2139
+
2140
+ <div class="card" style="margin-bottom:16px">
2141
+ <div class="card-title"><i class="bi bi-sliders"></i> Session</div>
2142
+ <div class="settings-grid">
2143
+ <div>
2144
+ <label class="settings-label">Max Sessions</label>
2145
+ <input type="number" id="cfg-max-sessions" value="{max_sessions}" min="1" max="1000">
2146
+ </div>
2147
+ <div>
2148
+ <label class="settings-label">Max Turns</label>
2149
+ <input type="number" id="cfg-max-turns" value="{max_turns}" min="1" max="100">
2150
+ </div>
2151
+ <div>
2152
+ <label class="settings-label">Session Delay (sec)</label>
2153
+ <input type="number" id="cfg-delay" value="{session_delay}" min="0" max="60">
2154
+ </div>
2155
+ <div>
2156
+ <label class="settings-label">Context Threshold</label>
2157
+ <input type="number" id="cfg-threshold" value="{context_threshold}" min="0.1" max="0.95" step="0.05">
2158
+ </div>
2159
+ </div>
2160
+ <button onclick="saveSessionConfig()" class="btn-primary" style="margin-top:12px">
2161
+ <i class="bi bi-check-lg"></i> Save
2162
+ </button>
2163
+ </div>
2164
+
2165
+ <div class="card" style="margin-bottom:16px">
2166
+ <div class="card-title"><i class="bi bi-moon-stars"></i> Theme</div>
2167
+ <button onclick="toggleTheme()" class="btn-secondary" style="margin-top:8px">
2168
+ <i class="bi bi-moon-stars"></i> Toggle Dark/Light
2169
+ </button>
2170
+ </div>
2171
+
2172
+ <div class="card" style="margin-bottom:16px">
2173
+ <div class="card-title"><i class="bi bi-file-earmark-code"></i> Config File</div>
2174
+ <p style="margin-top:8px;font-family:monospace" class="commit-hash">
2175
+ {esc(str(config.path))}
2176
+ </p>
2177
+ </div>
2178
+
2179
+ <script>
2180
+ function showToast(msg) {{
2181
+ const t = document.createElement('div');
2182
+ t.className = 'toast';
2183
+ t.textContent = msg;
2184
+ document.body.appendChild(t);
2185
+ setTimeout(() => t.remove(), 2500);
2186
+ }}
2187
+
2188
+ function onProviderChange(val) {{
2189
+ document.getElementById('card-apikey').style.display = val === 'claude-api' ? 'block' : 'none';
2190
+ document.getElementById('card-ollama').style.display = val === 'ollama' ? 'block' : 'none';
2191
+ fetch('/api/config', {{
2192
+ method: 'POST',
2193
+ headers: {{'Content-Type': 'application/json'}},
2194
+ body: JSON.stringify({{provider: val}})
2195
+ }}).then(r => r.json()).then(d => {{
2196
+ if (d.ok) {{
2197
+ let badge = document.getElementById('provider-badge');
2198
+ if (val === 'claude-max') badge.innerHTML = '<span style="color:var(--success)">Active</span>';
2199
+ else badge.innerHTML = '<span style="color:var(--warning)">EXPERIMENTAL</span>';
2200
+ showToast('Provider saved: ' + val);
2201
+ }}
2202
+ }});
2203
+ }}
2204
+
2205
+ function saveApiKey() {{
2206
+ let key = document.getElementById('cfg-apikey').value;
2207
+ if (!key || key.includes('...')) {{ showToast('Enter the full API key'); return; }}
2208
+ fetch('/api/config', {{
2209
+ method: 'POST',
2210
+ headers: {{'Content-Type': 'application/json'}},
2211
+ body: JSON.stringify({{api_key: key}})
2212
+ }}).then(r => r.json()).then(d => {{
2213
+ if (d.ok) showToast('API key saved');
2214
+ }});
2215
+ }}
2216
+
2217
+ function saveOllamaConfig() {{
2218
+ let host = document.getElementById('cfg-ollama-host').value;
2219
+ let model = document.getElementById('cfg-ollama-model').value;
2220
+ fetch('/api/config', {{
2221
+ method: 'POST',
2222
+ headers: {{'Content-Type': 'application/json'}},
2223
+ body: JSON.stringify({{ollama_host: host, ollama_model: model}})
2224
+ }}).then(r => r.json()).then(d => {{
2225
+ if (d.ok) showToast('Ollama config saved');
2226
+ }});
2227
+ }}
2228
+
2229
+ function saveSessionConfig() {{
2230
+ let cfg = {{
2231
+ max_sessions: parseInt(document.getElementById('cfg-max-sessions').value),
2232
+ max_turns: parseInt(document.getElementById('cfg-max-turns').value),
2233
+ session_delay: parseInt(document.getElementById('cfg-delay').value),
2234
+ context_threshold: parseFloat(document.getElementById('cfg-threshold').value),
2235
+ }};
2236
+ fetch('/api/config', {{
2237
+ method: 'POST',
2238
+ headers: {{'Content-Type': 'application/json'}},
2239
+ body: JSON.stringify(cfg)
2240
+ }}).then(r => r.json()).then(d => {{
2241
+ if (d.ok) showToast('Session config saved');
2242
+ }});
2243
+ }}
2244
+ </script>
2245
+ '''
2246
+
2247
+ def _provider_badge(self, provider: str) -> str:
2248
+ """Generate provider status badge HTML"""
2249
+ if provider == "claude-max":
2250
+ return '<span style="color:#a6e3a1">Active</span>'
2251
+ return '<span style="color:#f9e2af">EXPERIMENTAL</span> — untested'
2252
+
2253
+ def build_transform_page(self):
2254
+ return '''
2255
+ <div class="header">
2256
+ <h1 class="page-title"><i class="bi bi-magic"></i> Transform</h1>
2257
+ <button class="theme-toggle" onclick="toggleTheme()">
2258
+ <i class="bi bi-moon-stars"></i>
2259
+ </button>
2260
+ </div>
2261
+
2262
+ <div class="cards" style="grid-template-columns:repeat(3,1fr);margin-bottom:24px">
2263
+ <div class="card" style="text-align:center;padding:16px">
2264
+ <div style="font-size:24px;margin-bottom:8px"><i class="bi bi-pencil-square" style="color:var(--accent)"></i></div>
2265
+ <div style="font-size:13px;font-weight:600">1. Write</div>
2266
+ <div style="font-size:12px;color:var(--text-secondary)">Enter raw text or ideas</div>
2267
+ </div>
2268
+ <div class="card" style="text-align:center;padding:16px">
2269
+ <div style="font-size:24px;margin-bottom:8px"><i class="bi bi-magic" style="color:var(--warning)"></i></div>
2270
+ <div style="font-size:13px;font-weight:600">2. Transform</div>
2271
+ <div style="font-size:12px;color:var(--text-secondary)">AI breaks into tasks</div>
2272
+ </div>
2273
+ <div class="card" style="text-align:center;padding:16px">
2274
+ <div style="font-size:24px;margin-bottom:8px"><i class="bi bi-check2-all" style="color:var(--success)"></i></div>
2275
+ <div style="font-size:13px;font-weight:600">3. Confirm</div>
2276
+ <div style="font-size:12px;color:var(--text-secondary)">Review and add to queue</div>
2277
+ </div>
2278
+ </div>
2279
+
2280
+ <div class="form-section">
2281
+ <h3>Raw Text to Tasks</h3>
2282
+ <p style="color: var(--text-secondary); margin-bottom:16px; font-size:13px">
2283
+ Enter raw text, notes, or ideas — AI will break them into structured tasks.
2284
+ </p>
2285
+ <textarea id="transform-input" rows="6" placeholder="Example:&#10;Add login page with email/password fields&#10;Registration form with validation&#10;Password reset flow via email&#10;Write unit tests for auth module"></textarea>
2286
+ <div style="margin-top:12px;display:flex;align-items:center;gap:12px">
2287
+ <button class="btn-primary" onclick="doTransform()" id="transform-btn">
2288
+ <i class="bi bi-magic"></i> AI Transform
2289
+ </button>
2290
+ <span id="transform-status" style="font-size:13px;color:var(--text-secondary)"></span>
2291
+ </div>
2292
+ </div>
2293
+
2294
+ <div id="transform-preview" style="display:none;margin-top:24px">
2295
+ <div class="task-list">
2296
+ <div class="task-header">
2297
+ <h3>Preview Tasks</h3>
2298
+ <button class="btn-success" onclick="confirmTransform()">
2299
+ <i class="bi bi-check-all"></i> Add Selected
2300
+ </button>
2301
+ </div>
2302
+ <div id="preview-tasks"></div>
2303
+ </div>
2304
+ </div>
2305
+
2306
+ <script>
2307
+ let transformedTasks = [];
2308
+
2309
+ function doTransform() {
2310
+ const text = document.getElementById('transform-input').value.trim();
2311
+ if (!text) return;
2312
+ const btn = document.getElementById('transform-btn');
2313
+ const status = document.getElementById('transform-status');
2314
+ btn.disabled = true;
2315
+ status.textContent = 'Thinking...';
2316
+
2317
+ fetch('/transform', {
2318
+ method: 'POST',
2319
+ headers: {'Content-Type': 'application/x-www-form-urlencoded'},
2320
+ body: 'text=' + encodeURIComponent(text)
2321
+ })
2322
+ .then(r => r.json())
2323
+ .then(data => {
2324
+ btn.disabled = false;
2325
+ if (data.tasks && data.tasks.length > 0) {
2326
+ transformedTasks = data.tasks;
2327
+ status.textContent = data.tasks.length + ' tasks generated';
2328
+ renderPreview(data.tasks);
2329
+ } else {
2330
+ status.textContent = data.error || 'No tasks generated';
2331
+ }
2332
+ })
2333
+ .catch(err => {
2334
+ btn.disabled = false;
2335
+ status.textContent = 'Error: ' + err.message;
2336
+ });
2337
+ }
2338
+
2339
+ function renderPreview(tasks) {
2340
+ const container = document.getElementById('preview-tasks');
2341
+ container.innerHTML = '';
2342
+ tasks.forEach((t, i) => {
2343
+ container.innerHTML += '<div class="task"><div class="task-check"><input type="checkbox" checked data-idx="' + i + '" style="width:18px;height:18px;cursor:pointer"></div><div class="task-content"><div class="task-title">' + escHtml(t.title) + '</div><div class="task-meta">' + escHtml(t.description || '') + '</div></div></div>';
2344
+ });
2345
+ document.getElementById('transform-preview').style.display = 'block';
2346
+ }
2347
+
2348
+ function confirmTransform() {
2349
+ const checks = document.querySelectorAll('#preview-tasks input[type=checkbox]');
2350
+ const selected = [];
2351
+ checks.forEach(cb => {
2352
+ if (cb.checked) selected.push(transformedTasks[parseInt(cb.dataset.idx)]);
2353
+ });
2354
+ if (selected.length === 0) return;
2355
+ fetch('/transform-confirm', {
2356
+ method: 'POST',
2357
+ headers: {'Content-Type': 'application/json'},
2358
+ body: JSON.stringify({tasks: selected})
2359
+ })
2360
+ .then(r => r.json())
2361
+ .then(data => {
2362
+ if (data.ok) {
2363
+ document.getElementById('transform-status').textContent = data.added + ' tasks added!';
2364
+ document.getElementById('transform-preview').style.display = 'none';
2365
+ document.getElementById('transform-input').value = '';
2366
+ }
2367
+ });
2368
+ }
2369
+ </script>
2370
+ '''
2371
+
2372
+ def _transform_text(self, raw_text: str) -> dict:
2373
+ """Use Claude to break raw text into structured tasks"""
2374
+ import os
2375
+ import subprocess
2376
+
2377
+ prompt = f'''Break the following text into structured tasks for a software project.
2378
+ Return ONLY valid JSON array, no other text. Each task object must have:
2379
+ - "title": short task title (imperative, e.g. "Add login page")
2380
+ - "description": 1-2 sentence description
2381
+
2382
+ Text to transform:
2383
+ {raw_text}
2384
+
2385
+ Return format: [{{"title": "...", "description": "..."}}, ...]'''
2386
+
2387
+ try:
2388
+ env = os.environ.copy()
2389
+ env.pop("CLAUDECODE", None)
2390
+ result = subprocess.run(
2391
+ ["claude", "-p", prompt, "--max-turns", "1", "--no-session-persistence", "--dangerously-skip-permissions"],
2392
+ cwd=str(PROJECT_DIR),
2393
+ env=env,
2394
+ capture_output=True,
2395
+ text=True,
2396
+ timeout=300,
2397
+ )
2398
+ output = result.stdout.strip()
2399
+ # Try to extract JSON from output
2400
+ start = output.find('[')
2401
+ end = output.rfind(']')
2402
+ if start >= 0 and end > start:
2403
+ tasks = json.loads(output[start:end+1])
2404
+ return {"tasks": tasks}
2405
+ return {"tasks": [], "error": "Could not parse AI response"}
2406
+ except subprocess.TimeoutExpired:
2407
+ return {"tasks": [], "error": "AI request timed out (5 min)"}
2408
+ except FileNotFoundError:
2409
+ return {"tasks": [], "error": "Claude CLI not found"}
2410
+ except Exception as e:
2411
+ return {"tasks": [], "error": str(e)}
2412
+
2413
+ def send_json_status(self):
2414
+ checkpoint = CheckpointManager(PROJECT_DIR)
2415
+ tasks = TaskManager(PROJECT_DIR)
2416
+
2417
+ # Get live metrics from agent loop if running
2418
+ metrics = {}
2419
+ if AGENT_LOOP and hasattr(AGENT_LOOP, 'get_session_metrics'):
2420
+ metrics = AGENT_LOOP.get_session_metrics()
2421
+ else:
2422
+ # Try to load from checkpoint
2423
+ cp = checkpoint.load()
2424
+ metrics = cp.get('session_metrics', {})
2425
+
2426
+ data = {
2427
+ 'checkpoint': checkpoint.load(),
2428
+ 'tasks': [t.to_dict() for t in tasks.get_tasks()],
2429
+ 'progress': tasks.get_progress(),
2430
+ 'running': AGENT_RUNNING,
2431
+ 'activity': ACTIVITY_LOG[-10:],
2432
+ 'metrics': metrics,
2433
+ }
2434
+
2435
+ self.send_response(200)
2436
+ self.send_header('Content-Type', 'application/json')
2437
+ self.end_headers()
2438
+ self.wfile.write(json.dumps(data).encode('utf-8'))
2439
+
2440
+ def send_json_log(self):
2441
+ """Return agent log entries since a given index"""
2442
+ query = urlparse(self.path).query
2443
+ params = parse_qs(query)
2444
+ since = int(params.get('since', ['0'])[0])
2445
+
2446
+ entries = AGENT_LOG_BUFFER[since:]
2447
+ data = {
2448
+ 'entries': entries,
2449
+ 'total': len(AGENT_LOG_BUFFER),
2450
+ 'since': since,
2451
+ }
2452
+
2453
+ self.send_response(200)
2454
+ self.send_header('Content-Type', 'application/json')
2455
+ self.end_headers()
2456
+ self.wfile.write(json.dumps(data).encode('utf-8'))
2457
+
2458
+ def send_json_config(self):
2459
+ """Return current config (API key masked)"""
2460
+ from .config import Config, DEFAULTS
2461
+ config = Config(PROJECT_DIR)
2462
+ data = config.get_all()
2463
+ # Mask API key for security
2464
+ if data.get("api_key"):
2465
+ data["api_key"] = config.mask_api_key(data["api_key"])
2466
+ data["_defaults"] = DEFAULTS
2467
+ self.send_response(200)
2468
+ self.send_header('Content-Type', 'application/json')
2469
+ self.end_headers()
2470
+ self.wfile.write(json.dumps(data).encode('utf-8'))
2471
+
2472
+ def _render_log_entries(self):
2473
+ """Render existing log buffer entries as HTML (terminal style)"""
2474
+ label_map = {
2475
+ 'read': 'READ', 'edit': 'EDIT', 'write': 'WRITE',
2476
+ 'bash': 'BASH', 'thinking': 'THINK', 'text': 'OUT',
2477
+ 'metric': 'METRIC', 'verify': 'CHECK',
2478
+ }
2479
+ color_map = {
2480
+ 'read': '#89b4fa', 'edit': '#fab387', 'write': '#a6e3a1',
2481
+ 'bash': '#cba6f7', 'thinking': '#f9e2af', 'text': '#6c7086',
2482
+ 'metric': '#89dceb', 'verify': '#a6e3a1',
2483
+ }
2484
+ html = ''
2485
+ for e in AGENT_LOG_BUFFER[-50:]:
2486
+ etype = e.get('type', 'text')
2487
+ label = label_map.get(etype, 'LOG')
2488
+ color = color_map.get(etype, '#6c7086')
2489
+ html += f'<div class="log-entry"><span class="px-icon" style="background:{color}"></span><span class="log-time">{esc(e["time"])}</span><span class="log-label" style="color:{color}">{label}</span><span class="log-text">{esc(e["line"][:150])}</span></div>'
2490
+ if not AGENT_LOG_BUFFER:
2491
+ html = '<div class="log-empty">Waiting for agent output...<span class="log-cursor"></span></div>'
2492
+ return html
2493
+
2494
+ def _render_raw_log(self):
2495
+ """Render raw log text for initial page load"""
2496
+ return esc('\n'.join(e['line'] for e in AGENT_LOG_BUFFER[-50:]))
2497
+
2498
+ def start_agent(self):
2499
+ global AGENT_RUNNING, AGENT_LOOP
2500
+ if not AGENT_RUNNING:
2501
+ AGENT_LOG_BUFFER.clear()
2502
+ log_activity("Agent started", "", "success")
2503
+
2504
+ def run():
2505
+ global AGENT_RUNNING, AGENT_LOOP
2506
+ AGENT_RUNNING = True
2507
+ try:
2508
+ from .config import Config
2509
+ from .loop import SessionLoop
2510
+ config = Config(PROJECT_DIR)
2511
+ resolved = config.resolve()
2512
+ loop = SessionLoop(project_dir=PROJECT_DIR, **resolved)
2513
+ loop._log_callback = _on_agent_line
2514
+ AGENT_LOOP = loop
2515
+ loop.start()
2516
+ finally:
2517
+ AGENT_RUNNING = False
2518
+ AGENT_LOOP = None
2519
+ log_activity("Agent stopped", "", "warning")
2520
+
2521
+ thread = threading.Thread(target=run, daemon=True)
2522
+ thread.start()
2523
+
2524
+ def stop_agent(self):
2525
+ global AGENT_RUNNING, AGENT_LOOP
2526
+ if AGENT_LOOP:
2527
+ AGENT_LOOP.stop()
2528
+ AGENT_RUNNING = False
2529
+ log_activity("Agent stop requested", "", "warning")
2530
+
2531
+
2532
+ def find_free_port(start_port: int = 7331, max_attempts: int = 20) -> int:
2533
+ for offset in range(max_attempts):
2534
+ port = start_port + offset
2535
+ try:
2536
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
2537
+ s.bind(('localhost', port))
2538
+ return port
2539
+ except OSError:
2540
+ continue
2541
+ raise RuntimeError(f"No free port in range {start_port}-{start_port + max_attempts}")
2542
+
2543
+
2544
+ def run_dashboard(project_dir: Path, port: int = None, open_browser: bool = True):
2545
+ global PROJECT_DIR
2546
+ PROJECT_DIR = Path(project_dir).resolve()
2547
+
2548
+ a1_dir = PROJECT_DIR / '.a1'
2549
+ if not a1_dir.exists():
2550
+ a1_dir.mkdir(parents=True)
2551
+ (a1_dir / 'sessions').mkdir()
2552
+ (a1_dir / 'checkpoints').mkdir()
2553
+
2554
+ if port is None:
2555
+ port = find_free_port(7331)
2556
+ else:
2557
+ try:
2558
+ port = find_free_port(port, max_attempts=1)
2559
+ except RuntimeError:
2560
+ port = find_free_port(7331)
2561
+
2562
+ server = HTTPServer(('localhost', port), DashboardHandler)
2563
+
2564
+ url = f'http://localhost:{port}'
2565
+ print()
2566
+ print(' PocketCoder-A1 Dashboard')
2567
+ print(' -------------------------')
2568
+ print(f' URL: {url}')
2569
+ print(f' Project: {PROJECT_DIR}')
2570
+ print()
2571
+ print(' Press Ctrl+C to stop')
2572
+ print()
2573
+
2574
+ log_activity("Dashboard started", f"Port {port}", "info")
2575
+
2576
+ if open_browser:
2577
+ webbrowser.open(url)
2578
+
2579
+ try:
2580
+ server.serve_forever()
2581
+ except KeyboardInterrupt:
2582
+ print('\\nDashboard stopped')
2583
+ server.shutdown()
2584
+
2585
+
2586
+ if __name__ == '__main__':
2587
+ import sys
2588
+ project = sys.argv[1] if len(sys.argv) > 1 else '.'
2589
+ run_dashboard(project)