Compare commits
10 Commits
8d4392ee5a
...
9e2932fbc3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e2932fbc3 | ||
|
|
920376449a | ||
|
|
b0c24e10c8 | ||
|
|
c690d0c483 | ||
|
|
07e31ce215 | ||
|
|
27592f2750 | ||
|
|
167633c7be | ||
|
|
18352a99d5 | ||
|
|
3360c2ad85 | ||
|
|
7a8dd14bd3 |
@@ -27,14 +27,12 @@ def update_config(body: UpdateConfigRequest):
|
|||||||
cfg = config_service.update_config(
|
cfg = config_service.update_config(
|
||||||
default_wait_seconds=body.default_wait_seconds,
|
default_wait_seconds=body.default_wait_seconds,
|
||||||
default_empty_response=body.default_empty_response,
|
default_empty_response=body.default_empty_response,
|
||||||
agent_stale_after_seconds=body.agent_stale_after_seconds,
|
|
||||||
)
|
)
|
||||||
event_service.broadcast(
|
event_service.broadcast(
|
||||||
"config.updated",
|
"config.updated",
|
||||||
{
|
{
|
||||||
"default_wait_seconds": cfg.default_wait_seconds,
|
"default_wait_seconds": cfg.default_wait_seconds,
|
||||||
"default_empty_response": cfg.default_empty_response,
|
"default_empty_response": cfg.default_empty_response,
|
||||||
"agent_stale_after_seconds": cfg.agent_stale_after_seconds,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return cfg
|
return cfg
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from datetime import datetime, timezone
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from app.config import APP_VERSION
|
||||||
from app.models import (
|
from app.models import (
|
||||||
AgentInfo,
|
AgentInfo,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
@@ -49,6 +50,8 @@ def get_status():
|
|||||||
server=ServerInfo(
|
server=ServerInfo(
|
||||||
status="up",
|
status="up",
|
||||||
started_at=status_service.server_started_at(),
|
started_at=status_service.server_started_at(),
|
||||||
|
version=APP_VERSION,
|
||||||
|
type="python",
|
||||||
),
|
),
|
||||||
agent=agent_info,
|
agent=agent_info,
|
||||||
queue=QueueCounts(**counts),
|
queue=QueueCounts(**counts),
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import os
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
APP_VERSION = "1.0.1"
|
||||||
DEFAULT_EMPTY_RESPONSE = "call this tool `get_user_request` again to fetch latest user input..."
|
DEFAULT_EMPTY_RESPONSE = "call this tool `get_user_request` again to fetch latest user input..."
|
||||||
|
AGENT_STALE_AFTER_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -27,7 +29,6 @@ class Settings:
|
|||||||
# MCP / queue behaviour (runtime-editable values are stored in DB; these are defaults for first run)
|
# MCP / queue behaviour (runtime-editable values are stored in DB; these are defaults for first run)
|
||||||
default_wait_seconds: int = 10
|
default_wait_seconds: int = 10
|
||||||
default_empty_response: str = DEFAULT_EMPTY_RESPONSE
|
default_empty_response: str = DEFAULT_EMPTY_RESPONSE
|
||||||
agent_stale_after_seconds: int = 30
|
|
||||||
|
|
||||||
# MCP server name
|
# MCP server name
|
||||||
mcp_server_name: str = "local-mcp"
|
mcp_server_name: str = "local-mcp"
|
||||||
@@ -57,7 +58,6 @@ def load_settings() -> Settings:
|
|||||||
log_level=os.getenv("LOG_LEVEL", "INFO"),
|
log_level=os.getenv("LOG_LEVEL", "INFO"),
|
||||||
default_wait_seconds=int(os.getenv("DEFAULT_WAIT_SECONDS", "10")),
|
default_wait_seconds=int(os.getenv("DEFAULT_WAIT_SECONDS", "10")),
|
||||||
default_empty_response=os.getenv("DEFAULT_EMPTY_RESPONSE", DEFAULT_EMPTY_RESPONSE),
|
default_empty_response=os.getenv("DEFAULT_EMPTY_RESPONSE", DEFAULT_EMPTY_RESPONSE),
|
||||||
agent_stale_after_seconds=int(os.getenv("AGENT_STALE_AFTER_SECONDS", "30")),
|
|
||||||
mcp_server_name=os.getenv("MCP_SERVER_NAME", "local-mcp"),
|
mcp_server_name=os.getenv("MCP_SERVER_NAME", "local-mcp"),
|
||||||
mcp_stateless=_parse_bool(os.getenv("MCP_STATELESS", "true"), default=True),
|
mcp_stateless=_parse_bool(os.getenv("MCP_STATELESS", "true"), default=True),
|
||||||
api_token=os.getenv("API_TOKEN", ""),
|
api_token=os.getenv("API_TOKEN", ""),
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ CREATE TABLE IF NOT EXISTS agent_activity (
|
|||||||
_DEFAULT_SETTINGS = {
|
_DEFAULT_SETTINGS = {
|
||||||
"default_wait_seconds": "10",
|
"default_wait_seconds": "10",
|
||||||
"default_empty_response": DEFAULT_EMPTY_RESPONSE,
|
"default_empty_response": DEFAULT_EMPTY_RESPONSE,
|
||||||
"agent_stale_after_seconds": "30",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -125,6 +124,7 @@ def _seed_defaults(conn: sqlite3.Connection) -> None:
|
|||||||
"UPDATE settings SET value = ? WHERE key = 'default_empty_response' AND value = ''",
|
"UPDATE settings SET value = ? WHERE key = 'default_empty_response' AND value = ''",
|
||||||
(_DEFAULT_SETTINGS["default_empty_response"],),
|
(_DEFAULT_SETTINGS["default_empty_response"],),
|
||||||
)
|
)
|
||||||
|
conn.execute("DELETE FROM settings WHERE key = 'agent_stale_after_seconds'")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.debug("Default settings seeded")
|
logger.debug("Default settings seeded")
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ class UpdateInstructionRequest(BaseModel):
|
|||||||
class ServerInfo(BaseModel):
|
class ServerInfo(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
started_at: datetime
|
started_at: datetime
|
||||||
|
version: str
|
||||||
|
type: str
|
||||||
|
|
||||||
|
|
||||||
class AgentInfo(BaseModel):
|
class AgentInfo(BaseModel):
|
||||||
@@ -107,13 +109,11 @@ class StatusResponse(BaseModel):
|
|||||||
class ConfigResponse(BaseModel):
|
class ConfigResponse(BaseModel):
|
||||||
default_wait_seconds: int
|
default_wait_seconds: int
|
||||||
default_empty_response: str
|
default_empty_response: str
|
||||||
agent_stale_after_seconds: int
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateConfigRequest(BaseModel):
|
class UpdateConfigRequest(BaseModel):
|
||||||
default_wait_seconds: Optional[int] = None
|
default_wait_seconds: Optional[int] = None
|
||||||
default_empty_response: Optional[str] = None
|
default_empty_response: Optional[str] = None
|
||||||
agent_stale_after_seconds: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from app.models import ConfigResponse
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_SETTING_KEYS = {"default_wait_seconds", "default_empty_response", "agent_stale_after_seconds"}
|
|
||||||
def get_config() -> ConfigResponse:
|
def get_config() -> ConfigResponse:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute("SELECT key, value FROM settings").fetchall()
|
rows = conn.execute("SELECT key, value FROM settings").fetchall()
|
||||||
@@ -21,22 +20,18 @@ def get_config() -> ConfigResponse:
|
|||||||
return ConfigResponse(
|
return ConfigResponse(
|
||||||
default_wait_seconds=int(data.get("default_wait_seconds", 10)),
|
default_wait_seconds=int(data.get("default_wait_seconds", 10)),
|
||||||
default_empty_response=data.get("default_empty_response") or DEFAULT_EMPTY_RESPONSE,
|
default_empty_response=data.get("default_empty_response") or DEFAULT_EMPTY_RESPONSE,
|
||||||
agent_stale_after_seconds=int(data.get("agent_stale_after_seconds", 30)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def update_config(
|
def update_config(
|
||||||
default_wait_seconds: int | None = None,
|
default_wait_seconds: int | None = None,
|
||||||
default_empty_response: str | None = None,
|
default_empty_response: str | None = None,
|
||||||
agent_stale_after_seconds: int | None = None,
|
|
||||||
) -> ConfigResponse:
|
) -> ConfigResponse:
|
||||||
updates: dict[str, str] = {}
|
updates: dict[str, str] = {}
|
||||||
if default_wait_seconds is not None:
|
if default_wait_seconds is not None:
|
||||||
updates["default_wait_seconds"] = str(default_wait_seconds)
|
updates["default_wait_seconds"] = str(default_wait_seconds)
|
||||||
if default_empty_response is not None:
|
if default_empty_response is not None:
|
||||||
updates["default_empty_response"] = default_empty_response
|
updates["default_empty_response"] = default_empty_response
|
||||||
if agent_stale_after_seconds is not None:
|
|
||||||
updates["agent_stale_after_seconds"] = str(agent_stale_after_seconds)
|
|
||||||
|
|
||||||
if updates:
|
if updates:
|
||||||
with get_write_conn() as conn:
|
with get_write_conn() as conn:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import sqlite3
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.config import AGENT_STALE_AFTER_SECONDS
|
||||||
from app.database import get_conn, get_write_conn
|
from app.database import get_conn, get_write_conn
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -55,25 +56,15 @@ def get_latest_agent_activity() -> Optional[sqlite3.Row]:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
def get_agent_stale_seconds() -> int:
|
|
||||||
"""Read agent_stale_after_seconds from settings table."""
|
|
||||||
with get_conn() as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT value FROM settings WHERE key = 'agent_stale_after_seconds'"
|
|
||||||
).fetchone()
|
|
||||||
return int(row["value"]) if row else 30
|
|
||||||
|
|
||||||
|
|
||||||
def is_agent_connected() -> bool:
|
def is_agent_connected() -> bool:
|
||||||
"""True if the most recent agent activity is within the stale threshold."""
|
"""True if the most recent agent activity is within the stale threshold."""
|
||||||
row = get_latest_agent_activity()
|
row = get_latest_agent_activity()
|
||||||
if row is None:
|
if row is None:
|
||||||
return False
|
return False
|
||||||
stale_seconds = get_agent_stale_seconds()
|
|
||||||
last_seen = datetime.fromisoformat(row["last_seen_at"])
|
last_seen = datetime.fromisoformat(row["last_seen_at"])
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
if last_seen.tzinfo is None:
|
if last_seen.tzinfo is None:
|
||||||
last_seen = last_seen.replace(tzinfo=timezone.utc)
|
last_seen = last_seen.replace(tzinfo=timezone.utc)
|
||||||
delta = (now - last_seen).total_seconds()
|
delta = (now - last_seen).total_seconds()
|
||||||
return delta <= stale_seconds
|
return delta <= AGENT_STALE_AFTER_SECONDS
|
||||||
|
|
||||||
|
|||||||
@@ -1,607 +0,0 @@
|
|||||||
# local-mcp
|
|
||||||
|
|
||||||
`local-mcp` is a localhost-first MCP server whose primary responsibility is to deliver the latest user instruction to an agent through the `get_user_request` tool, while also providing a responsive web UI for managing the instruction queue and monitoring server/agent activity.
|
|
||||||
|
|
||||||
This document is the implementation plan for the project.
|
|
||||||
|
|
||||||
## 1. Goals
|
|
||||||
|
|
||||||
- Provide a single MCP tool, `get_user_request`, that returns at most one instruction per call.
|
|
||||||
- Give the user a polished local web UI to add, edit, remove, review, and monitor instructions.
|
|
||||||
- Preserve queue integrity so consumed instructions are clearly visible but no longer editable/deletable.
|
|
||||||
- Support configurable waiting/default-response behavior when no instruction is available.
|
|
||||||
- Show live server status and inferred agent connectivity in the UI.
|
|
||||||
- Keep the stack lightweight, maintainable, debuggable, and friendly to local development.
|
|
||||||
|
|
||||||
## 2. Recommended Tech Stack
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- **Language/runtime:** Python 3.11+
|
|
||||||
- **MCP integration:** official Python MCP SDK
|
|
||||||
- **HTTP server/API layer:** FastAPI
|
|
||||||
- **ASGI server:** Uvicorn
|
|
||||||
- **Persistence:** SQLite via Python standard library `sqlite3`
|
|
||||||
- **Concurrency/state coordination:** `asyncio` + standard library synchronization primitives where needed
|
|
||||||
- **Logging/error handling:** Python `logging`, structured request logs, centralized exception handling
|
|
||||||
- **Configuration:** environment variables + small local config file (`.json` or `.toml`)
|
|
||||||
|
|
||||||
### Why this backend stack
|
|
||||||
|
|
||||||
- The MCP SDK is the correct dependency for exposing the MCP tool cleanly.
|
|
||||||
- FastAPI + Uvicorn is a small, pragmatic backend stack that simplifies routing, validation, health endpoints, and server-sent updates without introducing a heavy framework.
|
|
||||||
- SQLite keeps the system local-first, dependency-light, and durable enough for instruction history and settings.
|
|
||||||
- Most supporting concerns remain in the Python standard library, which keeps third-party dependencies minimal.
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- **UI technology:** plain HTML, CSS, and JavaScript only
|
|
||||||
- **Realtime updates:** Server-Sent Events (preferred) with polling fallback if necessary
|
|
||||||
- **Styling:** local CSS files with design tokens and component-specific stylesheets
|
|
||||||
- **Client architecture:** modular vanilla JS organized by feature (`api.js`, `state.js`, `events.js`, `instructions.js`, etc.)
|
|
||||||
- **Assets:** all fonts/icons/scripts/styles stored locally in the repository; no CDN usage
|
|
||||||
|
|
||||||
### Mandatory frontend implementation instruction
|
|
||||||
|
|
||||||
Any future frontend implementation work **must first read and follow**:
|
|
||||||
|
|
||||||
- `.github/instructions/frontend-design.instructions.md`
|
|
||||||
|
|
||||||
This instruction file is mandatory for the UI because it requires a distinctive, production-grade, non-generic frontend. The implementation should not default to generic dashboard aesthetics.
|
|
||||||
|
|
||||||
## 3. Product/Architecture Plan
|
|
||||||
|
|
||||||
### Core backend responsibilities
|
|
||||||
|
|
||||||
1. Expose the MCP tool `get_user_request`.
|
|
||||||
2. Maintain an instruction queue with durable storage.
|
|
||||||
3. Mark instructions as consumed atomically when delivered to an agent.
|
|
||||||
4. Expose local HTTP endpoints for the web UI.
|
|
||||||
5. Stream status/instruction updates to the browser in real time.
|
|
||||||
6. Infer agent connectivity from recent MCP tool activity.
|
|
||||||
7. Persist and serve server configuration such as wait timeout and default empty response.
|
|
||||||
|
|
||||||
### Core frontend responsibilities
|
|
||||||
|
|
||||||
1. Show queued and consumed instructions in separate, clearly labeled sections.
|
|
||||||
2. Allow add/edit/delete only for instructions that are still pending.
|
|
||||||
3. Cross out and grey out consumed instructions.
|
|
||||||
4. Show server status, inferred agent status, last fetch time, and configuration values.
|
|
||||||
5. Update live as instruction state changes.
|
|
||||||
6. Remain usable and visually polished on desktop and smaller screens.
|
|
||||||
|
|
||||||
### Suggested repository layout
|
|
||||||
|
|
||||||
```text
|
|
||||||
local-mcp/
|
|
||||||
├─ main.py
|
|
||||||
├─ README.md
|
|
||||||
├─ requirements.txt
|
|
||||||
├─ app/
|
|
||||||
│ ├─ __init__.py
|
|
||||||
│ ├─ config.py
|
|
||||||
│ ├─ database.py
|
|
||||||
│ ├─ logging_setup.py
|
|
||||||
│ ├─ models.py
|
|
||||||
│ ├─ services/
|
|
||||||
│ │ ├─ instruction_service.py
|
|
||||||
│ │ ├─ status_service.py
|
|
||||||
│ │ └─ event_service.py
|
|
||||||
│ ├─ api/
|
|
||||||
│ │ ├─ routes_instructions.py
|
|
||||||
│ │ ├─ routes_status.py
|
|
||||||
│ │ └─ routes_config.py
|
|
||||||
│ └─ mcp_server.py
|
|
||||||
├─ static/
|
|
||||||
│ ├─ index.html
|
|
||||||
│ ├─ css/
|
|
||||||
│ │ ├─ base.css
|
|
||||||
│ │ ├─ layout.css
|
|
||||||
│ │ └─ components.css
|
|
||||||
│ ├─ js/
|
|
||||||
│ │ ├─ api.js
|
|
||||||
│ │ ├─ app.js
|
|
||||||
│ │ ├─ events.js
|
|
||||||
│ │ ├─ instructions.js
|
|
||||||
│ │ └─ status.js
|
|
||||||
│ └─ assets/
|
|
||||||
└─ data/
|
|
||||||
└─ local_mcp.sqlite3
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Data Model Plan
|
|
||||||
|
|
||||||
### `instructions`
|
|
||||||
|
|
||||||
- `id` - string/UUID primary key
|
|
||||||
- `content` - text, required
|
|
||||||
- `status` - enum: `pending`, `consumed`
|
|
||||||
- `created_at` - datetime
|
|
||||||
- `updated_at` - datetime
|
|
||||||
- `consumed_at` - nullable datetime
|
|
||||||
- `consumed_by_agent_id` - nullable string
|
|
||||||
- `position` - integer for stable queue order
|
|
||||||
|
|
||||||
### `settings`
|
|
||||||
|
|
||||||
- `default_wait_seconds` - integer — seconds the tool waits before returning an empty/default response; set exclusively by the user via the web UI
|
|
||||||
- `default_empty_response` - text, nullable
|
|
||||||
- `agent_stale_after_seconds` - integer
|
|
||||||
|
|
||||||
### `agent_activity`
|
|
||||||
|
|
||||||
- `agent_id` - string primary key
|
|
||||||
- `last_seen_at` - datetime
|
|
||||||
- `last_fetch_at` - datetime
|
|
||||||
- `last_result_type` - enum: `instruction`, `empty`, `default_response`
|
|
||||||
|
|
||||||
## 5. Detailed API Design
|
|
||||||
|
|
||||||
All routes are local-only and intended for `localhost` usage.
|
|
||||||
|
|
||||||
### 5.1 MCP tool contract
|
|
||||||
|
|
||||||
#### Tool: `get_user_request`
|
|
||||||
|
|
||||||
**Purpose**
|
|
||||||
|
|
||||||
- Return the next pending instruction, if one exists.
|
|
||||||
- If none exists, wait for a configurable duration, then return the server-controlled default response.
|
|
||||||
- Record agent activity so the UI can infer whether an agent is currently connected/recently active.
|
|
||||||
|
|
||||||
**Suggested input schema**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agent_id": "optional-string"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Suggested output schema when an instruction is delivered**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"result_type": "instruction",
|
|
||||||
"instruction": {
|
|
||||||
"id": "uuid",
|
|
||||||
"content": "user instruction text",
|
|
||||||
"consumed_at": "ISO-8601 timestamp"
|
|
||||||
},
|
|
||||||
"remaining_pending": 3,
|
|
||||||
"waited_seconds": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Suggested output schema when queue is empty**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"result_type": "empty",
|
|
||||||
"instruction": null,
|
|
||||||
"response": "",
|
|
||||||
"remaining_pending": 0,
|
|
||||||
"waited_seconds": 10
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Suggested output schema when a default response is returned**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"result_type": "default_response",
|
|
||||||
"instruction": null,
|
|
||||||
"response": "call this tool `get_user_request` again to fetch latest user input...",
|
|
||||||
"remaining_pending": 0,
|
|
||||||
"waited_seconds": 10
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Behavior rules**
|
|
||||||
|
|
||||||
- Deliver the oldest pending instruction first.
|
|
||||||
- Mark the delivered instruction as consumed in the same transaction used to claim it.
|
|
||||||
- Never return more than one instruction per call.
|
|
||||||
- `default_wait_seconds` is fully server-controlled (set by the user via the web UI). Agents cannot override it.
|
|
||||||
- Clamp `actual_wait` to an absolute server maximum (86400 s).
|
|
||||||
- Update the agent activity record on every call, even when no instruction is returned.
|
|
||||||
- The UI should infer "agent connected" if the latest activity is within `agent_stale_after_seconds`.
|
|
||||||
- Agent implementations should continue calling this tool instead of ending their work session on their own, so they can pick up newly added instructions without missing critical follow-up requests.
|
|
||||||
|
|
||||||
### 5.2 HTTP API for the web UI
|
|
||||||
|
|
||||||
#### `GET /healthz`
|
|
||||||
|
|
||||||
Returns service health.
|
|
||||||
|
|
||||||
**Response**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"server_time": "ISO-8601 timestamp"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `GET /api/status`
|
|
||||||
|
|
||||||
Returns current server and agent summary.
|
|
||||||
|
|
||||||
**Response**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"server": {
|
|
||||||
"status": "up",
|
|
||||||
"started_at": "ISO-8601 timestamp"
|
|
||||||
},
|
|
||||||
"agent": {
|
|
||||||
"connected": true,
|
|
||||||
"last_seen_at": "ISO-8601 timestamp",
|
|
||||||
"last_fetch_at": "ISO-8601 timestamp",
|
|
||||||
"agent_id": "copilot-agent"
|
|
||||||
},
|
|
||||||
"queue": {
|
|
||||||
"pending_count": 2,
|
|
||||||
"consumed_count": 8
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"default_wait_seconds": 10,
|
|
||||||
"default_empty_response": "call this tool `get_user_request` again to fetch latest user input...",
|
|
||||||
"agent_stale_after_seconds": 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `GET /api/instructions`
|
|
||||||
|
|
||||||
Returns all instructions in queue order.
|
|
||||||
|
|
||||||
**Query params**
|
|
||||||
|
|
||||||
- `status=pending|consumed|all` (default `all`)
|
|
||||||
|
|
||||||
**Response**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "uuid",
|
|
||||||
"content": "Implement logging",
|
|
||||||
"status": "pending",
|
|
||||||
"created_at": "ISO-8601 timestamp",
|
|
||||||
"updated_at": "ISO-8601 timestamp",
|
|
||||||
"consumed_at": null,
|
|
||||||
"consumed_by_agent_id": null,
|
|
||||||
"position": 1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `POST /api/instructions`
|
|
||||||
|
|
||||||
Creates a new pending instruction.
|
|
||||||
|
|
||||||
**Request**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"content": "Add a new status indicator"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response**: `201 Created`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"item": {
|
|
||||||
"id": "uuid",
|
|
||||||
"content": "Add a new status indicator",
|
|
||||||
"status": "pending",
|
|
||||||
"created_at": "ISO-8601 timestamp",
|
|
||||||
"updated_at": "ISO-8601 timestamp",
|
|
||||||
"consumed_at": null,
|
|
||||||
"consumed_by_agent_id": null,
|
|
||||||
"position": 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `PATCH /api/instructions/{instruction_id}`
|
|
||||||
|
|
||||||
Edits a pending instruction only.
|
|
||||||
|
|
||||||
**Request**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"content": "Reword an existing pending instruction"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rules**
|
|
||||||
|
|
||||||
- Return `409 Conflict` if the instruction has already been consumed.
|
|
||||||
- Return `404 Not Found` if the instruction does not exist.
|
|
||||||
|
|
||||||
#### `DELETE /api/instructions/{instruction_id}`
|
|
||||||
|
|
||||||
Deletes a pending instruction only.
|
|
||||||
|
|
||||||
**Rules**
|
|
||||||
|
|
||||||
- Return `409 Conflict` if the instruction has already been consumed.
|
|
||||||
- Return `204 No Content` on success.
|
|
||||||
|
|
||||||
#### `GET /api/config`
|
|
||||||
|
|
||||||
Returns editable runtime settings.
|
|
||||||
|
|
||||||
**Response**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"default_wait_seconds": 10,
|
|
||||||
"default_empty_response": "call this tool `get_user_request` again to fetch latest user input...",
|
|
||||||
"agent_stale_after_seconds": 30
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `PATCH /api/config`
|
|
||||||
|
|
||||||
Updates runtime settings.
|
|
||||||
|
|
||||||
**Request**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"default_wait_seconds": 15,
|
|
||||||
"default_empty_response": "",
|
|
||||||
"agent_stale_after_seconds": 45
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `GET /api/events`
|
|
||||||
|
|
||||||
Server-Sent Events endpoint for live UI updates.
|
|
||||||
|
|
||||||
**Event types**
|
|
||||||
|
|
||||||
- `instruction.created`
|
|
||||||
- `instruction.updated`
|
|
||||||
- `instruction.deleted`
|
|
||||||
- `instruction.consumed`
|
|
||||||
- `status.changed`
|
|
||||||
- `config.updated`
|
|
||||||
|
|
||||||
**SSE payload example**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "instruction.consumed",
|
|
||||||
"timestamp": "ISO-8601 timestamp",
|
|
||||||
"data": {
|
|
||||||
"id": "uuid",
|
|
||||||
"consumed_by_agent_id": "copilot-agent"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. UI/UX Plan
|
|
||||||
|
|
||||||
### Layout priorities
|
|
||||||
|
|
||||||
- A strong local-control dashboard feel rather than a generic admin template
|
|
||||||
- Clear separation between pending work and already-consumed history
|
|
||||||
- High-visibility connection/status strip for server and agent state
|
|
||||||
- Fast creation flow for new instructions
|
|
||||||
- Mobile-friendly stacking without losing queue readability
|
|
||||||
|
|
||||||
### Required screens/sections
|
|
||||||
|
|
||||||
- Header with project identity and server status
|
|
||||||
- Agent activity panel with last seen/fetch information
|
|
||||||
- Composer form for new instructions
|
|
||||||
- Pending instructions list with edit/delete actions
|
|
||||||
- Consumed instructions list with crossed-out styling and metadata
|
|
||||||
- Settings panel for wait timeout/default response behavior
|
|
||||||
|
|
||||||
### Frontend quality bar
|
|
||||||
|
|
||||||
- Follow `.github/instructions/frontend-design.instructions.md` before implementing any UI.
|
|
||||||
- Use only local assets.
|
|
||||||
- Build a visually distinctive interface with careful typography, color, spacing, motion, and responsive behavior.
|
|
||||||
- Keep accessibility in scope: semantic HTML, keyboard support, visible focus states, sufficient contrast.
|
|
||||||
|
|
||||||
## 7. Logging, Reliability, and Error Handling Plan
|
|
||||||
|
|
||||||
- Log startup, shutdown, configuration load, database initialization, and MCP registration.
|
|
||||||
- Log each instruction lifecycle event: created, updated, deleted, consumed.
|
|
||||||
- Log each `get_user_request` call with agent id, wait time, and result type.
|
|
||||||
- Return structured JSON errors for API failures.
|
|
||||||
- Protect queue consumption with transactions/locking so two simultaneous fetches cannot consume the same instruction.
|
|
||||||
- Validate payloads and reject empty or whitespace-only instructions.
|
|
||||||
- Handle browser reconnects for SSE cleanly.
|
|
||||||
|
|
||||||
## 8. Todo List
|
|
||||||
|
|
||||||
- [x] **Project setup**
|
|
||||||
- [x] Create the backend package structure under `app/`.
|
|
||||||
- [x] Add `requirements.txt` with only the required dependencies.
|
|
||||||
- [x] Replace the placeholder contents of `main.py` with the application entrypoint.
|
|
||||||
- [x] Add a local configuration strategy for defaults and runtime overrides.
|
|
||||||
|
|
||||||
- [x] **Data layer**
|
|
||||||
- [x] Create SQLite schema for `instructions`, `settings`, and `agent_activity`.
|
|
||||||
- [x] Add startup migration/initialization logic.
|
|
||||||
- [x] Implement queue ordering and atomic consumption behavior.
|
|
||||||
- [x] Seed default settings on first run.
|
|
||||||
|
|
||||||
- [x] **MCP server**
|
|
||||||
- [x] Register the `get_user_request` tool using the official MCP Python SDK.
|
|
||||||
- [x] Implement one-at-a-time delivery semantics.
|
|
||||||
- [x] Implement wait-until-timeout behavior when the queue is empty.
|
|
||||||
- [x] Return empty/default responses based on configuration.
|
|
||||||
- [x] Record agent activity on every tool call.
|
|
||||||
|
|
||||||
- [x] **HTTP API**
|
|
||||||
- [x] Implement `GET /healthz`.
|
|
||||||
- [x] Implement `GET /api/status`.
|
|
||||||
- [x] Implement `GET /api/instructions`.
|
|
||||||
- [x] Implement `POST /api/instructions`.
|
|
||||||
- [x] Implement `PATCH /api/instructions/{instruction_id}`.
|
|
||||||
- [x] Implement `DELETE /api/instructions/{instruction_id}`.
|
|
||||||
- [x] Implement `GET /api/config`.
|
|
||||||
- [x] Implement `PATCH /api/config`.
|
|
||||||
- [x] Implement `GET /api/events` for SSE.
|
|
||||||
|
|
||||||
- [x] **Frontend**
|
|
||||||
- [x] Read and follow `.github/instructions/frontend-design.instructions.md` before starting UI work.
|
|
||||||
- [x] Create `static/index.html` and split CSS/JS into separate folders/files.
|
|
||||||
- [x] Build the instruction composer.
|
|
||||||
- [x] Build the pending instruction list with edit/delete controls.
|
|
||||||
- [x] Build the consumed instruction list with crossed-out/greyed-out styling.
|
|
||||||
- [x] Build the live server/agent status panel.
|
|
||||||
- [x] Build the settings editor for timeout/default-response behavior.
|
|
||||||
- [x] Wire SSE updates into the UI so changes appear in real time.
|
|
||||||
- [x] Make the interface responsive and keyboard accessible.
|
|
||||||
|
|
||||||
- [x] **Observability and robustness**
|
|
||||||
- [x] Add centralized logging configuration.
|
|
||||||
- [x] Add structured error responses and exception handling.
|
|
||||||
- [x] Add queue-consumption concurrency protection.
|
|
||||||
- [x] Add validation for invalid edits/deletes of consumed instructions.
|
|
||||||
- [ ] Add tests for empty-queue, timeout, and consume-once behavior.
|
|
||||||
|
|
||||||
- [x] **Improvements (post-launch)**
|
|
||||||
- [x] Replace 1-second polling wait loop with `asyncio.Event`-based immediate wakeup.
|
|
||||||
- [x] Min-wait is a floor only when the queue is empty — a new instruction immediately wakes any waiting tool call (verified with timing test in `tests/test_wakeup.py`).
|
|
||||||
- [x] Enrich SSE events with full item payloads (no extra re-fetch round-trips).
|
|
||||||
- [x] Auto-refresh relative timestamps in the UI every 20 s.
|
|
||||||
- [x] Document title badge showing pending instruction count.
|
|
||||||
- [x] SSE reconnecting indicator in the header.
|
|
||||||
- [x] Dark / light theme toggle defaulting to OS colour-scheme preference.
|
|
||||||
- [x] `default_wait_seconds` changed to fully server-controlled (agents can no longer override wait time).
|
|
||||||
- [x] Non-blocking `server.ps1` management script (start / stop / restart / status / logs).
|
|
||||||
- [x] Non-blocking `server.sh` bash management script — identical feature set for macOS / Linux.
|
|
||||||
- [x] MCP stateless/stateful mode configurable via `MCP_STATELESS` env var (default `true`).
|
|
||||||
- [x] Per-agent generation counter prevents abandoned (timed-out) coroutines from silently consuming instructions meant for newer calls.
|
|
||||||
- [x] `tests/test_wakeup.py` covers both immediate-wakeup timing and concurrent-call generation safety.
|
|
||||||
- [x] Optional Bearer-token authentication via `API_TOKEN` env var (disabled by default); web UI prompts for token on first load.
|
|
||||||
|
|
||||||
- [ ] **Documentation and developer experience**
|
|
||||||
- [x] Document local run instructions.
|
|
||||||
- [x] Document the MCP tool contract clearly.
|
|
||||||
- [x] Document the HTTP API with request/response examples.
|
|
||||||
- [x] Document how agent connectivity is inferred.
|
|
||||||
- [x] Document how the frontend design instruction must be used during UI implementation.
|
|
||||||
|
|
||||||
## 9. Running the Server
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Python 3.11+
|
|
||||||
- pip
|
|
||||||
|
|
||||||
### Install dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Start the server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use the included management scripts (recommended — non-blocking):
|
|
||||||
|
|
||||||
**PowerShell (Windows)**
|
|
||||||
```powershell
|
|
||||||
.\server.ps1 start # start in background, logs to logs/
|
|
||||||
.\server.ps1 stop # graceful stop
|
|
||||||
.\server.ps1 restart # stop + start
|
|
||||||
.\server.ps1 status # PID, memory, tail logs
|
|
||||||
.\server.ps1 logs # show last 40 stdout lines
|
|
||||||
.\server.ps1 logs -f # follow logs live
|
|
||||||
.\server.ps1 logs 100 # show last 100 lines
|
|
||||||
```
|
|
||||||
|
|
||||||
**Bash (macOS / Linux)**
|
|
||||||
```bash
|
|
||||||
chmod +x server.sh # make executable once
|
|
||||||
./server.sh start # start in background, logs to logs/
|
|
||||||
./server.sh stop # graceful stop
|
|
||||||
./server.sh restart # stop + start
|
|
||||||
./server.sh status # PID, memory, tail logs
|
|
||||||
./server.sh logs # show last 40 stdout lines
|
|
||||||
./server.sh logs -f # follow logs live
|
|
||||||
./server.sh logs 100 # show last 100 lines
|
|
||||||
```
|
|
||||||
|
|
||||||
The server starts on `http://localhost:8000` by default.
|
|
||||||
|
|
||||||
| URL | Description |
|
|
||||||
|-----|-------------|
|
|
||||||
| `http://localhost:8000/` | Web UI |
|
|
||||||
| `http://localhost:8000/mcp` | MCP streamable-HTTP endpoint |
|
|
||||||
| `http://localhost:8000/docs` | FastAPI interactive API docs |
|
|
||||||
|
|
||||||
### Environment variable overrides
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `HOST` | `0.0.0.0` | Bind address |
|
|
||||||
| `HTTP_PORT` | `8000` | HTTP port |
|
|
||||||
| `DB_PATH` | `data/local_mcp.sqlite3` | SQLite database path |
|
|
||||||
| `LOG_LEVEL` | `INFO` | Logging level |
|
|
||||||
| `DEFAULT_WAIT_SECONDS` | `10` | Default tool wait timeout |
|
|
||||||
| `DEFAULT_EMPTY_RESPONSE` | `call this tool \`get_user_request\` again to fetch latest user input...` | Default response when queue is empty |
|
|
||||||
| `AGENT_STALE_AFTER_SECONDS` | `30` | Seconds of inactivity before agent shown as idle |
|
|
||||||
| `MCP_STATELESS` | `true` | `true` for stateless sessions (survives restarts, recommended); `false` for stateful |
|
|
||||||
| `API_TOKEN` | _(empty)_ | When set, all `/api/*` and `/mcp` requests require `Authorization: Bearer <token>`; web UI prompts for the token on first load |
|
|
||||||
|
|
||||||
### Configuring an MCP client (agent)
|
|
||||||
|
|
||||||
Point the agent's MCP client to the streamable-HTTP transport:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"local-mcp": {
|
|
||||||
"url": "http://localhost:8000/mcp",
|
|
||||||
"transport": "streamable-http"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If `API_TOKEN` is set, include the token as a request header:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"local-mcp": {
|
|
||||||
"url": "http://localhost:8000/mcp",
|
|
||||||
"transport": "streamable-http",
|
|
||||||
"headers": {
|
|
||||||
"Authorization": "Bearer <your-token>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The agent should call `get_user_request` aggressively and continuously — **do not end the working session**. Every call returns the next pending instruction (if any). When the queue is empty the tool waits up to `wait_seconds` before returning an empty/default response, so the agent should loop and call again.
|
|
||||||
|
|
||||||
## 10. Implementation Notes for Future Work
|
|
||||||
|
|
||||||
- Prefer small, explicit modules over monolithic files.
|
|
||||||
- Keep the project localhost-first and avoid remote asset dependencies.
|
|
||||||
- Treat the MCP tool and the web UI as two views over the same instruction queue.
|
|
||||||
- Optimize for correctness of queue semantics first, then refine the visual and realtime experience.
|
|
||||||
@@ -22,8 +22,7 @@ func handleUpdateConfig(stores Stores, broker *events.Broker) http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Decode partial patch
|
// Decode partial patch
|
||||||
var patch struct {
|
var patch struct {
|
||||||
DefaultWaitSeconds *int `json:"default_wait_seconds"`
|
DefaultWaitSeconds *int `json:"default_wait_seconds"`
|
||||||
AgentStaleAfterSeconds *int `json:"agent_stale_after_seconds"`
|
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Invalid JSON")
|
writeError(w, http.StatusBadRequest, "Invalid JSON")
|
||||||
@@ -41,9 +40,6 @@ func handleUpdateConfig(stores Stores, broker *events.Broker) http.HandlerFunc {
|
|||||||
if patch.DefaultWaitSeconds != nil {
|
if patch.DefaultWaitSeconds != nil {
|
||||||
current.DefaultWaitSeconds = *patch.DefaultWaitSeconds
|
current.DefaultWaitSeconds = *patch.DefaultWaitSeconds
|
||||||
}
|
}
|
||||||
if patch.AgentStaleAfterSeconds != nil {
|
|
||||||
current.AgentStaleAfterSeconds = *patch.AgentStaleAfterSeconds
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := stores.Settings.Update(current); err != nil {
|
if err := stores.Settings.Update(current); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
|||||||
@@ -106,14 +106,21 @@ func handleDeleteInstruction(stores Stores, broker *events.Broker) http.HandlerF
|
|||||||
|
|
||||||
func handleClearConsumed(stores Stores, broker *events.Broker) http.HandlerFunc {
|
func handleClearConsumed(stores Stores, broker *events.Broker) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
items, err := stores.Instructions.List("consumed")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := stores.Instructions.DeleteConsumed(); err != nil {
|
if err := stores.Instructions.DeleteConsumed(); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
counts, _ := stores.Instructions.Counts()
|
counts, _ := stores.Instructions.Counts()
|
||||||
broker.Broadcast("history.cleared", nil)
|
cleared := len(items)
|
||||||
|
broker.Broadcast("history.cleared", map[string]any{"count": cleared})
|
||||||
broker.Broadcast("status.changed", map[string]any{"queue": counts})
|
broker.Broadcast("status.changed", map[string]any{"queue": counts})
|
||||||
w.WriteHeader(http.StatusNoContent)
|
writeJSON(w, http.StatusOK, map[string]any{"cleared": cleared})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/local-mcp/local-mcp-go/internal/config"
|
||||||
"github.com/local-mcp/local-mcp-go/internal/models"
|
"github.com/local-mcp/local-mcp-go/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ func handleStatus(stores Stores) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if latest != nil {
|
if latest != nil {
|
||||||
connected := time.Since(latest.LastSeenAt).Seconds() <= float64(cfg.AgentStaleAfterSeconds)
|
connected := time.Since(latest.LastSeenAt).Seconds() <= float64(config.AgentStaleAfterSeconds)
|
||||||
agent = map[string]any{
|
agent = map[string]any{
|
||||||
"connected": connected,
|
"connected": connected,
|
||||||
"last_seen_at": latest.LastSeenAt.Format(time.RFC3339Nano),
|
"last_seen_at": latest.LastSeenAt.Format(time.RFC3339Nano),
|
||||||
@@ -57,6 +58,8 @@ func handleStatus(stores Stores) http.HandlerFunc {
|
|||||||
"server": map[string]any{
|
"server": map[string]any{
|
||||||
"status": "up",
|
"status": "up",
|
||||||
"started_at": serverStartTime.Format(time.RFC3339Nano),
|
"started_at": serverStartTime.Format(time.RFC3339Nano),
|
||||||
|
"type": "go",
|
||||||
|
"version": config.AppVersion,
|
||||||
},
|
},
|
||||||
"agent": agent,
|
"agent": agent,
|
||||||
"queue": map[string]any{
|
"queue": map[string]any{
|
||||||
@@ -66,7 +69,6 @@ func handleStatus(stores Stores) http.HandlerFunc {
|
|||||||
"settings": models.Settings{
|
"settings": models.Settings{
|
||||||
DefaultWaitSeconds: cfg.DefaultWaitSeconds,
|
DefaultWaitSeconds: cfg.DefaultWaitSeconds,
|
||||||
DefaultEmptyResponse: cfg.DefaultEmptyResponse,
|
DefaultEmptyResponse: cfg.DefaultEmptyResponse,
|
||||||
AgentStaleAfterSeconds: cfg.AgentStaleAfterSeconds,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultEmptyResponse = "call this tool `get_user_request` again to fetch latest user input..."
|
const (
|
||||||
|
AppVersion = "1.0.1"
|
||||||
|
AgentStaleAfterSeconds = 30
|
||||||
|
DefaultEmptyResponse = "call this tool `get_user_request` again to fetch latest user input..."
|
||||||
|
)
|
||||||
|
|
||||||
// Config holds all runtime configuration values for local-mcp.
|
// Config holds all runtime configuration values for local-mcp.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -15,7 +19,6 @@ type Config struct {
|
|||||||
DBPath string
|
DBPath string
|
||||||
LogLevel string
|
LogLevel string
|
||||||
DefaultWaitSeconds int
|
DefaultWaitSeconds int
|
||||||
AgentStaleAfterSeconds int
|
|
||||||
MCPStateless bool
|
MCPStateless bool
|
||||||
APIToken string
|
APIToken string
|
||||||
}
|
}
|
||||||
@@ -28,7 +31,6 @@ func Load() Config {
|
|||||||
DBPath: getEnv("DB_PATH", "data/local_mcp.sqlite3"),
|
DBPath: getEnv("DB_PATH", "data/local_mcp.sqlite3"),
|
||||||
LogLevel: getEnv("LOG_LEVEL", "INFO"),
|
LogLevel: getEnv("LOG_LEVEL", "INFO"),
|
||||||
DefaultWaitSeconds: getEnvInt("DEFAULT_WAIT_SECONDS", 10),
|
DefaultWaitSeconds: getEnvInt("DEFAULT_WAIT_SECONDS", 10),
|
||||||
AgentStaleAfterSeconds: getEnvInt("AGENT_STALE_AFTER_SECONDS", 30),
|
|
||||||
MCPStateless: getEnvBool("MCP_STATELESS", true),
|
MCPStateless: getEnvBool("MCP_STATELESS", true),
|
||||||
APIToken: getEnv("API_TOKEN", ""),
|
APIToken: getEnv("API_TOKEN", ""),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ CREATE TABLE IF NOT EXISTS agent_activity (
|
|||||||
// defaultSettings seeds initial values; OR IGNORE means existing rows are unchanged.
|
// defaultSettings seeds initial values; OR IGNORE means existing rows are unchanged.
|
||||||
const defaultSettings = "" +
|
const defaultSettings = "" +
|
||||||
"INSERT OR IGNORE INTO settings (key, value) VALUES ('default_wait_seconds', '10');\n" +
|
"INSERT OR IGNORE INTO settings (key, value) VALUES ('default_wait_seconds', '10');\n" +
|
||||||
"INSERT OR IGNORE INTO settings (key, value) VALUES ('agent_stale_after_seconds', '30');\n" +
|
"DELETE FROM settings WHERE key = 'default_empty_response';\n" +
|
||||||
"DELETE FROM settings WHERE key = 'default_empty_response';\n"
|
"DELETE FROM settings WHERE key = 'agent_stale_after_seconds';\n"
|
||||||
|
|
||||||
// Open opens (creating if necessary) a SQLite database at dbPath, applies the
|
// Open opens (creating if necessary) a SQLite database at dbPath, applies the
|
||||||
// schema, and seeds default settings.
|
// schema, and seeds default settings.
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func New(
|
|||||||
broker *events.Broker,
|
broker *events.Broker,
|
||||||
) *Handler {
|
) *Handler {
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
MCP: server.NewMCPServer("local-mcp", "1.0.0"),
|
MCP: server.NewMCPServer("local-mcp", config.AppVersion),
|
||||||
instStore: instStore,
|
instStore: instStore,
|
||||||
settStore: settStore,
|
settStore: settStore,
|
||||||
agentStore: agentStore,
|
agentStore: agentStore,
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ type Instruction struct {
|
|||||||
type Settings struct {
|
type Settings struct {
|
||||||
DefaultWaitSeconds int `json:"default_wait_seconds"`
|
DefaultWaitSeconds int `json:"default_wait_seconds"`
|
||||||
DefaultEmptyResponse string `json:"default_empty_response"`
|
DefaultEmptyResponse string `json:"default_empty_response"`
|
||||||
AgentStaleAfterSeconds int `json:"agent_stale_after_seconds"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentActivity tracks the last time an agent called get_user_request.
|
// AgentActivity tracks the last time an agent called get_user_request.
|
||||||
|
|||||||
@@ -135,9 +135,10 @@ func (s *InstructionStore) Create(content string) (*models.Instruction, error) {
|
|||||||
id := uuid.New().String()
|
id := uuid.New().String()
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|
||||||
// Assign next position
|
// Assign next position from the full instruction history so positions remain
|
||||||
|
// globally stable even after the pending queue empties.
|
||||||
var maxPos sql.NullInt64
|
var maxPos sql.NullInt64
|
||||||
_ = s.db.QueryRow(`SELECT MAX(position) FROM instructions WHERE status = 'pending'`).Scan(&maxPos)
|
_ = s.db.QueryRow(`SELECT MAX(position) FROM instructions`).Scan(&maxPos)
|
||||||
position := int(maxPos.Int64) + 1
|
position := int(maxPos.Int64) + 1
|
||||||
|
|
||||||
_, err := s.db.Exec(`
|
_, err := s.db.Exec(`
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ func (s *SettingsStore) Get() (models.Settings, error) {
|
|||||||
cfg := models.Settings{
|
cfg := models.Settings{
|
||||||
DefaultWaitSeconds: 10,
|
DefaultWaitSeconds: 10,
|
||||||
DefaultEmptyResponse: config.DefaultEmptyResponse,
|
DefaultEmptyResponse: config.DefaultEmptyResponse,
|
||||||
AgentStaleAfterSeconds: 30,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
@@ -43,10 +42,6 @@ func (s *SettingsStore) Get() (models.Settings, error) {
|
|||||||
if n, err := strconv.Atoi(value); err == nil {
|
if n, err := strconv.Atoi(value); err == nil {
|
||||||
cfg.DefaultWaitSeconds = n
|
cfg.DefaultWaitSeconds = n
|
||||||
}
|
}
|
||||||
case "agent_stale_after_seconds":
|
|
||||||
if n, err := strconv.Atoi(value); err == nil {
|
|
||||||
cfg.AgentStaleAfterSeconds = n
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cfg, rows.Err()
|
return cfg, rows.Err()
|
||||||
@@ -57,10 +52,8 @@ func (s *SettingsStore) Get() (models.Settings, error) {
|
|||||||
func (s *SettingsStore) Update(patch models.Settings) error {
|
func (s *SettingsStore) Update(patch models.Settings) error {
|
||||||
_, err := s.db.Exec(`
|
_, err := s.db.Exec(`
|
||||||
INSERT OR REPLACE INTO settings (key, value) VALUES
|
INSERT OR REPLACE INTO settings (key, value) VALUES
|
||||||
('default_wait_seconds', ?),
|
('default_wait_seconds', ?)` ,
|
||||||
('agent_stale_after_seconds', ?)`,
|
|
||||||
strconv.Itoa(patch.DefaultWaitSeconds),
|
strconv.Itoa(patch.DefaultWaitSeconds),
|
||||||
strconv.Itoa(patch.AgentStaleAfterSeconds),
|
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ func main() {
|
|||||||
mcpURL := fmt.Sprintf("http://%s/mcp", addr)
|
mcpURL := fmt.Sprintf("http://%s/mcp", addr)
|
||||||
|
|
||||||
slog.Info("local-mcp-go ready",
|
slog.Info("local-mcp-go ready",
|
||||||
|
"version", config.AppVersion,
|
||||||
"http", httpURL,
|
"http", httpURL,
|
||||||
"mcp", mcpURL,
|
"mcp", mcpURL,
|
||||||
"stateless", cfg.MCPStateless,
|
"stateless", cfg.MCPStateless,
|
||||||
@@ -131,7 +132,7 @@ func main() {
|
|||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("╔══════════════════════════════════════════════════════════╗")
|
fmt.Println("╔══════════════════════════════════════════════════════════╗")
|
||||||
fmt.Printf("║ local-mcp-go ready on port %s%-24s║\n", port, "")
|
fmt.Printf("║ local-mcp-go v%-6s ready on port %s%-14s║\n", config.AppVersion, port, "")
|
||||||
fmt.Println("║ ║")
|
fmt.Println("║ ║")
|
||||||
fmt.Printf("║ Web UI: %-46s║\n", httpURL)
|
fmt.Printf("║ Web UI: %-46s║\n", httpURL)
|
||||||
fmt.Printf("║ MCP: %-46s║\n", mcpURL)
|
fmt.Printf("║ MCP: %-46s║\n", mcpURL)
|
||||||
|
|||||||
9
main.py
9
main.py
@@ -17,7 +17,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
from app.api import routes_config, routes_instructions, routes_status
|
from app.api import routes_config, routes_instructions, routes_status
|
||||||
from app.api.auth import TokenAuthMiddleware
|
from app.api.auth import TokenAuthMiddleware
|
||||||
from app.config import settings
|
from app.config import APP_VERSION, settings
|
||||||
from app.database import init_db
|
from app.database import init_db
|
||||||
from app.logging_setup import configure_logging
|
from app.logging_setup import configure_logging
|
||||||
from app.mcp_server import mcp, mcp_asgi_app
|
from app.mcp_server import mcp, mcp_asgi_app
|
||||||
@@ -37,11 +37,12 @@ logger = logging.getLogger(__name__)
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup
|
# Startup
|
||||||
logger.info("local-mcp starting up – initialising database …")
|
logger.info("local-mcp v%s starting up – initialising database …", APP_VERSION)
|
||||||
init_db(settings.db_path)
|
init_db(settings.db_path)
|
||||||
await instruction_service.init_wakeup()
|
await instruction_service.init_wakeup()
|
||||||
logger.info(
|
logger.info(
|
||||||
"local-mcp ready http://%s:%d | MCP http://%s:%d/mcp (stateless=%s)",
|
"local-mcp v%s ready http://%s:%d | MCP http://%s:%d/mcp (stateless=%s)",
|
||||||
|
APP_VERSION,
|
||||||
settings.host, settings.http_port,
|
settings.host, settings.http_port,
|
||||||
settings.host, settings.http_port,
|
settings.host, settings.http_port,
|
||||||
settings.mcp_stateless,
|
settings.mcp_stateless,
|
||||||
@@ -57,7 +58,7 @@ def create_app() -> FastAPI:
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="local-mcp",
|
title="local-mcp",
|
||||||
description="Localhost MCP server with instruction queue management UI",
|
description="Localhost MCP server with instruction queue management UI",
|
||||||
version="1.0.0",
|
version=APP_VERSION,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -489,9 +489,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgba(0, 0, 0, 0.72);
|
background: color-mix(in srgb, var(--bg-void) 44%, transparent);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(2px);
|
||||||
-webkit-backdrop-filter: blur(6px);
|
-webkit-backdrop-filter: blur(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
@@ -559,6 +559,27 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.confirm-card {
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-card__icon--danger {
|
||||||
|
color: var(--red);
|
||||||
|
background: color-mix(in srgb, var(--red) 12%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--red) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-card__actions {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-card__actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Quick shortcuts ─────────────────────────────────────────────────────── */
|
/* ── Quick shortcuts ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.shortcuts-container {
|
.shortcuts-container {
|
||||||
|
|||||||
@@ -148,28 +148,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Config -->
|
|
||||||
<div class="panel fade-in fade-in--delay-2">
|
|
||||||
<div class="panel-header">
|
|
||||||
<span class="panel-title">
|
|
||||||
<svg class="icon icon--sm" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
|
||||||
Settings
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="panel-body">
|
|
||||||
<form id="config-form">
|
|
||||||
<div class="config-field">
|
|
||||||
<label class="config-label" for="cfg-stale">Agent Stale After (sec)</label>
|
|
||||||
<input id="cfg-stale" class="input input--sm" type="number" min="5" max="600" value="30" />
|
|
||||||
<span class="config-hint">Inactivity before agent shown as idle</span>
|
|
||||||
</div>
|
|
||||||
<button id="cfg-save" type="submit" class="btn btn--ghost btn--sm" style="width:100%">
|
|
||||||
<svg class="icon icon--sm" viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
|
||||||
Save Settings
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,64 @@ export function toast(message, type = 'info') {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function confirmDialog({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = 'Confirm',
|
||||||
|
cancelText = 'Cancel',
|
||||||
|
confirmClass = 'btn--danger',
|
||||||
|
}) {
|
||||||
|
document.getElementById('confirm-modal')?.remove();
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'confirm-modal';
|
||||||
|
overlay.className = 'auth-overlay';
|
||||||
|
overlay.tabIndex = -1;
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="auth-card confirm-card fade-in" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
|
||||||
|
<div class="auth-card__icon confirm-card__icon confirm-card__icon--danger">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 9v4"/>
|
||||||
|
<path d="M12 17h.01"/>
|
||||||
|
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 id="confirm-title" class="auth-card__title">${title}</h2>
|
||||||
|
<p class="auth-card__desc">${message}</p>
|
||||||
|
<div class="confirm-card__actions">
|
||||||
|
<button type="button" class="btn btn--ghost" data-action="cancel">${cancelText}</button>
|
||||||
|
<button type="button" class="btn ${confirmClass}" data-action="confirm">${confirmText}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const cancelBtn = overlay.querySelector('[data-action="cancel"]');
|
||||||
|
const confirmBtn = overlay.querySelector('[data-action="confirm"]');
|
||||||
|
|
||||||
|
const close = (result) => {
|
||||||
|
overlay.remove();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) close(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelBtn.addEventListener('click', () => close(false));
|
||||||
|
confirmBtn.addEventListener('click', () => close(true));
|
||||||
|
|
||||||
|
overlay.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') close(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.focus();
|
||||||
|
confirmBtn.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Token auth modal ──────────────────────────────────────────────────────
|
// ── Token auth modal ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function showTokenModal(onSuccess) {
|
function showTokenModal(onSuccess) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { toast } from './app.js';
|
import { confirmDialog, toast } from './app.js';
|
||||||
|
|
||||||
// ── SVG icon helpers ──────────────────────────────────────────────────────
|
// ── SVG icon helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -45,6 +45,16 @@ function fmtAbsTime(isoStr) {
|
|||||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function comparePending(a, b) {
|
||||||
|
return (a.position - b.position) || String(a.created_at).localeCompare(String(b.created_at));
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareConsumed(a, b) {
|
||||||
|
const consumedDelta = new Date(b.consumed_at || 0).getTime() - new Date(a.consumed_at || 0).getTime();
|
||||||
|
if (consumedDelta !== 0) return consumedDelta;
|
||||||
|
return b.position - a.position;
|
||||||
|
}
|
||||||
|
|
||||||
/** Refresh all relative-time spans in the lists (called by app.js on a timer). */
|
/** Refresh all relative-time spans in the lists (called by app.js on a timer). */
|
||||||
export function refreshTimestamps() {
|
export function refreshTimestamps() {
|
||||||
document.querySelectorAll('[data-ts]').forEach(el => {
|
document.querySelectorAll('[data-ts]').forEach(el => {
|
||||||
@@ -127,7 +137,14 @@ function renderPendingCard(item, index) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
deleteBtn.addEventListener('click', async () => {
|
deleteBtn.addEventListener('click', async () => {
|
||||||
if (!confirm('Delete this instruction?')) return;
|
const confirmed = await confirmDialog({
|
||||||
|
title: 'Delete instruction?',
|
||||||
|
message: 'This removes the pending instruction from the queue before any agent can consume it.',
|
||||||
|
confirmText: 'Delete Instruction',
|
||||||
|
cancelText: 'Keep Instruction',
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
deleteBtn.disabled = true;
|
deleteBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
await api.deleteInstruction(item.id);
|
await api.deleteInstruction(item.id);
|
||||||
@@ -141,7 +158,7 @@ function renderPendingCard(item, index) {
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderConsumedCard(item) {
|
function renderConsumedCard(item, index) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'instruction-card instruction-card--consumed';
|
card.className = 'instruction-card instruction-card--consumed';
|
||||||
card.dataset.id = item.id;
|
card.dataset.id = item.id;
|
||||||
@@ -171,8 +188,8 @@ export function initInstructions() {
|
|||||||
|
|
||||||
function render(instructions) {
|
function render(instructions) {
|
||||||
if (!instructions) return;
|
if (!instructions) return;
|
||||||
const pending = instructions.filter(i => i.status === 'pending');
|
const pending = instructions.filter(i => i.status === 'pending').sort(comparePending);
|
||||||
const consumed = instructions.filter(i => i.status === 'consumed').reverse();
|
const consumed = instructions.filter(i => i.status === 'consumed').sort(compareConsumed);
|
||||||
|
|
||||||
// Pending
|
// Pending
|
||||||
pendingList.innerHTML = '';
|
pendingList.innerHTML = '';
|
||||||
@@ -194,7 +211,7 @@ export function initInstructions() {
|
|||||||
consumedList.innerHTML = `<div class="empty-state"><div class="empty-state__icon">◈</div>No consumed instructions yet</div>`;
|
consumedList.innerHTML = `<div class="empty-state"><div class="empty-state__icon">◈</div>No consumed instructions yet</div>`;
|
||||||
clearHistoryBtn.style.display = 'none';
|
clearHistoryBtn.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
consumed.forEach(item => consumedList.appendChild(renderConsumedCard(item)));
|
consumed.forEach((item, i) => consumedList.appendChild(renderConsumedCard(item, i)));
|
||||||
clearHistoryBtn.style.display = '';
|
clearHistoryBtn.style.display = '';
|
||||||
}
|
}
|
||||||
consumedBadge.textContent = consumed.length;
|
consumedBadge.textContent = consumed.length;
|
||||||
@@ -205,11 +222,19 @@ export function initInstructions() {
|
|||||||
|
|
||||||
// Clear history button
|
// Clear history button
|
||||||
clearHistoryBtn.addEventListener('click', async () => {
|
clearHistoryBtn.addEventListener('click', async () => {
|
||||||
if (!confirm('Clear all consumed instruction history? This cannot be undone.')) return;
|
const confirmed = await confirmDialog({
|
||||||
|
title: 'Clear consumed history?',
|
||||||
|
message: 'This removes all consumed instructions from the history panel. This action cannot be undone.',
|
||||||
|
confirmText: 'Clear History',
|
||||||
|
cancelText: 'Keep History',
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
clearHistoryBtn.disabled = true;
|
clearHistoryBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const res = await api.clearConsumed();
|
const res = await api.clearConsumed();
|
||||||
toast(`Cleared ${res.cleared} consumed instruction${res.cleared !== 1 ? 's' : ''}`, 'info');
|
const cleared = res?.cleared ?? 0;
|
||||||
|
toast(`Cleared ${cleared} consumed instruction${cleared !== 1 ? 's' : ''}`, 'info');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, 'error');
|
toast(e.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { api } from './api.js';
|
|
||||||
import { toast } from './app.js';
|
|
||||||
|
|
||||||
// ── Time helpers ──────────────────────────────────────────────────────────
|
// ── Time helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -65,12 +63,22 @@ function renderStatusPanel(status) {
|
|||||||
pending_count: status.queue?.pending_count ?? 0,
|
pending_count: status.queue?.pending_count ?? 0,
|
||||||
consumed_count: status.queue?.consumed_count ?? 0,
|
consumed_count: status.queue?.consumed_count ?? 0,
|
||||||
};
|
};
|
||||||
|
const serverType = status.server?.type || 'unknown';
|
||||||
|
const version = status.server?.version || '–';
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">Server Up</span>
|
<span class="stat-label">Server Up</span>
|
||||||
<span class="stat-value">${fmtTime(status.server?.started_at)}</span>
|
<span class="stat-value">${fmtTime(status.server?.started_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Version</span>
|
||||||
|
<span class="stat-value">v${escapeHtml(version)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Server Type</span>
|
||||||
|
<span class="stat-value">${escapeHtml(serverType)}</span>
|
||||||
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">Pending</span>
|
<span class="stat-label">Pending</span>
|
||||||
<span class="stat-value stat-value--cyan">${queue.pending_count}</span>
|
<span class="stat-value stat-value--cyan">${queue.pending_count}</span>
|
||||||
@@ -116,35 +124,7 @@ export function initStatus() {
|
|||||||
// ── Config panel ──────────────────────────────────────────────────────────
|
// ── Config panel ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function initConfig() {
|
export function initConfig() {
|
||||||
const form = document.getElementById('config-form');
|
// Intentionally empty: the web UI no longer exposes editable settings.
|
||||||
const staleInput = document.getElementById('cfg-stale');
|
|
||||||
const saveBtn = document.getElementById('cfg-save');
|
|
||||||
|
|
||||||
// Populate from state
|
|
||||||
state.subscribe('config', (cfg) => {
|
|
||||||
if (!cfg) return;
|
|
||||||
staleInput.value = cfg.agent_stale_after_seconds;
|
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
saveBtn.disabled = true;
|
|
||||||
const original = saveBtn.innerHTML;
|
|
||||||
saveBtn.innerHTML = '<span class="spinner"></span>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cfg = await api.updateConfig({
|
|
||||||
agent_stale_after_seconds: parseInt(staleInput.value, 10) || 30,
|
|
||||||
});
|
|
||||||
state.set('config', cfg);
|
|
||||||
toast('Settings saved', 'success');
|
|
||||||
} catch (e) {
|
|
||||||
toast(e.message, 'error');
|
|
||||||
} finally {
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
saveBtn.innerHTML = original;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
|
|||||||
Reference in New Issue
Block a user