{"repo": "chashchin-dmitry/pocketcoder-a1", "files": {"a1/config.py": {"80863f2ff11776214b3cf361c01077c273a14e56": {"t": 1785674422.3965702, "n": 3929, "h": "0028ed2254f968b0", "c": "\"\"\"\na1/config.py — Configuration management for PocketCoder-A1.\n\nLoads/saves .a1/config.json with priority resolution:\n CLI args > env vars > config.json > defaults\n\"\"\"\n\nimport json\nimport os\nfrom pathlib import Path\nfrom typing import Any, Optional\n\nDEFAULTS = {\n \"provider\": \"claude-max\",\n \"model\": None,\n \"api_key\": None,\n \"ollama_host\": \"http://localhost:11434\",\n \"ollama_model\": \"qwen3:30b-a3b\",\n \"max_sessions\": 100,\n \"max_turns\": 25,\n \"session_delay\": 5,\n \"context_threshold\": 0.70,\n}\n\n# Env var → config key mapping\nENV_MAP = {\n \"ANTHROPIC_API_KEY\": \"api_key\",\n \"OLLAMA_HOST\": \"ollama_host\",\n \"OLLAMA_MODEL\": \"ollama_model\",\n}\n\n\nclass Config:\n \"\"\"Configuration manager — load/save/resolve .a1/config.json.\"\"\"\n\n def __init__(self, project_dir: Path):\n self.project_dir = Path(project_dir)\n self.path = self.project_dir / \".a1\" / \"config.json\"\n self._data: dict = {}\n self.load()\n\n def load(self) -> dict:\n \"\"\"Load from disk. Returns stored values (not merged with defaults).\"\"\"\n if self.path.exists():\n try:\n self._data = json.loads(self.path.read_text(encoding=\"utf-8\"))\n except (json.JSONDecodeError, OSError):\n self._data = {}\n else:\n self._data = {}\n return self._data\n\n def save(self) -> None:\n \"\"\"Save current data to .a1/config.json.\"\"\"\n self.path.parent.mkdir(parents=True, exist_ok=True)\n self.path.write_text(\n json.dumps(self._data, indent=2, ensure_ascii=False) + \"\\n\",\n encoding=\"utf-8\",\n )\n\n def get(self, key: str, default: Any = None) -> Any:\n \"\"\"Get value with fallback: config → DEFAULTS → default.\"\"\"\n if key in self._data and self._data[key] is not None:\n return self._data[key]\n if key in DEFAULTS and DEFAULTS[key] is not None:\n return DEFAULTS[key]\n return default\n\n def set(self, key: str, value: Any) -> None:\n \"\"\"Set a value and save to disk.\"\"\"\n self._data[key] = value\n self.save()\n\n def get_all(self) -> dict:\n \"\"\"Get full config merged with defaults.\"\"\"\n result = dict(DEFAULTS)\n result.update({k: v for k, v in self._data.items() if v is not None})\n return result\n\n def reset(self) -> None:\n \"\"\"Reset to defaults.\"\"\"\n self._data = {}\n self.save()\n\n def resolve(self, cli_args: Optional[dict] = None) -> dict:\n \"\"\"Merge all sources: CLI > env > config > defaults.\n\n Returns dict with keys matching SessionLoop.__init__ params:\n provider, model, api_key, ollama_host, ollama_model,\n max_sessions, max_turns, session_delay\n \"\"\"\n # 1. Start with defaults\n result = dict(DEFAULTS)\n\n # 2. Overlay config.json values\n for k, v in self._data.items():\n if v is not None:\n result[k] = v\n\n # 3. Overlay env vars\n for env_var, config_key in ENV_MAP.items():\n val = os.environ.get(env_var)\n if val:\n result[config_key] = val\n\n # 4. Overlay CLI args (skip None values — means \"not provided\")\n if cli_args:\n for k, v in cli_args.items():\n if v is not None:\n result[k] = v\n\n # Remove context_threshold from result (it's not a SessionLoop param)\n # Store it separately so loop.py can access if needed\n result.pop(\"context_threshold\", None)\n\n return result\n\n def mask_api_key(self, key: Optional[str] = None) -> Optional[str]:\n \"\"\"Mask API key for display: sk-ant-api03-...xxxx.\"\"\"\n k = key or self.get(\"api_key\")\n if not k or len(k) < 8:\n return k\n return k[:10] + \"...\" + k[-4:]\n\n def __repr__(self) -> str:\n return f\"Config({self.path}, keys={list(self._data.keys())})\"\n"}}, "a1/dashboard.py": {"80863f2ff11776214b3cf361c01077c273a14e56": {"t": 1785674429.3935359, "n": 127348, "h": "955a3f4285b597c7", "c": "#!/usr/bin/env python3\n\"\"\"\nPocketCoder-A1 Dashboard — Full-featured Web UI\n\"\"\"\n\nimport html as html_mod\nimport json\nimport re\nimport socket\nimport threading\nimport time as _time\nimport webbrowser\nfrom datetime import datetime\nfrom http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer\nfrom pathlib import Path\nfrom string import Template\nfrom urllib.parse import parse_qs, urlparse\n\nfrom .checkpoint import CheckpointManager\nfrom .tasks import TaskManager\n\nPROJECT_DIR = None\nAGENT_RUNNING = False\nAGENT_LOOP = None # Reference to SessionLoop for stop control\nACTIVITY_LOG = [] # Live activity log\nAGENT_LOG_BUFFER = [] # Live agent output lines for dashboard\n\n# Cache current task_id for tagging log entries (refreshes every 2s)\n_TASK_CACHE = {\"task_id\": None, \"ts\": 0}\n\n\ndef _get_current_task_id():\n \"\"\"Get current task_id from checkpoint (cached 2s)\"\"\"\n now = _time.time()\n if now - _TASK_CACHE[\"ts\"] < 2.0:\n return _TASK_CACHE[\"task_id\"]\n _TASK_CACHE[\"ts\"] = now\n if AGENT_LOOP and hasattr(AGENT_LOOP, 'checkpoint'):\n try:\n cp = AGENT_LOOP.checkpoint.load()\n _TASK_CACHE[\"task_id\"] = cp.get(\"current_task\")\n except Exception:\n pass\n return _TASK_CACHE[\"task_id\"]\n\n\ndef esc(text: str) -> str:\n \"\"\"Escape HTML to prevent XSS\"\"\"\n return html_mod.escape(str(text)) if text else \"\"\n\n\ndef log_activity(action: str, details: str = \"\", status: str = \"info\"):\n \"\"\"Log activity for dashboard\"\"\"\n ACTIVITY_LOG.append({\n \"time\": datetime.now().strftime(\"%H:%M:%S\"),\n \"action\": action,\n \"details\": details,\n \"status\": status # info, success, error, warning\n })\n # Keep last 100 entries\n if len(ACTIVITY_LOG) > 100:\n ACTIVITY_LOG.pop(0)\n\n\ndef _classify_line(line: str) -> str:\n \"\"\"Classify a Claude output line into a tool type for icon display\"\"\"\n lower = line.lower().strip()\n if any(kw in lower for kw in [\"read(\", \"reading file\", \"read file\", \"reading \", \"read \"]):\n return \"read\"\n elif any(kw in lower for kw in [\"edit(\", \"editing file\", \"edit file\", \"editing \"]):\n return \"edit\"\n elif any(kw in lower for kw in [\"write(\", \"writing file\", \"write file\", \"creating file\", \"created \"]):\n return \"write\"\n elif any(kw in lower for kw in [\"bash(\", \"running:\", \"$ \", \"command\", \"terminal\", \"pytest\", \"ruff \"]):\n return \"bash\"\n elif any(kw in lower for kw in [\"let me\", \"i'll\", \"i need\", \"thinking\", \"analyzing\", \"looking\"]):\n return \"thinking\"\n return \"text\"\n\n\ndef _on_agent_line(line: str, event_type: str = None):\n \"\"\"Parse agent output line and add to live buffer.\n event_type: pre-classified type from stream-json parser\n (read/edit/write/bash/thinking/text/metric/verify)\n If not provided, falls back to heuristic classifier.\"\"\"\n stripped = line.rstrip(\"\\n\")\n if not stripped:\n return\n entry = {\n \"time\": datetime.now().strftime(\"%H:%M:%S\"),\n \"line\": stripped,\n \"type\": event_type if event_type else _classify_line(line),\n \"task_id\": _get_current_task_id(),\n }\n AGENT_LOG_BUFFER.append(entry)\n if len(AGENT_LOG_BUFFER) > 500:\n AGENT_LOG_BUFFER.pop(0)\n\n\nCSS = '''\n:root {\n --bg-primary: #FAFAF9;\n --bg-secondary: #FFFFFF;\n --bg-tertiary: #F0EFED;\n --text-primary: #1A1A1A;\n --text-secondary: #6B6B6B;\n --border-color: #E5E3DF;\n --accent: #DA7756;\n --accent-light: #E8956A;\n --success: #2E8B57;\n --warning: #D4940A;\n --danger: #D94F4F;\n}\n\n[data-theme=\"dark\"] {\n --bg-primary: #191919;\n --bg-secondary: #262626;\n --bg-tertiary: #1E1E1E;\n --text-primary: #ECECEC;\n --text-secondary: #8E8E8E;\n --border-color: #333333;\n}\n\n* { box-sizing: border-box; margin: 0; padding: 0; }\n\nbody {\n font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n background: var(--bg-primary);\n color: var(--text-primary);\n min-height: 100vh;\n}\n\n.layout {\n display: grid;\n grid-template-columns: 240px 1fr;\n min-height: 100vh;\n}\n\n/* Sidebar */\n.sidebar {\n background: var(--bg-secondary);\n border-right: 3px solid var(--border-color);\n padding: 20px;\n position: sticky;\n top: 0;\n height: 100vh;\n overflow-y: auto;\n}\n\n.logo-ascii {\n margin-bottom: 4px;\n user-select: none;\n}\n.logo-art {\n color: #E8956A;\n font-size: 14px;\n line-height: 1.1;\n font-family: 'JetBrains Mono', monospace;\n margin: 0;\n text-shadow:\n 1px 1px 0 #DA7756,\n 2px 2px 0 #C4653F,\n 3px 3px 0 #A8522F,\n 4px 4px 0 #8C4020,\n 5px 5px 0 #703018;\n letter-spacing: 1px;\n}\n[data-theme=\"dark\"] .logo-art {\n color: #F0A87A;\n text-shadow:\n 1px 1px 0 #DA7756,\n 2px 2px 0 #C4653F,\n 3px 3px 0 #A8522F,\n 4px 4px 0 #8C4020,\n 5px 5px 0 #703018;\n}\n\n.logo-sub {\n font-size: 10px;\n color: var(--text-secondary);\n margin-bottom: 30px;\n font-family: 'JetBrains Mono', monospace;\n letter-spacing: 1px;\n text-transform: uppercase;\n}\n\n.nav-section {\n margin-bottom: 24px;\n}\n\n.nav-title {\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 1px;\n color: var(--text-secondary);\n margin-bottom: 12px;\n}\n\n.nav-item {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n border-radius: 0;\n color: var(--text-primary);\n text-decoration: none;\n margin-bottom: 2px;\n transition: background 0.15s;\n font-family: 'JetBrains Mono', monospace;\n font-size: 12px;\n font-weight: 500;\n border-left: 3px solid transparent;\n}\n\n.nav-item:hover {\n background: var(--bg-tertiary);\n border-left-color: var(--border-color);\n}\n\n.nav-item.active {\n background: var(--accent);\n color: white;\n border-left-color: var(--accent);\n}\n\n.nav-item i {\n width: 18px;\n text-align: center;\n}\n\n/* Main content */\n.main {\n padding: 24px 32px;\n overflow-y: auto;\n}\n\n.header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 24px;\n}\n\n.page-title {\n font-size: 20px;\n font-weight: 700;\n font-family: 'JetBrains Mono', monospace;\n text-transform: uppercase;\n letter-spacing: 1px;\n}\n\n.header-actions {\n display: flex;\n gap: 12px;\n align-items: center;\n}\n\n/* Status badge */\n.status {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 6px 14px;\n border-radius: 0;\n font-size: 11px;\n font-weight: 700;\n font-family: 'JetBrains Mono', monospace;\n text-transform: uppercase;\n letter-spacing: 1px;\n}\n\n.status-running { background: var(--success); color: white; }\n.status-stopped { background: var(--danger); color: white; }\n.status-completed { background: var(--accent); color: white; }\n\n/* Cards */\n.cards {\n display: grid;\n grid-template-columns: repeat(3, 1fr);\n gap: 16px;\n margin-bottom: 24px;\n}\n\n.card {\n background: var(--bg-secondary);\n border: 3px solid var(--border-color);\n border-radius: 0;\n padding: 20px;\n transition: transform 0.15s;\n box-shadow: inset 2px 2px 6px rgba(0,0,0,0.08);\n}\n.card:hover {\n transform: translateY(-1px);\n box-shadow: inset 1px 1px 3px rgba(0,0,0,0.04);\n}\n[data-theme=\"dark\"] .card:hover {\n box-shadow: inset 1px 1px 3px rgba(0,0,0,0.15);\n}\n\n.card-title {\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 1.5px;\n color: var(--text-secondary);\n margin-bottom: 8px;\n display: flex;\n align-items: center;\n gap: 6px;\n font-family: 'JetBrains Mono', monospace;\n font-weight: 700;\n}\n\n.card-value {\n font-size: 28px;\n font-weight: 700;\n font-family: 'JetBrains Mono', monospace;\n}\n\n.card-sub {\n font-size: 11px;\n color: var(--text-secondary);\n margin-top: 4px;\n font-family: 'JetBrains Mono', monospace;\n}\n\n/* Progress bar */\n.progress {\n height: 4px;\n background: var(--bg-tertiary);\n border-radius: 0;\n margin-top: 12px;\n overflow: hidden;\n border: 1px solid var(--border-color);\n}\n\n.progress-fill {\n height: 100%;\n background: var(--accent);\n border-radius: 0;\n transition: width 0.3s;\n}\n\n/* Tasks list */\n/* --- Stalinist Grid --- */\n.task-list {\n background: var(--bg-tertiary);\n border: 3px solid var(--border-color);\n border-radius: 0;\n overflow: hidden;\n}\n\n.task-header {\n padding: 14px 20px;\n border-bottom: 3px solid var(--border-color);\n display: flex;\n justify-content: space-between;\n align-items: center;\n background: var(--bg-secondary);\n}\n\n.task-header h3 {\n font-size: 14px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 1.5px;\n font-family: 'JetBrains Mono', monospace;\n}\n\n.task-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));\n gap: 0;\n padding: 0;\n}\n\n/* Default task = horizontal row (dashboard list) */\n.task {\n display: flex;\n flex-direction: row;\n align-items: center;\n padding: 14px 16px;\n border-bottom: 1px solid var(--border-color);\n transition: background 0.15s;\n position: relative;\n background: var(--bg-secondary);\n gap: 12px;\n}\n\n.task:last-child {\n border-bottom: none;\n}\n\n.task:hover {\n background: var(--bg-tertiary);\n}\n\n/* Grid card = column layout (tasks page) */\n.task-grid .task {\n flex-direction: column;\n align-items: stretch;\n padding: 16px;\n border-right: 3px solid var(--border-color);\n border-bottom: 3px solid var(--border-color);\n min-height: 130px;\n box-shadow: inset 2px 2px 6px rgba(0,0,0,0.12);\n gap: 0;\n}\n\n.task-grid .task:hover {\n box-shadow: inset 1px 1px 3px rgba(0,0,0,0.06);\n}\n\n.task-status-stripe {\n position: absolute;\n top: 0;\n left: 0;\n width: 4px;\n height: 100%;\n}\n.task-status-stripe.pending { background: var(--border-color); }\n.task-status-stripe.in_progress { background: var(--warning); }\n.task-status-stripe.done { background: var(--success); }\n.task-status-stripe.blocked { background: var(--danger); }\n\n.task-check {\n width: 20px;\n height: 20px;\n border-radius: 0;\n border: 2px solid var(--border-color);\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 11px;\n flex-shrink: 0;\n}\n\n.task-check.done {\n background: var(--success);\n border-color: var(--success);\n color: white;\n}\n\n.task-check.progress {\n background: var(--warning);\n border-color: var(--warning);\n color: white;\n}\n\n.task-check.blocked {\n background: var(--danger);\n border-color: var(--danger);\n color: white;\n}\n\n.task-top-row {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-bottom: 10px;\n}\n\n.task-content {\n flex: 1;\n}\n\n.task-title {\n font-weight: 600;\n font-size: 13px;\n margin-bottom: 6px;\n line-height: 1.3;\n font-family: 'JetBrains Mono', monospace;\n}\n\n.task-meta {\n font-size: 11px;\n color: var(--text-secondary);\n font-family: 'JetBrains Mono', monospace;\n}\n\n.task-desc-preview {\n font-size: 11px;\n color: var(--text-secondary);\n margin-top: 8px;\n line-height: 1.4;\n overflow: hidden;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n}\n\n.task-card-footer {\n margin-top: auto;\n padding-top: 10px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n font-size: 11px;\n color: var(--text-secondary);\n}\n.task-card-footer i {\n font-size: 12px;\n color: var(--accent);\n}\n\n.task-phase {\n background: var(--bg-tertiary);\n padding: 2px 8px;\n border-radius: 0;\n border: 1px solid var(--border-color);\n font-size: 10px;\n color: var(--text-secondary);\n font-family: 'JetBrains Mono', monospace;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n}\n\n/* Activity log */\n.activity {\n background: var(--bg-secondary);\n border: 3px solid var(--border-color);\n border-radius: 0;\n margin-top: 24px;\n}\n\n.activity-header {\n padding: 14px 20px;\n border-bottom: 3px solid var(--border-color);\n font-weight: 700;\n font-size: 13px;\n font-family: 'JetBrains Mono', monospace;\n text-transform: uppercase;\n letter-spacing: 1.5px;\n}\n\n.activity-list {\n max-height: 300px;\n overflow-y: auto;\n}\n\n.activity-item {\n display: flex;\n gap: 12px;\n padding: 12px 20px;\n border-bottom: 1px solid var(--border-color);\n font-size: 13px;\n}\n\n.activity-item:last-child {\n border-bottom: none;\n}\n\n.activity-time {\n color: var(--text-secondary);\n font-family: monospace;\n flex-shrink: 0;\n}\n\n.activity-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n margin-top: 5px;\n flex-shrink: 0;\n}\n\n.activity-dot.info { background: var(--accent); }\n.activity-dot.success { background: var(--success); }\n.activity-dot.warning { background: var(--warning); }\n.activity-dot.error { background: var(--danger); }\n\n.activity-text {\n flex: 1;\n}\n\n.activity-details {\n color: var(--text-secondary);\n}\n\n/* Forms */\n.form-section {\n background: var(--bg-secondary);\n border: 3px solid var(--border-color);\n border-radius: 0;\n padding: 20px;\n margin-top: 24px;\n}\n\n.form-section h3 {\n font-size: 13px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 1.5px;\n font-family: 'JetBrains Mono', monospace;\n margin-bottom: 16px;\n}\n\n.form-row {\n display: flex;\n gap: 12px;\n margin-bottom: 12px;\n}\n\ninput[type=\"text\"] {\n flex: 1;\n padding: 10px 14px;\n border: 2px solid var(--border-color);\n border-radius: 0;\n background: var(--bg-primary);\n color: var(--text-primary);\n font-size: 13px;\n font-family: 'JetBrains Mono', monospace;\n}\n\ninput[type=\"text\"]:focus {\n outline: none;\n border-color: var(--accent);\n box-shadow: none;\n}\n\ninput[type=\"text\"]::placeholder {\n color: var(--text-secondary);\n}\n\ntextarea {\n width: 100%;\n padding: 10px 14px;\n border: 2px solid var(--border-color);\n border-radius: 0;\n background: var(--bg-primary);\n color: var(--text-primary);\n font-size: 13px;\n font-family: 'JetBrains Mono', monospace;\n resize: vertical;\n min-height: 60px;\n box-sizing: border-box;\n}\ntextarea:focus {\n outline: none;\n border-color: var(--accent);\n box-shadow: none;\n}\ntextarea::placeholder {\n color: var(--text-secondary);\n}\n\n.priority-badge {\n display: inline-block;\n background: var(--accent);\n color: white;\n padding: 1px 6px;\n border-radius: 0;\n font-size: 10px;\n font-weight: 700;\n font-family: 'JetBrains Mono', monospace;\n margin-left: 4px;\n}\n\n.task[draggable=\"true\"] {\n cursor: grab;\n}\n.task[draggable=\"true\"]:active {\n cursor: grabbing;\n}\n.task.drag-over {\n border-top: 2px solid var(--accent);\n}\n\n/* Terminal-style log panel */\n.log-panel {\n background: #1a1a1a;\n border-radius: 0;\n margin-top: 24px;\n overflow: hidden;\n box-shadow: none;\n border: 3px solid #333;\n}\n.log-panel-header {\n padding: 10px 16px;\n background: #111;\n border-bottom: 3px solid #333;\n display: flex;\n justify-content: space-between;\n align-items: center;\n}\n.terminal-dots {\n display: flex;\n gap: 6px;\n align-items: center;\n}\n.terminal-dots span {\n width: 10px;\n height: 10px;\n border-radius: 0;\n display: inline-block;\n}\n.terminal-dots .dot-red { background: #DA7756; }\n.terminal-dots .dot-yellow { background: #e8b749; }\n.terminal-dots .dot-green { background: #5cb85c; }\n.terminal-title {\n font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;\n font-size: 12px;\n color: #6c7086;\n letter-spacing: 0.5px;\n}\n.log-feed {\n max-height: 340px;\n overflow-y: auto;\n padding: 4px 0;\n scrollbar-width: thin;\n scrollbar-color: #444 #1a1a1a;\n}\n.log-feed::-webkit-scrollbar { width: 6px; }\n.log-feed::-webkit-scrollbar-track { background: #1a1a1a; }\n.log-feed::-webkit-scrollbar-thumb { background: #444; border-radius: 0; }\n.log-entry {\n display: flex;\n gap: 8px;\n padding: 3px 16px;\n font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;\n font-size: 12px;\n align-items: flex-start;\n line-height: 1.5;\n transition: background 0.15s;\n}\n.log-entry:hover {\n background: rgba(69, 71, 90, 0.3);\n}\n.log-entry i {\n width: 16px;\n text-align: center;\n flex-shrink: 0;\n font-size: 11px;\n margin-top: 2px;\n}\n.log-time {\n color: #585b70;\n font-size: 11px;\n flex-shrink: 0;\n user-select: none;\n}\n.log-text {\n flex: 1;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: #cdd6f4;\n}\n.log-prompt {\n color: #a6e3a1;\n flex-shrink: 0;\n user-select: none;\n}\n.log-cursor {\n display: inline-block;\n width: 7px;\n height: 14px;\n background: #a6e3a1;\n animation: blink 1s step-end infinite;\n margin-left: 4px;\n vertical-align: text-bottom;\n}\n@keyframes blink {\n 0%, 100% { opacity: 1; }\n 50% { opacity: 0; }\n}\n.log-empty {\n padding: 20px 16px;\n color: #585b70;\n font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;\n font-size: 12px;\n text-align: center;\n}\n.raw-log {\n max-height: 300px;\n overflow-y: auto;\n padding: 12px 16px;\n font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;\n font-size: 11px;\n white-space: pre-wrap;\n word-break: break-all;\n background: #11111b;\n color: #a6adc8;\n border-top: 1px solid #313244;\n}\n.log-toggle {\n font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;\n font-size: 11px;\n color: #89b4fa;\n cursor: pointer;\n background: none;\n border: none;\n padding: 2px 8px;\n border-radius: 4px;\n transition: background 0.15s;\n}\n.log-toggle:hover {\n background: rgba(137, 180, 250, 0.1);\n}\n/* Pixel art indicator */\n.px-icon {\n display: inline-block;\n width: 6px;\n height: 6px;\n border-radius: 1px;\n flex-shrink: 0;\n margin-top: 5px;\n image-rendering: pixelated;\n box-shadow: 1px 0 0 0 currentColor, 0 1px 0 0 currentColor;\n}\n.log-label {\n font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;\n font-size: 10px;\n font-weight: 600;\n letter-spacing: 0.5px;\n min-width: 44px;\n flex-shrink: 0;\n text-transform: uppercase;\n}\n\nbutton {\n padding: 10px 20px;\n border: 2px solid transparent;\n border-radius: 0;\n font-size: 12px;\n font-weight: 700;\n font-family: 'JetBrains Mono', monospace;\n text-transform: uppercase;\n letter-spacing: 1px;\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n transition: transform 0.1s, opacity 0.2s;\n}\n\nbutton:hover { filter: brightness(1.1); }\nbutton:active { transform: scale(0.98); }\n\n.btn-primary { background: var(--accent); color: white; border-color: var(--accent); }\n.btn-primary:hover { box-shadow: none; filter: brightness(1.15); }\n.btn-success { background: var(--success); color: white; border-color: var(--success); }\n.btn-success:hover { box-shadow: none; filter: brightness(1.15); }\n.btn-danger { background: var(--danger); color: white; border-color: var(--danger); }\n.btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); border-color: var(--border-color); }\n\n/* Global select styling */\nselect {\n padding: 10px 14px;\n border: 2px solid var(--border-color);\n border-radius: 0;\n background: var(--bg-primary);\n color: var(--text-primary);\n font-size: 13px;\n font-family: 'JetBrains Mono', monospace;\n cursor: pointer;\n width: 100%;\n transition: border-color 0.15s;\n -webkit-appearance: none;\n appearance: none;\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E\");\n background-repeat: no-repeat;\n background-position: right 12px center;\n padding-right: 32px;\n}\nselect:focus {\n outline: none;\n border-color: var(--accent);\n box-shadow: none;\n}\ninput[type=\"number\"] {\n padding: 10px 14px;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n background: var(--bg-primary);\n color: var(--text-primary);\n font-size: 14px;\n width: 100%;\n transition: border-color 0.2s, box-shadow 0.2s;\n}\ninput[type=\"number\"]:focus {\n outline: none;\n border-color: var(--accent);\n box-shadow: 0 0 0 3px rgba(218,119,86,0.2);\n}\ninput[type=\"password\"] {\n padding: 10px 14px;\n border: 2px solid var(--border-color);\n border-radius: 0;\n background: var(--bg-primary);\n color: var(--text-primary);\n font-size: 13px;\n font-family: 'JetBrains Mono', monospace;\n width: 100%;\n transition: border-color 0.15s;\n}\ninput[type=\"password\"]:focus {\n outline: none;\n border-color: var(--accent);\n box-shadow: none;\n}\n\n/* Styled scrollbars */\n.activity-list::-webkit-scrollbar { width: 6px; }\n.activity-list::-webkit-scrollbar-track { background: transparent; }\n.activity-list::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }\n\n/* Toast notification */\n.toast {\n position: fixed;\n bottom: 24px;\n right: 24px;\n padding: 12px 20px;\n background: var(--success);\n color: white;\n border-radius: 0;\n font-size: 12px;\n font-weight: 700;\n font-family: 'JetBrains Mono', monospace;\n box-shadow: none;\n border: 2px solid rgba(255,255,255,0.2);\n z-index: 1000;\n animation: toast-in 0.3s ease, toast-out 0.3s ease 2s forwards;\n}\n@keyframes toast-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }\n@keyframes toast-out { from { opacity: 1; } to { opacity: 0; transform: translateY(10px); } }\n\n/* Settings-specific styles */\n.settings-label {\n font-size: 11px;\n color: var(--text-secondary);\n margin-bottom: 4px;\n display: block;\n font-family: 'JetBrains Mono', monospace;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n}\n.settings-grid {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 12px;\n margin-top: 8px;\n}\n.settings-hint {\n color: var(--text-secondary);\n font-size: 11px;\n margin-top: 6px;\n font-family: 'JetBrains Mono', monospace;\n}\n\n/* Commit styling */\n.commit-hash {\n font-family: 'JetBrains Mono', 'SF Mono', monospace;\n font-size: 12px;\n color: var(--text-secondary);\n background: var(--bg-tertiary);\n padding: 2px 8px;\n border-radius: 0;\n border: 1px solid var(--border-color);\n}\n.commit-msg {\n font-weight: 500;\n}\n.commit-time {\n font-size: 12px;\n color: var(--text-secondary);\n}\n\n/* Control buttons */\n.controls {\n display: flex;\n gap: 12px;\n margin-top: 24px;\n}\n\n.controls form {\n flex: 1;\n}\n\n.controls button {\n width: 100%;\n padding: 14px;\n font-size: 15px;\n}\n\n/* Theme toggle */\n.theme-toggle {\n background: none;\n border: 2px solid var(--border-color);\n padding: 8px 12px;\n cursor: pointer;\n border-radius: 0;\n color: var(--text-primary);\n font-family: 'JetBrains Mono', monospace;\n}\n\n/* Session details */\n.session-card {\n background: var(--bg-secondary);\n border: 3px solid var(--border-color);\n border-radius: 0;\n padding: 20px;\n margin-bottom: 16px;\n}\n\n.session-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 16px;\n}\n\n.session-title {\n font-weight: 600;\n font-size: 16px;\n}\n\n.session-meta {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));\n gap: 16px;\n}\n\n.meta-item {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.meta-label {\n font-size: 11px;\n text-transform: uppercase;\n color: var(--text-secondary);\n}\n\n.meta-value {\n font-weight: 500;\n}\n\n/* Empty state */\n.empty {\n text-align: center;\n padding: 40px;\n color: var(--text-secondary);\n}\n\n.empty i {\n font-size: 48px;\n margin-bottom: 16px;\n opacity: 0.5;\n}\n\n/* Pulsing indicator for running state */\n.pulse-dot {\n display: inline-block;\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: #fff;\n margin: 0 2px;\n animation: pulse-anim 1.5s ease-in-out infinite;\n}\n\n@keyframes pulse-anim {\n 0%, 100% { opacity: 1; transform: scale(1); }\n 50% { opacity: 0.4; transform: scale(0.7); }\n}\n\n/* Live timer styling */\n.live-timer {\n font-family: monospace;\n font-variant-numeric: tabular-nums;\n}\n\n@media (max-width: 1200px) {\n .cards {\n grid-template-columns: repeat(2, 1fr);\n }\n}\n\n@media (max-width: 768px) {\n .layout {\n grid-template-columns: 1fr;\n }\n .sidebar {\n position: fixed;\n left: -260px;\n z-index: 100;\n transition: left 0.3s;\n width: 240px;\n }\n .sidebar.open {\n left: 0;\n }\n .hamburger {\n display: block !important;\n }\n .cards {\n grid-template-columns: 1fr;\n }\n .task-grid {\n grid-template-columns: repeat(2, 1fr);\n }\n .log-feed {\n max-height: none;\n }\n}\n\n@media (max-width: 480px) {\n .task-grid {\n grid-template-columns: 1fr;\n }\n}\n\n.hamburger {\n display: none;\n background: none;\n border: 2px solid var(--border-color);\n padding: 8px 12px;\n cursor: pointer;\n border-radius: 0;\n color: var(--text-primary);\n font-size: 18px;\n font-family: 'JetBrains Mono', monospace;\n}\n\n/* Task detail expandable — slide transition */\n.task-detail {\n max-height: 0;\n overflow: hidden;\n padding: 0 20px 0 56px;\n border-bottom: 1px solid var(--border-color);\n transition: max-height 0.3s ease, padding 0.3s ease;\n}\n.task-detail.open {\n max-height: 400px;\n padding: 12px 20px 16px 56px;\n}\n\n.task-stages {\n display: flex;\n gap: 4px;\n margin-bottom: 12px;\n}\n\n.stage-step {\n flex: 1;\n height: 6px;\n border-radius: 3px;\n background: var(--bg-tertiary);\n}\n\n.stage-step.active {\n background: var(--warning);\n}\n\n.stage-step.done {\n background: var(--success);\n}\n\n.task-detail-meta {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));\n gap: 8px;\n font-size: 12px;\n color: var(--text-secondary);\n}\n\n.task-detail-meta dt {\n font-weight: 600;\n color: var(--text-primary);\n}\n\n.task-criteria {\n margin-top: 8px;\n padding: 8px 12px;\n background: var(--bg-tertiary);\n border-radius: 8px;\n font-size: 12px;\n}\n\n.task-clickable {\n cursor: pointer;\n}\n\n/* Task Detail Page */\n.back-link {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n color: var(--text-secondary);\n text-decoration: none;\n font-size: 13px;\n margin-bottom: 16px;\n transition: color 0.2s;\n}\n.back-link:hover { color: var(--accent); }\n\n.task-detail-header {\n display: flex;\n align-items: center;\n gap: 16px;\n margin-bottom: 24px;\n}\n.task-detail-header h1 {\n font-size: 22px;\n font-weight: 600;\n flex: 1;\n}\n.td-badge {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n padding: 4px 12px;\n border-radius: 16px;\n font-size: 12px;\n font-weight: 500;\n}\n.td-badge.pending { background: var(--bg-tertiary); color: var(--text-secondary); }\n.td-badge.in_progress { background: var(--warning); color: #fff; }\n.td-badge.done { background: var(--success); color: #fff; }\n.td-badge.blocked { background: var(--danger); color: #fff; }\n\n.td-grid {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 16px;\n margin-bottom: 24px;\n}\n@media (max-width: 768px) { .td-grid { grid-template-columns: 1fr; } }\n\n.td-info {\n background: var(--bg-secondary);\n border: 1px solid var(--border-color);\n border-radius: 12px;\n padding: 20px;\n}\n.td-info-label {\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n color: var(--text-secondary);\n margin-bottom: 4px;\n}\n.td-info-value {\n font-size: 14px;\n margin-bottom: 14px;\n}\n.td-info-value:last-child { margin-bottom: 0; }\n\n.td-metrics {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 12px;\n}\n.td-metric {\n background: var(--bg-secondary);\n border: 1px solid var(--border-color);\n border-radius: 12px;\n padding: 16px;\n text-align: center;\n}\n.td-metric-val {\n font-size: 28px;\n font-weight: 600;\n color: var(--accent);\n}\n.td-metric-label {\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n color: var(--text-secondary);\n margin-top: 4px;\n}\n\n.td-log-section {\n background: var(--bg-secondary);\n border: 1px solid var(--border-color);\n border-radius: 12px;\n margin-bottom: 24px;\n overflow: hidden;\n}\n.td-log-header {\n padding: 14px 20px;\n border-bottom: 1px solid var(--border-color);\n font-weight: 600;\n font-size: 14px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n}\n.td-log-body {\n max-height: 400px;\n overflow-y: auto;\n padding: 8px 0;\n font-family: 'JetBrains Mono', monospace;\n font-size: 12px;\n line-height: 1.7;\n}\n.td-log-entry {\n display: flex;\n align-items: flex-start;\n gap: 8px;\n padding: 3px 16px;\n}\n.td-log-entry:hover { background: var(--bg-tertiary); }\n.td-log-time {\n color: var(--text-secondary);\n flex-shrink: 0;\n font-size: 11px;\n}\n.td-log-icon {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n flex-shrink: 0;\n margin-top: 5px;\n}\n.td-log-label {\n font-size: 10px;\n font-weight: 600;\n letter-spacing: 0.5px;\n flex-shrink: 0;\n min-width: 44px;\n}\n.td-log-text {\n flex: 1;\n word-break: break-all;\n color: var(--text-primary);\n}\n.td-log-empty {\n padding: 32px;\n text-align: center;\n color: var(--text-secondary);\n}\n\n.td-sessions {\n background: var(--bg-secondary);\n border: 1px solid var(--border-color);\n border-radius: 12px;\n overflow: hidden;\n}\n.td-sessions-header {\n padding: 14px 20px;\n border-bottom: 1px solid var(--border-color);\n font-weight: 600;\n font-size: 14px;\n}\n.td-session-item {\n display: flex;\n align-items: center;\n gap: 16px;\n padding: 12px 20px;\n border-bottom: 1px solid var(--border-color);\n font-size: 13px;\n}\n.td-session-item:last-child { border-bottom: none; }\n.td-session-num {\n font-weight: 600;\n color: var(--accent);\n min-width: 70px;\n}\n.td-session-meta {\n display: flex;\n gap: 16px;\n flex: 1;\n color: var(--text-secondary);\n font-size: 12px;\n}\n'''\n\nHTML_TEMPLATE = Template('''\n\n\n \n \n PocketCoder-A1 Dashboard\n \n \n \n \n \n\n\n
\n \n\n
\n \n $content\n
\n
\n\n \n\n\n''')\n\n\nclass DashboardHandler(BaseHTTPRequestHandler):\n def log_message(self, format, *args):\n pass\n\n def _safe_write(self, data):\n try:\n self.wfile.write(data)\n except BrokenPipeError:\n pass\n\n def do_GET(self):\n path = urlparse(self.path).path\n\n if path == '/' or path == '/index.html':\n self.send_page('dashboard')\n elif path == '/tasks':\n self.send_page('tasks')\n elif path == '/sessions':\n self.send_page('sessions')\n elif path == '/log':\n self.send_page('log')\n elif path == '/commits':\n self.send_page('commits')\n elif path == '/settings':\n self.send_page('settings')\n elif path == '/transform':\n self.send_response(302)\n self.send_header('Location', '/tasks')\n self.end_headers()\n elif path == '/api/status':\n self.send_json_status()\n elif path.startswith('/api/log'):\n self.send_json_log()\n elif path == '/api/config':\n self.send_json_config()\n elif re.match(r'^/api/task/(task_\\d+)$', path):\n task_id = re.match(r'^/api/task/(task_\\d+)$', path).group(1)\n self.send_json_task(task_id)\n elif re.match(r'^/task/(task_\\d+)$', path):\n task_id = re.match(r'^/task/(task_\\d+)$', path).group(1)\n self.send_page('task_detail', task_id=task_id)\n else:\n self.send_error(404)\n\n def do_POST(self):\n content_length = int(self.headers.get('Content-Length', 0))\n post_data = self.rfile.read(content_length).decode('utf-8')\n params = parse_qs(post_data)\n\n if self.path == '/add-task':\n task_text = params.get('task', [''])[0]\n task_desc = params.get('description', [''])[0]\n if task_text:\n tasks = TaskManager(PROJECT_DIR)\n tasks.add_task(task_text, description=task_desc)\n log_activity(\"Task added\", task_text, \"success\")\n self.redirect('/')\n\n elif self.path == '/add-thought':\n thought = params.get('thought', [''])[0]\n if thought:\n tasks = TaskManager(PROJECT_DIR)\n tasks.add_raw_thought(thought)\n log_activity(\"Thought added\", thought, \"info\")\n self.redirect('/')\n\n elif self.path == '/start':\n self.start_agent()\n self.redirect('/')\n\n elif re.match(r'^/start-task/(task_\\d+)$', self.path):\n task_id = re.match(r'^/start-task/(task_\\d+)$', self.path).group(1)\n self.start_task_agent(task_id)\n\n elif self.path == '/stop':\n self.stop_agent()\n self.redirect('/')\n\n elif re.match(r'^/delete-task/(task_\\d+)$', self.path):\n task_id = re.match(r'^/delete-task/(task_\\d+)$', self.path).group(1)\n tasks_mgr = TaskManager(PROJECT_DIR)\n tasks_mgr.delete_task(task_id)\n log_activity(\"Task deleted\", task_id, \"warning\")\n self.redirect('/tasks')\n\n elif self.path == '/add-tasks-bulk':\n lines_raw = params.get('tasks_bulk', [''])[0]\n if lines_raw.strip():\n tasks_mgr = TaskManager(PROJECT_DIR)\n count = 0\n for line in lines_raw.strip().split('\\n'):\n line = line.strip().lstrip('- ').lstrip('* ').lstrip('0123456789.)')\n line = line.strip()\n if line:\n tasks_mgr.add_task(line)\n count += 1\n log_activity(\"Bulk tasks added\", f\"{count} tasks\", \"success\")\n self.redirect('/tasks')\n\n elif self.path == '/toggle-theme':\n self.redirect('/')\n elif self.path == '/transform':\n raw_text = params.get('text', [''])[0]\n if raw_text:\n result = self._transform_text(raw_text)\n self.send_response(200)\n self.send_header('Content-Type', 'application/json')\n self.end_headers()\n self.wfile.write(json.dumps(result).encode('utf-8'))\n else:\n self.send_response(400)\n self.end_headers()\n\n elif self.path == '/transform-confirm':\n try:\n body = json.loads(post_data)\n tasks_list = body.get('tasks', [])\n tasks_mgr = TaskManager(PROJECT_DIR)\n added = 0\n for t in tasks_list:\n if t.get('title'):\n tasks_mgr.add_task(t['title'], description=t.get('description', ''), success_criteria=t.get('success_criteria'))\n added += 1\n log_activity(\"Transform confirmed\", f\"{added} tasks added\", \"success\")\n self.send_response(200)\n self.send_header('Content-Type', 'application/json')\n self.end_headers()\n self.wfile.write(json.dumps({\"ok\": True, \"added\": added}).encode('utf-8'))\n except Exception:\n self.send_response(400)\n self.end_headers()\n\n elif self.path == '/queue-message':\n msg_text = params.get('message', [''])[0]\n if msg_text:\n queue_file = PROJECT_DIR / '.a1' / 'queue.json'\n queue_data = {\"messages\": []}\n if queue_file.exists():\n try:\n queue_data = json.loads(queue_file.read_text())\n except (json.JSONDecodeError, IOError):\n pass\n queue_data[\"messages\"].append({\n \"text\": msg_text,\n \"added_at\": datetime.now().isoformat(),\n \"read\": False,\n })\n queue_file.write_text(json.dumps(queue_data, indent=2, ensure_ascii=False))\n log_activity(\"Message queued\", msg_text[:50], \"info\")\n self.send_response(200)\n self.send_header('Content-Type', 'application/json')\n self.end_headers()\n self.wfile.write(b'{\"ok\": true}')\n\n elif self.path == '/api/config':\n try:\n body = json.loads(post_data)\n from .config import Config\n config = Config(PROJECT_DIR)\n for key, value in body.items():\n config.set(key, value)\n log_activity(\"Config updated\", \", \".join(body.keys()), \"info\")\n self.send_response(200)\n self.send_header('Content-Type', 'application/json')\n self.end_headers()\n self.wfile.write(json.dumps({\"ok\": True}).encode('utf-8'))\n except Exception as e:\n self.send_response(400)\n self.send_header('Content-Type', 'application/json')\n self.end_headers()\n self.wfile.write(json.dumps({\"error\": str(e)}).encode('utf-8'))\n\n elif self.path == '/api/reorder':\n try:\n body = json.loads(post_data)\n task_ids = body.get('order', [])\n if task_ids:\n tasks_mgr = TaskManager(PROJECT_DIR)\n tasks_mgr.reorder_tasks(task_ids)\n log_activity(\"Tasks reordered\", f\"{len(task_ids)} tasks\", \"info\")\n self.send_response(200)\n self.send_header('Content-Type', 'application/json')\n self.end_headers()\n self.wfile.write(b'{\"ok\": true}')\n except Exception:\n self.send_response(400)\n self.end_headers()\n else:\n self.send_error(404)\n\n def redirect(self, location):\n self.send_response(302)\n self.send_header('Location', location)\n self.end_headers()\n\n def send_page(self, page, task_id=None):\n theme = 'light' # Default light theme\n content = self.build_content(page, task_id=task_id)\n\n nav_active = {\n 'nav_dashboard': 'active' if page == 'dashboard' else '',\n 'nav_tasks': 'active' if page in ('tasks', 'task_detail') else '',\n 'nav_sessions': 'active' if page == 'sessions' else '',\n 'nav_log': 'active' if page == 'log' else '',\n 'nav_commits': 'active' if page == 'commits' else '',\n 'nav_transform': 'active' if page == 'transform' else '',\n 'nav_settings': 'active' if page == 'settings' else '',\n }\n\n html = HTML_TEMPLATE.substitute(\n theme=theme,\n content=content,\n page_name=page,\n **nav_active\n )\n\n self.send_response(200)\n self.send_header('Content-Type', 'text/html; charset=utf-8')\n self.end_headers()\n try:\n self.wfile.write(html.encode('utf-8'))\n except (BrokenPipeError, ConnectionResetError):\n pass\n\n def build_content(self, page, task_id=None):\n if page == 'dashboard':\n return self.build_dashboard()\n elif page == 'tasks':\n return self.build_tasks_page()\n elif page == 'task_detail' and task_id:\n return self.build_task_detail_page(task_id)\n elif page == 'sessions':\n return self.build_sessions_page()\n elif page == 'log':\n return self.build_log_page()\n elif page == 'commits':\n return self.build_commits_page()\n elif page == 'settings':\n return self.build_settings_page()\n elif page == 'transform':\n return self.build_transform_page()\n return ''\n\n def build_dashboard(self):\n checkpoint = CheckpointManager(PROJECT_DIR)\n tasks_mgr = TaskManager(PROJECT_DIR)\n\n cp = checkpoint.load()\n all_tasks = tasks_mgr.get_tasks()\n done, total, blocked = tasks_mgr.get_progress()\n progress = int((done / total * 100)) if total > 0 else 0\n\n # Status\n if AGENT_RUNNING:\n status_class = 'status-running'\n status_text = ' Running'\n elif cp.get('status') == 'COMPLETED':\n status_class = 'status-completed'\n status_text = ' Completed'\n else:\n status_class = 'status-stopped'\n status_text = ' Stopped'\n\n # Tasks HTML (last 5, sorted by priority)\n sorted_tasks = sorted(all_tasks, key=lambda t: (t.status == 'done', t.priority))\n tasks_html = ''\n for t in sorted_tasks[:5]:\n if t.status == 'done':\n check_class = 'done'\n check_icon = ''\n elif t.status == 'in_progress':\n check_class = 'progress'\n check_icon = ''\n elif t.status == 'blocked':\n check_class = 'blocked'\n check_icon = ''\n else:\n check_class = ''\n check_icon = ''\n\n pri = getattr(t, 'priority', 0)\n pri_badge = f'#{pri}' if pri and t.status not in ('done', 'blocked') else ''\n\n tasks_html += f'''\n \n
{check_icon}
\n
\n
{esc(t.title)}
\n
{esc(t.id)} {pri_badge}
\n
\n \n
\n '''\n\n if not tasks_html:\n tasks_html = '

No tasks yet

'\n\n # Activity HTML (last 5) with status icons\n status_icons = {\n 'success': '',\n 'error': '',\n 'warning': '',\n 'info': '',\n }\n activity_html = ''\n for a in reversed(ACTIVITY_LOG[-5:]):\n icon = status_icons.get(a['status'], status_icons['info'])\n activity_html += f'''\n
\n {esc(a['time'])}\n {icon}\n {esc(a['action'])} {esc(a['details'])}\n
\n '''\n\n if not activity_html:\n activity_html = '

No activity yet

'\n\n # Control buttons\n if AGENT_RUNNING:\n control_html = '''\n
\n \n
\n '''\n else:\n control_html = '''\n
\n \n
\n '''\n\n return f'''\n
\n

// Dashboard

\n
\n {status_text}\n \n
\n
\n\n
\n
\n
Tasks
\n
{done}/{total}
\n
{'completed' + (f', {blocked} blocked' if blocked else '')}
\n
\n
\n
\n
\n
\n
Session
\n
#{cp.get('session', 0)}
\n
{cp.get('status', 'Not started')}
\n
\n
\n
Tokens
\n
0 / 0
\n
in / out
\n
\n
\n
\n
Context: 0% (auto-save at 70%)
\n
\n
\n
Cost
\n
$$0.00
\n
this session
\n
\n
\n
Duration
\n
0s
\n
current session
\n
\n
\n
Files
\n
{len(cp.get('files_modified', []))}
\n
modified
\n
\n
\n\n
\n
\n

Recent Tasks

\n View All\n
\n {tasks_html}\n
\n\n
\n

Quick Add

\n
\n
\n \n \n
\n \n
\n
\n\n
\n {control_html}\n
\n\n
\n

Message to Agent

\n
\n
\n \n \n
\n
\n
\n
\n\n
\n
\n
\n
\n \n \n \n
\n agent@pocketcoder ~ live-log\n
\n \n
\n
\n {self._render_log_entries()}\n
\n
\n
{self._render_raw_log()}
\n
\n
\n\n
\n
Recent Activity
\n
\n {activity_html}\n
\n
\n '''\n\n def build_tasks_page(self):\n tasks_mgr = TaskManager(PROJECT_DIR)\n all_tasks = tasks_mgr.get_tasks()\n all_tasks.sort(key=lambda t: (t.status == 'done', t.priority))\n thoughts = tasks_mgr.get_raw_thoughts()\n\n tasks_html = ''\n for t in all_tasks:\n if t.status == 'done':\n check_class = 'done'\n check_icon = ''\n elif t.status == 'in_progress':\n check_class = 'progress'\n check_icon = ''\n elif t.status == 'blocked':\n check_class = 'blocked'\n check_icon = ''\n else:\n check_class = ''\n check_icon = ''\n\n desc = t.description[:80] if t.description else ''\n pri = getattr(t, 'priority', 0)\n pri_badge = f'#{pri}' if pri and t.status not in ('done', 'blocked') else ''\n phase_text = getattr(t, 'phase', '') or ''\n phase_tag = f'{esc(phase_text)}' if phase_text else ''\n status_class = t.status.replace(' ', '_')\n\n tasks_html += f'''\n \n
\n
\n
{check_icon}
\n
{esc(t.id)} {pri_badge}
\n {phase_tag}\n
\n
\n
{esc(t.title)}
\n {f'
{esc(desc)}
' if desc else ''}\n
\n
\n {esc(t.status.replace(\"_\", \" \").upper())}\n \n
\n
\n '''\n\n thoughts_html = ''\n if thoughts:\n for th in thoughts:\n thoughts_html += f'''\n
\n
\n
\n
{esc(th['text'])}
\n
Raw thought
\n
\n
\n '''\n\n return f'''\n
\n

// Tasks

\n \n
\n\n
\n
\n

[ {len(all_tasks)} tasks ]

\n
\n
\n {tasks_html if tasks_html else '

No tasks yet

'}\n
\n
\n\n {('

Raw Thoughts

' + thoughts_html + '
') if thoughts_html else ''}\n\n
\n

// Add Task

\n
\n
\n \n \n
\n \n
\n
\n
\n \n \n
\n
\n
\n\n
\n

// Bulk Import

\n

One task per line. Priorities auto-assigned.

\n
\n \n
\n \n
\n
\n
\n\n
\n

// AI Transform

\n

\n Write raw text, notes, ideas — AI breaks them into structured tasks.\n

\n \n
\n \n \n
\n
\n\n
\n
\n
\n

[ preview ]

\n \n
\n
\n
\n
\n\n \n '''\n\n def build_task_detail_page(self, task_id):\n \"\"\"Full task detail page with logs, metrics, sessions\"\"\"\n tasks_mgr = TaskManager(PROJECT_DIR)\n all_tasks = tasks_mgr.get_tasks()\n task = None\n for t in all_tasks:\n if t.id == task_id:\n task = t\n break\n if not task:\n return '

Task not found

Back to Tasks
'\n\n # Status badge\n badge_cls = task.status.replace(' ', '_')\n status_label = task.status.replace('_', ' ').title()\n\n # Task info\n desc = task.description or 'No description'\n criteria = task.success_criteria or 'N/A'\n phase = getattr(task, 'phase', '') or 'N/A'\n blocked_reason = getattr(task, 'blocked_reason', '') or ''\n created = task.created_at[:16].replace('T', ' ') if task.created_at else 'N/A'\n completed = task.completed_at[:16].replace('T', ' ') if task.completed_at else '—'\n pri = getattr(task, 'priority', 0) or 0\n\n # Check if this is the active task\n is_active = False\n active_task_id = _get_current_task_id()\n if AGENT_RUNNING and active_task_id == task_id:\n is_active = True\n\n # Filtered logs: if active task, show ALL logs (including untagged)\n # If historical, filter by task_id match only\n if is_active:\n task_logs = [e for e in AGENT_LOG_BUFFER if e.get('task_id') in (task_id, None)]\n else:\n task_logs = [e for e in AGENT_LOG_BUFFER if e.get('task_id') == task_id]\n\n # Log type colors and labels\n label_map = {\n 'read': 'READ', 'edit': 'EDIT', 'write': 'WRITE',\n 'bash': 'BASH', 'thinking': 'THINK', 'text': 'OUT',\n 'metric': 'METRIC', 'verify': 'CHECK',\n }\n color_map = {\n 'read': '#89b4fa', 'edit': '#DA7756', 'write': '#2E8B57',\n 'bash': '#b392f0', 'thinking': '#D4940A', 'text': '#6B6B6B',\n 'metric': '#89dceb', 'verify': '#2E8B57',\n }\n\n # Derive metrics from logs\n tool_count = len([e for e in task_logs if e.get('type') in ('read', 'edit', 'write', 'bash')])\n time_span = '—'\n if task_logs:\n time_span = f\"{task_logs[0]['time']} — {task_logs[-1]['time']}\"\n\n # Live metrics from running agent\n live_tokens_in = 0\n live_tokens_out = 0\n if is_active and AGENT_LOOP and hasattr(AGENT_LOOP, 'get_session_metrics'):\n lm = AGENT_LOOP.get_session_metrics()\n live_tokens_in = lm.get('tokens_in', 0)\n live_tokens_out = lm.get('tokens_out', 0)\n\n # Sessions from checkpoint archives\n sessions_html = ''\n session_count = 0\n total_tokens_in = live_tokens_in\n total_tokens_out = live_tokens_out\n checkpoints_dir = PROJECT_DIR / '.a1' / 'checkpoints'\n if checkpoints_dir.exists():\n for f in sorted(checkpoints_dir.glob('session_*.json')):\n try:\n data = json.loads(f.read_text())\n if data.get('current_task') == task_id:\n session_count += 1\n sn = data.get('session', '?')\n sm = data.get('session_metrics', {})\n tok_in = sm.get('tokens_in', 0)\n tok_out = sm.get('tokens_out', 0)\n dur = sm.get('session_duration', 0)\n tools = sm.get('tools_used', 0)\n total_tokens_in += tok_in\n total_tokens_out += tok_out\n dur_str = f'{dur // 60}m {dur % 60}s' if dur >= 60 else f'{dur}s'\n def _ft(n):\n if n >= 1000000: return f'{n/1000000:.1f}M'\n if n >= 1000: return f'{n/1000:.1f}K'\n return str(n)\n sessions_html += f'''\n
\n Session #{sn}\n
\n {_ft(tok_in)}/{_ft(tok_out)}\n {dur_str}\n {tools} tools\n
\n {esc(data.get('status', '?'))}\n
'''\n except Exception:\n pass\n\n # Format token totals\n def _ft(n):\n if n >= 1000000: return f'{n/1000000:.1f}M'\n if n >= 1000: return f'{n/1000:.1f}K'\n return str(n)\n\n # Build log entries HTML\n log_html = ''\n for e in task_logs[-100:]:\n etype = e.get('type', 'text')\n label = label_map.get(etype, 'LOG')\n color = color_map.get(etype, '#6B6B6B')\n log_html += f'''
\n {esc(e[\"time\"])}\n \n {label}\n {esc(e[\"line\"][:200])}\n
'''\n\n active_dot = '' if is_active else ''\n\n return f'''\n Back to Tasks\n\n
\n

{esc(task.title)}{active_dot}

\n {status_label}\n {f'Priority #{pri}' if pri else ''}\n {'' if task.status in ('pending', 'in_progress') and not is_active else ''}\n {'' if is_active else ''}\n {'' if not is_active else ''}\n
\n\n
\n
\n
Description
\n
{esc(desc)}
\n
Success Criteria
\n
{esc(criteria)}
\n {f'
Blocked Reason
{esc(blocked_reason)}
' if blocked_reason else ''}\n
Phase
\n
{esc(phase)}
\n
Created
\n
{esc(created)}
\n
Completed
\n
{esc(completed)}
\n
\n
\n
\n
{tool_count}
\n
Tool Calls
\n
\n
\n
{session_count}
\n
Sessions
\n
\n
\n
{_ft(total_tokens_in)}
\n
Tokens In
\n
\n
\n
{_ft(total_tokens_out)}
\n
Tokens Out
\n
\n
\n
\n\n
\n
\n Execution Log\n {len(task_logs)} entries{' — LIVE' if is_active else ''}\n
\n
\n {log_html if log_html else '

No log entries for this task yet
'}\n
\n
\n\n {f\"\"\"
\n
Session History
\n {sessions_html}\n
\"\"\" if sessions_html else ''}\n\n \n '''\n\n def build_sessions_page(self):\n checkpoint = CheckpointManager(PROJECT_DIR)\n cp = checkpoint.load()\n\n sessions_html = ''\n checkpoints_dir = PROJECT_DIR / '.a1' / 'checkpoints'\n if checkpoints_dir.exists():\n for f in sorted(checkpoints_dir.glob('session_*.json'), reverse=True)[:10]:\n try:\n data = json.loads(f.read_text())\n session_num = data.get('session', '?')\n status = data.get('status', 'Unknown')\n files = len(data.get('files_modified', []))\n\n # Status badge color\n if status == 'COMPLETED':\n badge_cls = 'status-completed'\n elif status == 'WORKING':\n badge_cls = 'status-running'\n else:\n badge_cls = 'status-stopped'\n\n # Extract metrics from checkpoint data\n sm = data.get('session_metrics', {})\n tok_in = sm.get('tokens_in', 0)\n tok_out = sm.get('tokens_out', 0)\n duration = sm.get('session_duration', 0)\n tools = sm.get('tools_used', 0)\n\n # Format duration\n dur_str = f'{duration // 60}m {duration % 60}s' if duration >= 60 else f'{duration}s'\n\n # Format tokens\n def _fmt_tok(n):\n if n >= 1000000: return f'{n/1000000:.1f}M'\n if n >= 1000: return f'{n/1000:.1f}K'\n return str(n)\n\n sessions_html += f'''\n
\n
\n Session #{session_num}\n {esc(status)}\n
\n
\n
\n Files\n {files}\n
\n
\n Task\n {esc(data.get('current_task', 'N/A'))}\n
\n
\n Tokens\n {_fmt_tok(tok_in)} / {_fmt_tok(tok_out)}\n
\n
\n Duration\n {dur_str}\n
\n
\n Tool Calls\n {tools}\n
\n
\n
\n '''\n except Exception:\n pass\n\n # Current session status badge\n if AGENT_RUNNING:\n cur_badge = 'status-running'\n cur_label = ' Running'\n elif cp.get('status') == 'COMPLETED':\n cur_badge = 'status-completed'\n cur_label = ' Completed'\n elif cp.get('status') == 'WORKING':\n cur_badge = 'status-running'\n cur_label = ' Working'\n else:\n cur_badge = 'status-stopped'\n cur_label = ' Idle'\n\n return f'''\n
\n

// Sessions

\n \n
\n\n
\n
\n Current Session #{cp.get('session', 0)}\n {cur_label}\n
\n
\n
\n Status\n {esc(cp.get('status', 'Not started'))}\n
\n
\n Context\n {cp.get('context_percent', 0)}%\n
\n
\n Current Task\n {esc(cp.get('current_task', 'None'))}\n
\n
\n Files Modified\n {len(cp.get('files_modified', []))}\n
\n
\n
\n\n

Previous Sessions

\n {sessions_html if sessions_html else '

No previous sessions

'}\n '''\n\n def build_log_page(self):\n # Icons per status type\n status_icons = {\n 'success': '',\n 'error': '',\n 'warning': '',\n 'info': '',\n }\n\n activity_html = ''\n for a in reversed(ACTIVITY_LOG):\n icon = status_icons.get(a['status'], status_icons['info'])\n activity_html += f'''\n
\n {esc(a['time'])}\n {icon}\n \n {esc(a['action'])}\n {esc(a['details'])}\n \n
\n '''\n\n return f'''\n
\n

// Activity Log

\n \n
\n\n
\n
\n All Activity\n {len(ACTIVITY_LOG)} entries\n
\n
\n {activity_html if activity_html else '

No activity yet

'}\n
\n
\n '''\n\n def build_commits_page(self):\n # Get git log with dates and full formatting\n import subprocess\n commits_html = ''\n try:\n result = subprocess.run(\n ['git', 'log', '--format=%h|%s|%cr|%an', '-20'],\n cwd=PROJECT_DIR,\n capture_output=True,\n text=True\n )\n if result.returncode == 0:\n for line in result.stdout.strip().split('\\n'):\n if line and '|' in line:\n parts = line.split('|', 3)\n hash_short = parts[0] if len(parts) > 0 else ''\n msg = parts[1] if len(parts) > 1 else ''\n rel_time = parts[2] if len(parts) > 2 else ''\n author = parts[3] if len(parts) > 3 else ''\n\n # Split commit message: first line = title, rest = body\n msg_title = esc(msg)\n\n # Icon based on conventional commit prefix\n if msg.startswith('feat'):\n icon_cls = 'bi-plus-circle'\n icon_color = 'var(--success)'\n elif msg.startswith('fix'):\n icon_cls = 'bi-bug'\n icon_color = 'var(--danger)'\n elif msg.startswith('docs') or msg.startswith('doc'):\n icon_cls = 'bi-file-text'\n icon_color = 'var(--accent)'\n elif msg.startswith('refactor') or msg.startswith('chore'):\n icon_cls = 'bi-arrow-repeat'\n icon_color = 'var(--warning)'\n elif msg.startswith('test'):\n icon_cls = 'bi-check2-circle'\n icon_color = '#89dceb'\n else:\n icon_cls = 'bi-git'\n icon_color = 'var(--text-secondary)'\n\n commits_html += f'''\n
\n
\n
\n
{msg_title}
\n
\n {esc(hash_short)}\n {esc(rel_time)}\n {esc(author)}\n
\n
\n
\n '''\n except Exception:\n pass\n\n # Branch info\n branch = ''\n try:\n result = subprocess.run(\n ['git', 'branch', '--show-current'],\n cwd=PROJECT_DIR, capture_output=True, text=True\n )\n if result.returncode == 0:\n branch = result.stdout.strip()\n except Exception:\n pass\n\n return f'''\n
\n

// Commits

\n
\n {f' {esc(branch)}' if branch else ''}\n \n
\n
\n\n
\n
\n

Recent Commits

\n
\n {commits_html if commits_html else '

No commits found

'}\n
\n '''\n\n def build_settings_page(self):\n from .config import Config\n config = Config(PROJECT_DIR)\n data = config.get_all()\n provider = esc(data.get(\"provider\", \"claude-max\"))\n api_key_masked = esc(config.mask_api_key(data.get(\"api_key\")) or \"\")\n ollama_host = esc(data.get(\"ollama_host\", \"http://localhost:11434\"))\n ollama_model = esc(data.get(\"ollama_model\", \"qwen3:30b-a3b\"))\n max_sessions = data.get(\"max_sessions\", 100)\n max_turns = data.get(\"max_turns\", 25)\n session_delay = data.get(\"session_delay\", 5)\n context_threshold = data.get(\"context_threshold\", 0.70)\n\n # Provider options with selected state\n providers = [\n (\"claude-max\", \"Claude Subscription (Pro/Max — via Claude Code CLI)\"),\n (\"claude-api\", \"Claude API (Anthropic API key)\"),\n (\"ollama\", \"Ollama (local models)\"),\n ]\n options_html = \"\"\n for val, label in providers:\n sel = ' selected' if val == provider else ''\n options_html += f''\n\n return f'''\n
\n

// Settings

\n \n
\n\n
\n
Provider
\n \n
\n {self._provider_badge(provider)}\n
\n
\n\n
\n
API Key
\n
\n \n \n
\n
\n Or set ANTHROPIC_API_KEY environment variable\n
\n
\n\n
\n
Ollama
\n
\n \n \n
\n
\n \n \n
\n \n
\n\n
\n
Session
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n\n
\n
Theme
\n \n
\n\n
\n
Config File
\n

\n {esc(str(config.path))}\n

\n
\n\n \n '''\n\n def _provider_badge(self, provider: str) -> str:\n \"\"\"Generate provider status badge HTML\"\"\"\n if provider == \"claude-max\":\n return 'Active'\n return 'EXPERIMENTAL — untested'\n\n def build_transform_page(self):\n return '''\n
\n

// Transform

\n \n
\n\n
\n
\n
\n
1. Write
\n
Enter raw text or ideas
\n
\n
\n
\n
2. Transform
\n
AI breaks into tasks
\n
\n
\n
\n
3. Confirm
\n
Review and add to queue
\n
\n
\n\n
\n

Raw Text to Tasks

\n

\n Enter raw text, notes, or ideas — AI will break them into structured tasks.\n

\n \n
\n \n \n
\n
\n\n
\n
\n
\n

Preview Tasks

\n \n
\n
\n
\n
\n\n \n '''\n\n def _transform_text(self, raw_text: str) -> dict:\n \"\"\"Use Claude to break raw text into structured tasks\"\"\"\n import os\n import subprocess\n\n prompt = f'''Break the following text into structured tasks for a software project.\nReturn ONLY valid JSON array, no other text. Each task object must have:\n- \"title\": short task title (imperative, e.g. \"Add login page\")\n- \"description\": 1-2 sentence description of what needs to be done\n- \"success_criteria\": how to verify the task is done (e.g. \"pytest passes\", \"page renders at /login\", \"API returns 200\")\n\nText to transform:\n{raw_text}\n\nReturn format: [{{\"title\": \"...\", \"description\": \"...\", \"success_criteria\": \"...\"}}, ...]'''\n\n try:\n env = os.environ.copy()\n env.pop(\"CLAUDECODE\", None)\n result = subprocess.run(\n [\"claude\", \"-p\", prompt, \"--max-turns\", \"1\", \"--no-session-persistence\", \"--dangerously-skip-permissions\"],\n cwd=str(PROJECT_DIR),\n env=env,\n capture_output=True,\n text=True,\n timeout=300,\n )\n output = result.stdout.strip()\n # Try to extract JSON from output\n start = output.find('[')\n end = output.rfind(']')\n if start >= 0 and end > start:\n tasks = json.loads(output[start:end+1])\n return {\"tasks\": tasks}\n return {\"tasks\": [], \"error\": \"Could not parse AI response\"}\n except subprocess.TimeoutExpired:\n return {\"tasks\": [], \"error\": \"AI request timed out (5 min)\"}\n except FileNotFoundError:\n return {\"tasks\": [], \"error\": \"Claude CLI not found\"}\n except Exception as e:\n return {\"tasks\": [], \"error\": str(e)}\n\n def send_json_status(self):\n checkpoint = CheckpointManager(PROJECT_DIR)\n tasks = TaskManager(PROJECT_DIR)\n\n # Get live metrics from agent loop if running\n metrics = {}\n if AGENT_LOOP and hasattr(AGENT_LOOP, 'get_session_metrics'):\n metrics = AGENT_LOOP.get_session_metrics()\n else:\n # Try to load from checkpoint\n cp = checkpoint.load()\n metrics = cp.get('session_metrics', {})\n\n data = {\n 'checkpoint': checkpoint.load(),\n 'tasks': [t.to_dict() for t in tasks.get_tasks()],\n 'progress': tasks.get_progress(),\n 'running': AGENT_RUNNING,\n 'activity': ACTIVITY_LOG[-10:],\n 'metrics': metrics,\n }\n\n self.send_response(200)\n self.send_header('Content-Type', 'application/json')\n self.end_headers()\n self.wfile.write(json.dumps(data).encode('utf-8'))\n\n def send_json_log(self):\n \"\"\"Return agent log entries since a given index\"\"\"\n query = urlparse(self.path).query\n params = parse_qs(query)\n since = int(params.get('since', ['0'])[0])\n\n entries = AGENT_LOG_BUFFER[since:]\n data = {\n 'entries': entries,\n 'total': len(AGENT_LOG_BUFFER),\n 'since': since,\n }\n\n self.send_response(200)\n self.send_header('Content-Type', 'application/json')\n self.end_headers()\n self.wfile.write(json.dumps(data).encode('utf-8'))\n\n def send_json_task(self, task_id):\n \"\"\"Return task detail JSON with logs, metrics, sessions\"\"\"\n tasks_mgr = TaskManager(PROJECT_DIR)\n task = None\n for t in tasks_mgr.get_tasks():\n if t.id == task_id:\n task = t\n break\n if not task:\n self.send_response(404)\n self.send_header('Content-Type', 'application/json')\n self.end_headers()\n self.wfile.write(json.dumps({\"error\": \"Task not found\"}).encode('utf-8'))\n return\n\n # Filtered logs: if active task, include untagged logs\n is_active_task = AGENT_RUNNING and _get_current_task_id() == task_id\n if is_active_task:\n task_logs = [e for e in AGENT_LOG_BUFFER if e.get('task_id') in (task_id, None)]\n else:\n task_logs = [e for e in AGENT_LOG_BUFFER if e.get('task_id') == task_id]\n\n # Metrics from logs\n tool_types = {}\n for e in task_logs:\n et = e.get('type', 'text')\n tool_types[et] = tool_types.get(et, 0) + 1\n\n # Sessions from archives\n sessions = []\n checkpoints_dir = PROJECT_DIR / '.a1' / 'checkpoints'\n if checkpoints_dir.exists():\n for f in sorted(checkpoints_dir.glob('session_*.json')):\n try:\n data = json.loads(f.read_text())\n if data.get('current_task') == task_id:\n sessions.append({\n 'session': data.get('session'),\n 'status': data.get('status'),\n 'metrics': data.get('session_metrics', {}),\n })\n except Exception:\n pass\n\n result = {\n 'task': task.to_dict(),\n 'logs': task_logs[-200:],\n 'log_count': len(task_logs),\n 'metrics': {\n 'tool_types': tool_types,\n 'tool_count': sum(1 for e in task_logs if e.get('type') in ('read', 'edit', 'write', 'bash')),\n },\n 'sessions': sessions,\n 'is_active': AGENT_RUNNING and _get_current_task_id() == task_id,\n }\n\n self.send_response(200)\n self.send_header('Content-Type', 'application/json')\n self.end_headers()\n self.wfile.write(json.dumps(result).encode('utf-8'))\n\n def send_json_config(self):\n \"\"\"Return current config (API key masked)\"\"\"\n from .config import Config, DEFAULTS\n config = Config(PROJECT_DIR)\n data = config.get_all()\n # Mask API key for security\n if data.get(\"api_key\"):\n data[\"api_key\"] = config.mask_api_key(data[\"api_key\"])\n data[\"_defaults\"] = DEFAULTS\n self.send_response(200)\n self.send_header('Content-Type', 'application/json')\n self.end_headers()\n self.wfile.write(json.dumps(data).encode('utf-8'))\n\n def _render_log_entries(self):\n \"\"\"Render existing log buffer entries as HTML (terminal style)\"\"\"\n label_map = {\n 'read': 'READ', 'edit': 'EDIT', 'write': 'WRITE',\n 'bash': 'BASH', 'thinking': 'THINK', 'text': 'OUT',\n 'metric': 'METRIC', 'verify': 'CHECK',\n }\n color_map = {\n 'read': '#89b4fa', 'edit': '#fab387', 'write': '#a6e3a1',\n 'bash': '#cba6f7', 'thinking': '#f9e2af', 'text': '#6c7086',\n 'metric': '#89dceb', 'verify': '#a6e3a1',\n }\n html = ''\n for e in AGENT_LOG_BUFFER[-50:]:\n etype = e.get('type', 'text')\n label = label_map.get(etype, 'LOG')\n color = color_map.get(etype, '#6c7086')\n tid = e.get('task_id', '')\n tid_tag = f'[{esc(tid)}]' if tid else ''\n html += f'
{esc(e[\"time\"])}{label}{tid_tag}{esc(e[\"line\"][:150])}
'\n if not AGENT_LOG_BUFFER:\n html = '
Waiting for agent output...
'\n return html\n\n def _render_raw_log(self):\n \"\"\"Render raw log text for initial page load\"\"\"\n return esc('\\n'.join(e['line'] for e in AGENT_LOG_BUFFER[-50:]))\n\n def start_agent(self):\n global AGENT_RUNNING, AGENT_LOOP\n if not AGENT_RUNNING:\n AGENT_LOG_BUFFER.clear()\n\n # Pre-set current_task so logs get tagged from the start\n tasks_mgr = TaskManager(PROJECT_DIR)\n next_task = tasks_mgr.get_next_task()\n if next_task:\n checkpoint = CheckpointManager(PROJECT_DIR)\n cp = checkpoint.load()\n if not cp.get(\"current_task\"):\n cp[\"current_task\"] = next_task.id\n checkpoint.save(cp)\n _TASK_CACHE[\"task_id\"] = cp.get(\"current_task\") or (next_task.id if next_task else None)\n _TASK_CACHE[\"ts\"] = _time.time()\n\n log_activity(\"Agent started\", f\"task: {next_task.id if next_task else 'none'}\", \"success\")\n\n def run():\n global AGENT_RUNNING, AGENT_LOOP\n AGENT_RUNNING = True\n try:\n from .config import Config\n from .loop import SessionLoop\n config = Config(PROJECT_DIR)\n resolved = config.resolve()\n loop = SessionLoop(project_dir=PROJECT_DIR, **resolved)\n loop._log_callback = _on_agent_line\n AGENT_LOOP = loop\n loop.start()\n finally:\n AGENT_RUNNING = False\n AGENT_LOOP = None\n log_activity(\"Agent stopped\", \"\", \"warning\")\n\n thread = threading.Thread(target=run, daemon=True)\n thread.start()\n\n def start_task_agent(self, task_id):\n \"\"\"Start agent on a specific task — set current_task + in_progress, then start\"\"\"\n # Set task to in_progress\n tasks_mgr = TaskManager(PROJECT_DIR)\n tasks_mgr.start_task(task_id)\n\n # Set current_task in checkpoint so agent picks it up\n checkpoint = CheckpointManager(PROJECT_DIR)\n cp = checkpoint.load()\n cp[\"current_task\"] = task_id\n checkpoint.save(cp)\n\n # Update task cache immediately\n _TASK_CACHE[\"task_id\"] = task_id\n _TASK_CACHE[\"ts\"] = _time.time()\n\n log_activity(\"Task started\", task_id, \"success\")\n\n # Start agent for this one task only\n if not AGENT_RUNNING:\n self._start_single_task_agent(task_id)\n\n # Return JSON (called via fetch from task detail page)\n self.send_response(200)\n self.send_header('Content-Type', 'application/json')\n self.end_headers()\n self.wfile.write(json.dumps({\"ok\": True, \"task_id\": task_id}).encode('utf-8'))\n\n def _start_single_task_agent(self, task_id):\n \"\"\"Start agent for ONE task only (max_sessions=1)\"\"\"\n global AGENT_RUNNING, AGENT_LOOP\n if not AGENT_RUNNING:\n AGENT_LOG_BUFFER.clear()\n log_activity(\"Agent started\", f\"single task: {task_id}\", \"success\")\n\n def run():\n global AGENT_RUNNING, AGENT_LOOP\n AGENT_RUNNING = True\n try:\n from .config import Config\n from .loop import SessionLoop\n config = Config(PROJECT_DIR)\n resolved = config.resolve()\n resolved['max_sessions'] = 1\n loop = SessionLoop(project_dir=PROJECT_DIR, **resolved)\n loop._log_callback = _on_agent_line\n AGENT_LOOP = loop\n loop.start()\n finally:\n AGENT_RUNNING = False\n AGENT_LOOP = None\n log_activity(\"Agent stopped\", f\"task: {task_id}\", \"warning\")\n\n thread = threading.Thread(target=run, daemon=True)\n thread.start()\n\n def stop_agent(self):\n global AGENT_RUNNING, AGENT_LOOP\n if AGENT_LOOP:\n AGENT_LOOP.stop()\n AGENT_RUNNING = False\n log_activity(\"Agent stop requested\", \"\", \"warning\")\n\n\ndef find_free_port(start_port: int = 7331, max_attempts: int = 20) -> int:\n for offset in range(max_attempts):\n port = start_port + offset\n try:\n with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:\n s.bind(('0.0.0.0', port))\n return port\n except OSError:\n continue\n raise RuntimeError(f\"No free port in range {start_port}-{start_port + max_attempts}\")\n\n\ndef run_dashboard(project_dir: Path, port: int = None, open_browser: bool = True):\n global PROJECT_DIR\n PROJECT_DIR = Path(project_dir).resolve()\n\n a1_dir = PROJECT_DIR / '.a1'\n if not a1_dir.exists():\n a1_dir.mkdir(parents=True)\n (a1_dir / 'sessions').mkdir()\n (a1_dir / 'checkpoints').mkdir()\n\n if port is None:\n port = find_free_port(7331)\n else:\n try:\n port = find_free_port(port, max_attempts=1)\n except RuntimeError:\n port = find_free_port(7331)\n\n server = ThreadingHTTPServer(('0.0.0.0', port), DashboardHandler)\n\n url = f'http://localhost:{port}'\n print()\n print(' PocketCoder-A1 Dashboard')\n print(' -------------------------')\n print(f' URL: {url}')\n print(f' Project: {PROJECT_DIR}')\n print()\n print(' Press Ctrl+C to stop')\n print()\n\n log_activity(\"Dashboard started\", f\"Port {port}\", \"info\")\n\n if open_browser:\n webbrowser.open(url)\n\n try:\n server.serve_forever()\n except KeyboardInterrupt:\n print('\\\\nDashboard stopped')\n server.shutdown()\n\n\nif __name__ == '__main__':\n import sys\n project = sys.argv[1] if len(sys.argv) > 1 else '.'\n run_dashboard(project)\n"}}}}