feat: add sitrep-panel skill

Live local HTML progress report (status board, running narrative,
screenshots, newest-first log) served on localhost via a stdlib-only
Python server, so a human can watch a long/delegated agent task
without reading the raw transcript. Adapted from dbl8005/sitrep-panel
(MIT), evaluated and drafted via Codex per this fleet's standard
skill-candidate review process. Bundled server and HTML template
copied unmodified from upstream.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 07:34:15 +02:00
co-authored by Claude Sonnet 5
parent fa824ed4e0
commit cfd518d451
4 changed files with 644 additions and 0 deletions
+6
View File
@@ -118,6 +118,11 @@ than assuming the delegate can fetch it itself.
- `obsidian-vault-memory` -- use an Obsidian vault as durable cross-session
memory: low-conflict session capture, canonical-note promotion, provenance
links, queryable Bases, and Syncthing-safe write boundaries.
- `sitrep-panel` -- keep a live local HTML report (status board, running
narrative, screenshots, newest-first log) updated during a long or
delegated task, served on localhost via a zero-dependency stdlib Python
server, so a human can watch progress in a browser tab instead of reading
the transcript.
## Provenance
@@ -136,6 +141,7 @@ each skill's frontmatter:
- [citeworthyio/seo-agent](https://github.com/citeworthyio/seo-agent) (MIT)
- [addyosmani/agent-skills](https://github.com/addyosmani/agent-skills) (MIT)
- [kepano/obsidian-skills](https://github.com/kepano/obsidian-skills) (MIT)
- [dbl8005/sitrep-panel](https://github.com/dbl8005/sitrep-panel) (MIT)
## Vetting external skills
+176
View File
@@ -0,0 +1,176 @@
---
name: sitrep-panel
description: Use when a human wants live browser visibility into a long-running or delegated agent task — asks for a sitrep panel, live status page, progress report, running narrative, screenshots, or a watchable local dashboard. Covers scaffolding, serving, resuming, updating, archiving, and stopping a private localhost report across unrelated project types and agent CLIs.
license: MIT
---
# Sitrep Panel
Keep a live local HTML report updated while work proceeds: a four-column
status board, a standing explanation of what is happening now, screenshots,
and a newest-first log. The browser polls a tiny timestamp file and reloads
when it changes. Everything stays on the machine; there is no account,
publish step, dependency install, build process, or external request.
Provenance: adapted from
[`dbl8005/sitrep-panel`](https://github.com/dbl8005/sitrep-panel), released
under the MIT license. The bundled server and HTML template are copied
unmodified from upstream. The workflow below preserves upstream's scaffold,
serve, resume, archive, and update protocol while adapting invocation and
background-process guidance for this fleet's mixed agents and CLI delegates.
This is scratch visibility, not durable project documentation. Keep one
active report at `.sitrep-panel/report/` per project so its URL remains stable
through a long session.
## Commands
Interpret `sitrep-panel [start | open | stop | archive]`; default to `start`.
- `start`: scaffold if necessary, start or reuse the server, and print the URL.
- `open`: print the active report's URL, starting its server if needed; do not
alter report content.
- `archive`: move the active report to
`.sitrep-panel/archive/<UTC-timestamp>-<short-slug>/`, then scaffold a fresh
report. Use this only for genuinely unrelated work.
- `stop`: stop the server process that was launched for this report. Preserve
all report content.
Do not assume slash-command support. These are workflow actions a session
agent or CLI delegate can carry out with its own filesystem and background
execution tools.
## Scaffold or resume
Resolve `<skill-dir>` to the directory containing this `SKILL.md`, and
`<project>` to the project root. On `start`, if
`<project>/.sitrep-panel/report/` does not exist:
1. Create `<project>/.sitrep-panel/report/screenshots/`.
2. Copy `<skill-dir>/support/assets/template.html` to
`<project>/.sitrep-panel/report/index.html`.
3. Replace `{{TITLE}}` with a short task name and `{{SUBTITLE}}` with one line
saying what the report tracks and when it started. HTML-escape inserted
text.
4. Write `<project>/.sitrep-panel/report/meta.json` with a current UTC time:
```json
{"updated_at": "2026-08-24T12:34:56Z"}
```
5. In a Git repository, check whether `.sitrep-panel/` is already ignored
(`git check-ignore -q .sitrep-panel/` is suitable once it exists). If not,
append `.sitrep-panel/` to the project's `.gitignore` and tell the user.
Skip this only when the user explicitly wants the report committed.
If the report already exists, resume it without overwriting content. Archive
first only when the new work is unrelated. Treat any existing `.gitignore`
changes as project changes: preserve them and append only the missing rule.
## Serve on localhost
First read `.sitrep-panel/report/.server.json` if present. If it contains a
port and a GET to `http://127.0.0.1:<port>/meta.json` succeeds, reuse that
server and do not start a duplicate.
Otherwise launch the bundled blocking server with the current agent's
detached/background mechanism:
```bash
python3 <skill-dir>/support/scripts/serve.py <project>/.sitrep-panel/report
```
Capture its single startup line:
```text
SERVING http://localhost:<port>/
```
It binds only to `127.0.0.1`, tries port 8934, and lets the OS select a free
port if 8934 is occupied. Immediately record the selected port and UTC start
time in `.sitrep-panel/report/.server.json`:
```json
{"port": 8934, "started_at": "2026-08-24T12:34:56Z"}
```
Also retain the background process/session handle when the execution surface
provides one; use that exact handle for `stop`. Never stop a server with a
broad `pkill python`, `killall`, or an unverified pattern. After stopping,
verify the recorded endpoint no longer answers and remove only the stale
`.server.json` file. A stale file is harmless: `start` and `open` must probe
the endpoint before trusting it.
After every `start` or `open`, show `http://localhost:<port>/` prominently.
The URL is the result, not an implementation detail.
## Update throughout the task
A panel written once at startup is useless. Update it after each meaningful
write, decision, state transition, screenshot, or verification result. Skip
noise such as routine file reads.
For each update, edit `index.html` and then perform these operations:
1. Replace the contents of `#current-work-body` with plain-language prose
explaining what is happening now and why. This block describes the
present, not history.
2. Prepend one entry directly inside `#entries`, before older entries. Use
the exact `.entry`, `.entry-rail`, `.entry-dot`, `.entry-time`,
`.entry-title`, and `.entry-body` structure in the template comment; the
timeline styling depends on it. State what happened, what was decided and
why, and what was verified.
3. Add or move task cards among `#board-todo`, `#board-progress`,
`#board-done`, and `#board-blocked`. A card is
`<div class="board-card">Step name</div>`. Move the element between
containers; do not invent status classes.
4. Save useful screenshots under `report/screenshots/` and reference them
with relative paths such as
`<img src="screenshots/settings-after.png" alt="Settings after update">`.
Put them in `#shots-body`, a relevant log entry, or both. Use meaningful
filenames and alt text.
5. Rewrite `meta.json` with a new ISO-8601 UTC `updated_at` value **last**.
The page polls it every two seconds; touching it before `index.html` is
fully written can trigger a stale or partial reload.
Preserve valid HTML and HTML-escape task text, tracker titles, paths, and log
content. Do not replace the whole report when a targeted block update will
do; the additive protocol leaves useful partial history if an agent stops.
At completion, move the final card to Done, add the verification result to
the log, and replace the current-work block with a concise completion summary
or an honest blocked state. Touch `meta.json` last as usual.
## Use real tracker data or a manual board
If a working issue-tracker connection is already available and real tickets
map to this task, use their real key, title, link, and status. Refresh that
mapping as work advances. Map tracker states conservatively into To do, In
progress, Done, or Blocked.
Otherwise maintain a plain checklist of actual work. Never fabricate tickets
to make the board look populated. If several trackers are plausible and the
choice changes what the board represents, ask which one to use.
## When to run this
- A user explicitly asks for a sitrep panel, live status page, progress
dashboard, screenshots-as-you-go, or a browser view of agent work.
- A long-running delegated or background task needs passive human visibility
without repeated transcript/status queries.
- A multi-stage operational or development session benefits from a stable,
private localhost view shared across session agents and CLI delegates.
## What NOT to do
- Do not start this automatically for every small task; maintaining it has a
real update cost and is valuable mainly for long or explicitly watched work.
- Do not expose the server beyond loopback, tunnel it, upload the report, or
add external assets without explicit authorization. This skill is local and
private by design.
- Do not present it as a durable audit log. Archive preserves a snapshot, but
`.sitrep-panel/` is normally ignored scratch state.
- Do not fabricate status, tracker tickets, screenshots, verification, or
narrative. The panel must reflect work that actually happened.
- Do not forget the final `meta.json` write after a content update, and do not
bury the localhost URL after `start` or `open`.
@@ -0,0 +1,413 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='2' fill='%232f6fed'/%3E%3Cpath d='M4 8a4 4 0 0 1 8 0' stroke='%232f6fed' stroke-width='1.3' stroke-linecap='round' opacity='.55' fill='none'/%3E%3Cpath d='M1.5 8a6.5 6.5 0 0 1 13 0' stroke='%232f6fed' stroke-width='1.3' stroke-linecap='round' opacity='.3' fill='none'/%3E%3C/svg%3E">
<title>{{TITLE}} — sitrep-panel</title>
<style>
:root {
--hue: 250;
--bg: oklch(98% 0.003 var(--hue));
--bg-raised: oklch(99.4% 0.002 var(--hue));
--border: oklch(90% 0.006 var(--hue));
--text: oklch(22% 0.01 var(--hue));
--text-muted: oklch(48% 0.01 var(--hue));
--accent: oklch(58% 0.19 258);
--accent-contrast: oklch(99% 0.005 258);
--code-bg: oklch(95% 0.004 var(--hue));
--status-todo-bg: oklch(94% 0.005 var(--hue)); --status-todo-fg: oklch(45% 0.01 var(--hue));
--status-progress-bg: oklch(93% 0.06 85); --status-progress-fg: oklch(46% 0.13 75);
--status-done-bg: oklch(93% 0.06 155); --status-done-fg: oklch(42% 0.13 155);
--status-blocked-bg: oklch(93% 0.06 25); --status-blocked-fg: oklch(48% 0.17 25);
}
:root[data-theme="dark"] {
--bg: oklch(16% 0.008 var(--hue));
--bg-raised: oklch(21% 0.01 var(--hue));
--border: oklch(31% 0.015 var(--hue));
--text: oklch(93% 0.004 var(--hue));
--text-muted: oklch(66% 0.012 var(--hue));
--accent: oklch(74% 0.15 258);
--accent-contrast: oklch(18% 0.02 258);
--code-bg: oklch(24% 0.012 var(--hue));
--status-todo-bg: oklch(27% 0.01 var(--hue)); --status-todo-fg: oklch(72% 0.012 var(--hue));
--status-progress-bg: oklch(30% 0.06 85); --status-progress-fg: oklch(80% 0.11 85);
--status-done-bg: oklch(28% 0.055 155); --status-done-fg: oklch(75% 0.12 155);
--status-blocked-bg: oklch(30% 0.08 25); --status-blocked-fg: oklch(78% 0.14 25);
}
* { box-sizing: border-box; }
html { color-scheme: light; }
html[data-theme="dark"] { color-scheme: dark; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.brand {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.brand svg { color: var(--accent); }
#theme-toggle {
appearance: none;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-muted);
border-radius: 6px;
width: 30px;
height: 30px;
cursor: pointer;
}
#theme-toggle:hover { color: var(--text); border-color: var(--text-muted); }
#theme-toggle svg { display: block; }
main, .task-header, .section-nav { max-width: 860px; margin: 0 auto; padding-left: 1.25rem; padding-right: 1.25rem; }
.task-header { padding-top: 1.75rem; padding-bottom: 1rem; }
h1 { font-size: 1.6rem; margin: 0 0 0.3rem; letter-spacing: -0.01em; }
.sub { color: var(--text-muted); margin: 0 0 0.6rem; font-size: 0.92rem; max-width: 70ch; }
.updated-badge {
display: inline-block;
font-size: 0.75rem;
color: var(--text-muted);
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.2rem 0.65rem;
}
.section-nav {
position: sticky;
top: 0;
z-index: 5;
display: flex;
gap: 1.25rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
padding-top: 0.6rem;
padding-bottom: 0.6rem;
}
.section-nav a {
color: var(--text-muted);
text-decoration: none;
font-size: 0.85rem;
font-weight: 500;
padding-bottom: 0.5rem;
border-bottom: 2px solid transparent;
}
.section-nav a.active { color: var(--text); border-bottom-color: var(--accent); }
main { padding-top: 1.75rem; padding-bottom: 4rem; }
section { margin-bottom: 2.5rem; scroll-margin-top: 3.5rem; }
h2 { font-size: 1.05rem; margin: 0 0 0.2rem; font-weight: 650; }
h2 .hint { font-weight: 400; color: var(--text-muted); font-size: 0.85rem; }
.section-hint { color: var(--text-muted); font-size: 0.85rem; margin: 0 0 1rem; }
.board { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; }
.board-col-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.72rem;
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.board-col[data-status="progress"] .board-col-head { color: var(--status-progress-fg); }
.board-col[data-status="done"] .board-col-head { color: var(--status-done-fg); }
.board-col[data-status="blocked"] .board-col-head { color: var(--status-blocked-fg); }
.board-col-head .count {
font-weight: 600;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0 0.4rem;
font-size: 0.7rem;
}
.board-col-body { display: flex; flex-direction: column; gap: 0.4rem; min-height: 1.5rem; }
.board-card {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.65rem;
font-size: 0.83rem;
line-height: 1.35;
}
.board-card a { color: inherit; text-decoration: none; }
.board-card a:hover { text-decoration: underline; }
.board-col-body:empty::after {
content: "—";
color: var(--text-muted);
font-size: 0.8rem;
opacity: 0.5;
}
@media (max-width: 720px) {
.board { grid-template-columns: repeat(2, 1fr); }
}
.current-work {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem 1.25rem;
}
.current-work h2, .current-work .section-hint { padding: 0 0 0 0; }
.current-work p { margin: 0.75rem 0 0; max-width: 75ch; }
.current-work p:first-of-type { margin-top: 0; }
.current-work .placeholder { color: var(--text-muted); font-style: italic; }
.current-work code, .entry code {
background: var(--code-bg);
border-radius: 4px;
padding: 0.1rem 0.35rem;
font-size: 0.85em;
}
.shots-body { display: flex; flex-wrap: wrap; gap: 0.75rem; }
.shots-body img {
max-width: 260px;
max-height: 180px;
border-radius: 8px;
border: 1px solid var(--border);
object-fit: cover;
cursor: zoom-in;
}
.shots-body:empty::after {
content: "No screenshots yet.";
color: var(--text-muted);
font-style: italic;
font-size: 0.85rem;
}
.entry { display: grid; grid-template-columns: 20px 1fr; column-gap: 0.75rem; padding-bottom: 1.25rem; }
.entry-rail { position: relative; }
.entry-rail::before {
content: "";
position: absolute;
left: 50%;
top: 1.1rem;
bottom: -1.25rem;
width: 1px;
background: var(--border);
transform: translateX(-50%);
}
.entry:last-child .entry-rail::before { display: none; }
.entry-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--accent);
margin-top: 0.4rem;
}
.entry-time { font-size: 0.75rem; color: var(--text-muted); margin-bottom: 0.15rem; }
.entry-title { font-weight: 600; margin-bottom: 0.35rem; }
.entry-body p { margin: 0 0 0.5rem; max-width: 75ch; }
.entry-body p:last-child { margin-bottom: 0; }
.entry-body img {
max-width: 100%;
border-radius: 8px;
border: 1px solid var(--border);
margin: 0.5rem 0;
}
#entries:empty::after {
content: "No entries yet.";
color: var(--text-muted);
font-style: italic;
}
</style>
</head>
<body>
<div class="topbar">
<div class="brand">
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="8" cy="8" r="2" fill="currentColor"/>
<path d="M4 8a4 4 0 0 1 8 0" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" opacity="0.55"/>
<path d="M1.5 8a6.5 6.5 0 0 1 13 0" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" opacity="0.3"/>
</svg>
sitrep-panel
</div>
<button id="theme-toggle" type="button" aria-label="Switch to dark"></button>
</div>
<header class="task-header">
<h1>{{TITLE}}</h1>
<p class="sub">{{SUBTITLE}}</p>
<span class="updated-badge" id="updated-badge">just started</span>
</header>
<nav class="section-nav" id="section-nav">
<a href="#board-section" data-target="board-section">Board</a>
<a href="#now-section" data-target="now-section">Now</a>
<a href="#shots-section" data-target="shots-section">Screenshots</a>
<a href="#log-section" data-target="log-section" id="nav-log">Log</a>
</nav>
<main>
<section class="board-section" id="board-section">
<h2>Status board</h2>
<p class="section-hint">Where each part of the task stands right now.</p>
<div class="board" id="board">
<div class="board-col" data-status="todo">
<div class="board-col-head">To do <span class="count" id="count-todo">0</span></div>
<div class="board-col-body" id="board-todo"></div>
</div>
<div class="board-col" data-status="progress">
<div class="board-col-head">In progress <span class="count" id="count-progress">0</span></div>
<div class="board-col-body" id="board-progress"></div>
</div>
<div class="board-col" data-status="done">
<div class="board-col-head">Done <span class="count" id="count-done">0</span></div>
<div class="board-col-body" id="board-done"></div>
</div>
<div class="board-col" data-status="blocked">
<div class="board-col-head">Blocked <span class="count" id="count-blocked">0</span></div>
<div class="board-col-body" id="board-blocked"></div>
</div>
</div>
<!-- Card markup, placed in the matching board-todo / board-progress / board-done /
board-blocked container:
<div class="board-card">Step name</div>
Tracker-synced example (real ticket key + link, never fabricated):
<div class="board-card"><a href="https://...">PROJ-123 issue title</a></div>
-->
</section>
<section class="current-work" id="now-section">
<h2>What's happening now</h2>
<p class="section-hint">What's actually happening, in plain language — not just a status word.</p>
<div id="current-work-body">
<p class="placeholder">Nothing yet — this fills in once work starts.</p>
</div>
</section>
<section class="shots" id="shots-section">
<h2>Screenshots</h2>
<p class="section-hint">Visual proof, dropped in as it happens.</p>
<div class="shots-body" id="shots-body"></div>
</section>
<section class="log" id="log-section">
<h2>Log <span class="hint">(newest first)</span></h2>
<p class="section-hint">Everything that's happened, in order.</p>
<div id="entries">
<!-- New entries are PREPENDED here, right after this comment. Example:
<div class="entry">
<div class="entry-rail"><div class="entry-dot"></div></div>
<div>
<div class="entry-time">Jan 1, 00:00</div>
<div class="entry-title">Short title of what happened</div>
<div class="entry-body">
<p>Prose describing what happened and why.</p>
</div>
</div>
</div>
-->
</div>
</section>
</main>
<script>
(function () {
// Theme: manual toggle only, defaults to light. No OS auto-detection —
// this tool is watched in a normal browser tab, not a dim ops room.
var KEY = 'sitrep-panel-theme';
var root = document.documentElement;
var btn = document.getElementById('theme-toggle');
// Real icons, not emoji/dingbat glyphs — glyph support for things like
// ☽/☀ varies enough across fonts/platforms that it can render as tofu.
var SUN = '<svg width="15" height="15" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="3.2" stroke="currentColor" stroke-width="1.3"/><g stroke="currentColor" stroke-width="1.3" stroke-linecap="round"><path d="M8 1v1.5M8 13.5V15M15 8h-1.5M2.5 8H1M12.7 3.3l-1 1M4.3 11.7l-1 1M12.7 12.7l-1-1M4.3 4.3l-1-1"/></g></svg>';
var MOON = '<svg width="15" height="15" viewBox="0 0 16 16" fill="none"><path d="M13.5 9.7A5.8 5.8 0 1 1 6.3 2.5a4.6 4.6 0 0 0 7.2 7.2z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>';
function apply(theme) {
if (theme === 'dark') root.setAttribute('data-theme', 'dark');
else root.removeAttribute('data-theme');
btn.innerHTML = theme === 'dark' ? SUN : MOON;
btn.setAttribute('aria-label', theme === 'dark' ? 'Switch to light' : 'Switch to dark');
}
var saved = null;
try { saved = localStorage.getItem(KEY); } catch (e) { /* private mode etc. */ }
apply(saved === 'dark' ? 'dark' : 'light');
btn.addEventListener('click', function () {
var next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
apply(next);
try { localStorage.setItem(KEY, next); } catch (e) { /* ignore */ }
});
})();
(function () {
// Section nav: highlights whichever section is in view. Nothing is
// hidden — this is wayfinding, not tabs.
var links = Array.prototype.slice.call(document.querySelectorAll('#section-nav a'));
var sections = links.map(function (a) { return document.getElementById(a.dataset.target); }).filter(Boolean);
if (!sections.length || typeof IntersectionObserver === 'undefined') return;
var observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
links.forEach(function (a) { a.classList.toggle('active', a.dataset.target === entry.target.id); });
});
}, { rootMargin: '-45% 0px -50% 0px', threshold: 0 });
sections.forEach(function (s) { observer.observe(s); });
})();
(function () {
// Counts: recomputed on every load (a live-reload IS a fresh load).
function count(id) { var el = document.getElementById(id); return el ? el.children.length : 0; }
['todo', 'progress', 'done', 'blocked'].forEach(function (status) {
var el = document.getElementById('count-' + status);
if (el) el.textContent = count('board-' + status);
});
var navLog = document.getElementById('nav-log');
if (navLog) navLog.textContent = 'Log (' + count('entries') + ')';
})();
(function () {
// Live-reload: poll meta.json every 2s, reload the instant it changes.
var badge = document.getElementById('updated-badge');
var known = null;
function fmtAgo(iso) {
var diff = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return Math.floor(diff) + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
return Math.floor(diff / 3600) + 'h ago';
}
function poll() {
fetch('meta.json?_=' + Date.now(), { cache: 'no-store' })
.then(function (res) { return res.json(); })
.then(function (data) {
if (known === null) known = data.updated_at;
if (data.updated_at !== known) { location.reload(); return; }
badge.textContent = 'updated ' + fmtAgo(data.updated_at);
})
.catch(function () { /* server briefly restarting between writes — ignore */ });
}
poll();
setInterval(poll, 2000);
})();
</script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Serve a sitrep-panel report directory on a free localhost port.
Usage: python3 serve.py <directory> [preferred_port]
Prints exactly one line to stdout on success: `SERVING http://localhost:<port>/`
then blocks, running the HTTP server — launch this with the caller's
background/detached mechanism (e.g. Claude Code's Bash tool with
run_in_background: true). Stdlib only, no dependencies.
"""
from __future__ import annotations
import functools
import http.server
import sys
class QuietHandler(http.server.SimpleHTTPRequestHandler):
"""SimpleHTTPRequestHandler that suppresses per-request access logging,
so stdout stays to the one clean SERVING line callers parse."""
def log_message(self, format: str, *args: object) -> None: # noqa: A002
pass
def main() -> None:
if len(sys.argv) < 2:
print("usage: serve.py <directory> [preferred_port]", file=sys.stderr)
sys.exit(1)
directory = sys.argv[1]
preferred_port = int(sys.argv[2]) if len(sys.argv) > 2 else 8934
handler = functools.partial(QuietHandler, directory=directory)
try:
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", preferred_port), handler)
except OSError:
# Preferred port is taken — let the OS assign a free one rather than
# guessing and racing another process for it.
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
port = httpd.server_address[1]
print(f"SERVING http://localhost:{port}/", flush=True)
httpd.serve_forever()
if __name__ == "__main__":
main()