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(
|
||||
default_wait_seconds=body.default_wait_seconds,
|
||||
default_empty_response=body.default_empty_response,
|
||||
agent_stale_after_seconds=body.agent_stale_after_seconds,
|
||||
)
|
||||
event_service.broadcast(
|
||||
"config.updated",
|
||||
{
|
||||
"default_wait_seconds": cfg.default_wait_seconds,
|
||||
"default_empty_response": cfg.default_empty_response,
|
||||
"agent_stale_after_seconds": cfg.agent_stale_after_seconds,
|
||||
},
|
||||
)
|
||||
return cfg
|
||||
|
||||
@@ -11,6 +11,7 @@ from datetime import datetime, timezone
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.config import APP_VERSION
|
||||
from app.models import (
|
||||
AgentInfo,
|
||||
HealthResponse,
|
||||
@@ -49,6 +50,8 @@ def get_status():
|
||||
server=ServerInfo(
|
||||
status="up",
|
||||
started_at=status_service.server_started_at(),
|
||||
version=APP_VERSION,
|
||||
type="python",
|
||||
),
|
||||
agent=agent_info,
|
||||
queue=QueueCounts(**counts),
|
||||
|
||||
@@ -9,7 +9,9 @@ import os
|
||||
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..."
|
||||
AGENT_STALE_AFTER_SECONDS = 30
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -27,7 +29,6 @@ class Settings:
|
||||
# MCP / queue behaviour (runtime-editable values are stored in DB; these are defaults for first run)
|
||||
default_wait_seconds: int = 10
|
||||
default_empty_response: str = DEFAULT_EMPTY_RESPONSE
|
||||
agent_stale_after_seconds: int = 30
|
||||
|
||||
# MCP server name
|
||||
mcp_server_name: str = "local-mcp"
|
||||
@@ -57,7 +58,6 @@ def load_settings() -> Settings:
|
||||
log_level=os.getenv("LOG_LEVEL", "INFO"),
|
||||
default_wait_seconds=int(os.getenv("DEFAULT_WAIT_SECONDS", "10")),
|
||||
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_stateless=_parse_bool(os.getenv("MCP_STATELESS", "true"), default=True),
|
||||
api_token=os.getenv("API_TOKEN", ""),
|
||||
|
||||
@@ -105,7 +105,6 @@ CREATE TABLE IF NOT EXISTS agent_activity (
|
||||
_DEFAULT_SETTINGS = {
|
||||
"default_wait_seconds": "10",
|
||||
"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 = ''",
|
||||
(_DEFAULT_SETTINGS["default_empty_response"],),
|
||||
)
|
||||
conn.execute("DELETE FROM settings WHERE key = 'agent_stale_after_seconds'")
|
||||
conn.commit()
|
||||
logger.debug("Default settings seeded")
|
||||
|
||||
|
||||
@@ -79,6 +79,8 @@ class UpdateInstructionRequest(BaseModel):
|
||||
class ServerInfo(BaseModel):
|
||||
status: str
|
||||
started_at: datetime
|
||||
version: str
|
||||
type: str
|
||||
|
||||
|
||||
class AgentInfo(BaseModel):
|
||||
@@ -107,13 +109,11 @@ class StatusResponse(BaseModel):
|
||||
class ConfigResponse(BaseModel):
|
||||
default_wait_seconds: int
|
||||
default_empty_response: str
|
||||
agent_stale_after_seconds: int
|
||||
|
||||
|
||||
class UpdateConfigRequest(BaseModel):
|
||||
default_wait_seconds: Optional[int] = 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__)
|
||||
|
||||
_SETTING_KEYS = {"default_wait_seconds", "default_empty_response", "agent_stale_after_seconds"}
|
||||
def get_config() -> ConfigResponse:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT key, value FROM settings").fetchall()
|
||||
@@ -21,22 +20,18 @@ def get_config() -> ConfigResponse:
|
||||
return ConfigResponse(
|
||||
default_wait_seconds=int(data.get("default_wait_seconds", 10)),
|
||||
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(
|
||||
default_wait_seconds: int | None = None,
|
||||
default_empty_response: str | None = None,
|
||||
agent_stale_after_seconds: int | None = None,
|
||||
) -> ConfigResponse:
|
||||
updates: dict[str, str] = {}
|
||||
if default_wait_seconds is not None:
|
||||
updates["default_wait_seconds"] = str(default_wait_seconds)
|
||||
if default_empty_response is not None:
|
||||
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:
|
||||
with get_write_conn() as conn:
|
||||
|
||||
@@ -10,6 +10,7 @@ import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.config import AGENT_STALE_AFTER_SECONDS
|
||||
from app.database import get_conn, get_write_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -55,25 +56,15 @@ def get_latest_agent_activity() -> Optional[sqlite3.Row]:
|
||||
).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:
|
||||
"""True if the most recent agent activity is within the stale threshold."""
|
||||
row = get_latest_agent_activity()
|
||||
if row is None:
|
||||
return False
|
||||
stale_seconds = get_agent_stale_seconds()
|
||||
last_seen = datetime.fromisoformat(row["last_seen_at"])
|
||||
now = datetime.now(timezone.utc)
|
||||
if last_seen.tzinfo is None:
|
||||
last_seen = last_seen.replace(tzinfo=timezone.utc)
|
||||
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) {
|
||||
// Decode partial patch
|
||||
var patch struct {
|
||||
DefaultWaitSeconds *int `json:"default_wait_seconds"`
|
||||
AgentStaleAfterSeconds *int `json:"agent_stale_after_seconds"`
|
||||
DefaultWaitSeconds *int `json:"default_wait_seconds"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
@@ -41,9 +40,6 @@ func handleUpdateConfig(stores Stores, broker *events.Broker) http.HandlerFunc {
|
||||
if patch.DefaultWaitSeconds != nil {
|
||||
current.DefaultWaitSeconds = *patch.DefaultWaitSeconds
|
||||
}
|
||||
if patch.AgentStaleAfterSeconds != nil {
|
||||
current.AgentStaleAfterSeconds = *patch.AgentStaleAfterSeconds
|
||||
}
|
||||
|
||||
if err := stores.Settings.Update(current); err != nil {
|
||||
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 {
|
||||
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 {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
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})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"cleared": cleared})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/local-mcp/local-mcp-go/internal/config"
|
||||
"github.com/local-mcp/local-mcp-go/internal/models"
|
||||
)
|
||||
|
||||
@@ -44,7 +45,7 @@ func handleStatus(stores Stores) http.HandlerFunc {
|
||||
}
|
||||
|
||||
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{
|
||||
"connected": connected,
|
||||
"last_seen_at": latest.LastSeenAt.Format(time.RFC3339Nano),
|
||||
@@ -57,6 +58,8 @@ func handleStatus(stores Stores) http.HandlerFunc {
|
||||
"server": map[string]any{
|
||||
"status": "up",
|
||||
"started_at": serverStartTime.Format(time.RFC3339Nano),
|
||||
"type": "go",
|
||||
"version": config.AppVersion,
|
||||
},
|
||||
"agent": agent,
|
||||
"queue": map[string]any{
|
||||
@@ -66,7 +69,6 @@ func handleStatus(stores Stores) http.HandlerFunc {
|
||||
"settings": models.Settings{
|
||||
DefaultWaitSeconds: cfg.DefaultWaitSeconds,
|
||||
DefaultEmptyResponse: cfg.DefaultEmptyResponse,
|
||||
AgentStaleAfterSeconds: cfg.AgentStaleAfterSeconds,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ import (
|
||||
"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.
|
||||
type Config struct {
|
||||
@@ -15,7 +19,6 @@ type Config struct {
|
||||
DBPath string
|
||||
LogLevel string
|
||||
DefaultWaitSeconds int
|
||||
AgentStaleAfterSeconds int
|
||||
MCPStateless bool
|
||||
APIToken string
|
||||
}
|
||||
@@ -28,7 +31,6 @@ func Load() Config {
|
||||
DBPath: getEnv("DB_PATH", "data/local_mcp.sqlite3"),
|
||||
LogLevel: getEnv("LOG_LEVEL", "INFO"),
|
||||
DefaultWaitSeconds: getEnvInt("DEFAULT_WAIT_SECONDS", 10),
|
||||
AgentStaleAfterSeconds: getEnvInt("AGENT_STALE_AFTER_SECONDS", 30),
|
||||
MCPStateless: getEnvBool("MCP_STATELESS", true),
|
||||
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.
|
||||
const defaultSettings = "" +
|
||||
"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
|
||||
// schema, and seeds default settings.
|
||||
|
||||
@@ -53,7 +53,7 @@ func New(
|
||||
broker *events.Broker,
|
||||
) *Handler {
|
||||
h := &Handler{
|
||||
MCP: server.NewMCPServer("local-mcp", "1.0.0"),
|
||||
MCP: server.NewMCPServer("local-mcp", config.AppVersion),
|
||||
instStore: instStore,
|
||||
settStore: settStore,
|
||||
agentStore: agentStore,
|
||||
|
||||
@@ -27,7 +27,6 @@ type Instruction struct {
|
||||
type Settings struct {
|
||||
DefaultWaitSeconds int `json:"default_wait_seconds"`
|
||||
DefaultEmptyResponse string `json:"default_empty_response"`
|
||||
AgentStaleAfterSeconds int `json:"agent_stale_after_seconds"`
|
||||
}
|
||||
|
||||
// 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()
|
||||
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
|
||||
_ = 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
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
|
||||
@@ -30,7 +30,6 @@ func (s *SettingsStore) Get() (models.Settings, error) {
|
||||
cfg := models.Settings{
|
||||
DefaultWaitSeconds: 10,
|
||||
DefaultEmptyResponse: config.DefaultEmptyResponse,
|
||||
AgentStaleAfterSeconds: 30,
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
@@ -43,10 +42,6 @@ func (s *SettingsStore) Get() (models.Settings, error) {
|
||||
if n, err := strconv.Atoi(value); err == nil {
|
||||
cfg.DefaultWaitSeconds = n
|
||||
}
|
||||
case "agent_stale_after_seconds":
|
||||
if n, err := strconv.Atoi(value); err == nil {
|
||||
cfg.AgentStaleAfterSeconds = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return cfg, rows.Err()
|
||||
@@ -57,10 +52,8 @@ func (s *SettingsStore) Get() (models.Settings, error) {
|
||||
func (s *SettingsStore) Update(patch models.Settings) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT OR REPLACE INTO settings (key, value) VALUES
|
||||
('default_wait_seconds', ?),
|
||||
('agent_stale_after_seconds', ?)`,
|
||||
('default_wait_seconds', ?)` ,
|
||||
strconv.Itoa(patch.DefaultWaitSeconds),
|
||||
strconv.Itoa(patch.AgentStaleAfterSeconds),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ func main() {
|
||||
mcpURL := fmt.Sprintf("http://%s/mcp", addr)
|
||||
|
||||
slog.Info("local-mcp-go ready",
|
||||
"version", config.AppVersion,
|
||||
"http", httpURL,
|
||||
"mcp", mcpURL,
|
||||
"stateless", cfg.MCPStateless,
|
||||
@@ -131,7 +132,7 @@ func main() {
|
||||
if runtime.GOOS == "windows" {
|
||||
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.Printf("║ Web UI: %-46s║\n", httpURL)
|
||||
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.auth import TokenAuthMiddleware
|
||||
from app.config import settings
|
||||
from app.config import APP_VERSION, settings
|
||||
from app.database import init_db
|
||||
from app.logging_setup import configure_logging
|
||||
from app.mcp_server import mcp, mcp_asgi_app
|
||||
@@ -37,11 +37,12 @@ logger = logging.getLogger(__name__)
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# 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)
|
||||
await instruction_service.init_wakeup()
|
||||
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.mcp_stateless,
|
||||
@@ -57,7 +58,7 @@ def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="local-mcp",
|
||||
description="Localhost MCP server with instruction queue management UI",
|
||||
version="1.0.0",
|
||||
version=APP_VERSION,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
@@ -489,9 +489,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
background: color-mix(in srgb, var(--bg-void) 44%, transparent);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
@@ -559,6 +559,27 @@
|
||||
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 ─────────────────────────────────────────────────────── */
|
||||
|
||||
.shortcuts-container {
|
||||
|
||||
@@ -148,28 +148,6 @@
|
||||
</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>
|
||||
|
||||
|
||||
@@ -27,6 +27,64 @@ export function toast(message, type = 'info') {
|
||||
}, 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 ──────────────────────────────────────────────────────
|
||||
|
||||
function showTokenModal(onSuccess) {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { state } from './state.js';
|
||||
import { api } from './api.js';
|
||||
import { toast } from './app.js';
|
||||
import { confirmDialog, toast } from './app.js';
|
||||
|
||||
// ── SVG icon helpers ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -45,6 +45,16 @@ function fmtAbsTime(isoStr) {
|
||||
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). */
|
||||
export function refreshTimestamps() {
|
||||
document.querySelectorAll('[data-ts]').forEach(el => {
|
||||
@@ -127,7 +137,14 @@ function renderPendingCard(item, index) {
|
||||
});
|
||||
|
||||
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;
|
||||
try {
|
||||
await api.deleteInstruction(item.id);
|
||||
@@ -141,7 +158,7 @@ function renderPendingCard(item, index) {
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderConsumedCard(item) {
|
||||
function renderConsumedCard(item, index) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'instruction-card instruction-card--consumed';
|
||||
card.dataset.id = item.id;
|
||||
@@ -171,8 +188,8 @@ export function initInstructions() {
|
||||
|
||||
function render(instructions) {
|
||||
if (!instructions) return;
|
||||
const pending = instructions.filter(i => i.status === 'pending');
|
||||
const consumed = instructions.filter(i => i.status === 'consumed').reverse();
|
||||
const pending = instructions.filter(i => i.status === 'pending').sort(comparePending);
|
||||
const consumed = instructions.filter(i => i.status === 'consumed').sort(compareConsumed);
|
||||
|
||||
// Pending
|
||||
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>`;
|
||||
clearHistoryBtn.style.display = 'none';
|
||||
} else {
|
||||
consumed.forEach(item => consumedList.appendChild(renderConsumedCard(item)));
|
||||
consumed.forEach((item, i) => consumedList.appendChild(renderConsumedCard(item, i)));
|
||||
clearHistoryBtn.style.display = '';
|
||||
}
|
||||
consumedBadge.textContent = consumed.length;
|
||||
@@ -205,11 +222,19 @@ export function initInstructions() {
|
||||
|
||||
// Clear history button
|
||||
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;
|
||||
try {
|
||||
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) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
*/
|
||||
|
||||
import { state } from './state.js';
|
||||
import { api } from './api.js';
|
||||
import { toast } from './app.js';
|
||||
|
||||
// ── Time helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -65,12 +63,22 @@ function renderStatusPanel(status) {
|
||||
pending_count: status.queue?.pending_count ?? 0,
|
||||
consumed_count: status.queue?.consumed_count ?? 0,
|
||||
};
|
||||
const serverType = status.server?.type || 'unknown';
|
||||
const version = status.server?.version || '–';
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Server Up</span>
|
||||
<span class="stat-value">${fmtTime(status.server?.started_at)}</span>
|
||||
</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">
|
||||
<span class="stat-label">Pending</span>
|
||||
<span class="stat-value stat-value--cyan">${queue.pending_count}</span>
|
||||
@@ -116,35 +124,7 @@ export function initStatus() {
|
||||
// ── Config panel ──────────────────────────────────────────────────────────
|
||||
|
||||
export function initConfig() {
|
||||
const form = document.getElementById('config-form');
|
||||
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;
|
||||
}
|
||||
});
|
||||
// Intentionally empty: the web UI no longer exposes editable settings.
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
|
||||
Reference in New Issue
Block a user