Harbor as of 2026-10-05: the coordination hub for the Open Sea's decomp team

Messages, threads, acknowledgements, claims, bands, tasks, review items and
stop flags for a team of AI agents; the chat page and board; host hooks and
MCP server; autopilot; tools/watch.py. Put under version control after the
weekend's changes so they have history and can be rolled back. Local only.
This commit is contained in:
KeelrLeek committed 2026-10-05 10:44:23 -05:00
commit dbfd1720c5
43 files changed
+12440

No files matched your search

+554
View File
@@ -0,0 +1,554 @@
"""Harbor's local web front: the team chat page and the coordination board, for every registered project.
harbor serve --port 8766 (or: py -3.12 -B harbor_server.py 8766)
/ chat for the default project /board its board
/p/<project>/ chat for another project /p/<project>/board
Only this computer can reach it (127.0.0.1, Host header checked). Messages written here are the only ones Harbor
accepts from the human. A browser POST must carry the page's per-start token and the browser's Sec-Fetch-Site
header, so a script on this machine cannot speak as the human by accident. A program that forges all of that on
purpose is breaking the team's rules; this is not a security boundary against software running as the same user.
"""
import argparse
import http.server
import json
import re
import secrets
import sys
import threading
import time
import traceback
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlsplit
from . import projects
from .core import Harbor, HarborError
WEB = Path(__file__).resolve().parent / "web"
# The files pages load from static/: content type and how long a browser may keep them (seconds).
STATIC = {"harbor.css": ("text/css; charset=utf-8", 60), "themes.css": ("text/css; charset=utf-8", 60),
"themes.js": ("text/javascript; charset=utf-8", 60), "icons.svg": ("image/svg+xml", 60),
"InterVariable.woff2": ("font/woff2", 86400)}
TOKEN = secrets.token_urlsafe(24)
LEGACY_LIMIT = 800
BACKUP_HOURS = 6
class Registry:
"""One Harbor (shared connection + lock) per project."""
def __init__(self, primary):
self.primary = primary
self.lock = threading.Lock()
self.open = {}
def names(self):
names = projects.list_names()
return [self.primary.name] + [n for n in names if n != self.primary.name]
def get(self, name=None):
name = name or self.primary.name
with self.lock:
if name not in self.open:
project = self.primary if name == self.primary.name else projects.load_named(name)
self.open[name] = (Harbor(project, shared=True), threading.Lock())
h, lock = self.open[name]
with lock:
h.refresh_project() # project-file edits apply without a restart
return h, lock
REGISTRY = None
PORT = 8766
def chat_shape(m):
"""A message as the pages read it (the old {time, from, to, text} plus Harbor's fields)."""
recipients = m["recipients"]
return {"id": m["id"], "time": m["created_at"].replace("T", " ")[:16] + "Z", "at": m["created_at"],
"from": m["sender"], "to": recipients[0] if len(recipients) == 1 else ", ".join(recipients),
"recipients": recipients, "text": m["body"], "topic": m["topic"], "subject": m["subject"],
"kind": m["kind"], "importance": m["importance"], "ack": m["ack_required"], "origin": m["origin"],
"reply_to": m.get("reply_to"), "attachments": safe_attachments(m.get("refs", {}).get("attachments")),
"corrects": m["corrects"] if isinstance(m.get("corrects"), int) else None, "due_at": m.get("due_at")}
def safe_attachments(value):
"""Only stored attachment names reach the page (anything else in old data is dropped)."""
if not isinstance(value, list):
return []
return [v for v in value if isinstance(v, str) and re.fullmatch(r"[0-9a-f]{12}-[A-Za-z0-9._-]{1,80}", v)]
def shaped(h, rows):
"""chat_shape for a batch, plus which of them were corrected later and which are pinned."""
out = [chat_shape(m) for m in rows]
ids = [m["id"] for m in out]
corrected = h.corrections_for(ids) if ids else {}
pinned = {m["id"] for m in h.pins(limit=500)}
asked = h.questions_by_message(ids) if ids else {}
for m in out:
if m["id"] in corrected:
m["corrected_by"] = corrected[m["id"]]
if m["id"] in pinned:
m["pinned"] = True
q = asked.get(m["id"])
if q:
m["question"] = {"id": q["id"], "to": q["to_name"], "options": q["options"], "answer": q["answer"],
"answered_by": q["answered_by"]}
return out
GIT = {} # project name -> {"at": monotonic, "radar": {...}}
RUNNING = {} # agent name -> number of Harbor MCP server processes seen running (refreshed every minute)
SEEN_WORK = {}
def note_worktree_work(h, project, agent, git):
"""Record activity when the agent changed a file or committed in the last 3 minutes (once per change)."""
now = time.time()
found = []
change = h.worktree_change(agent)
if change and now - change[0] < 180:
found.append((change[0], "edit", change[1]))
commit = (git or {}).get("last_commit") or {}
if commit.get("at"):
from .util import parse_iso
at = parse_iso(commit["at"]).timestamp()
if now - at < 180:
found.append((at, "commit", f"{commit.get('sha', '')} {commit.get('subject', '')}"[:120]))
for stamp, tool, target in found:
key = (project, agent, tool)
if SEEN_WORK.get(key, 0) < stamp:
SEEN_WORK[key] = stamp
h.activity(agent, tool, "", target=target, channel="files")
def git_loop():
"""Refresh every project's git radar in the background (git can be slow; requests only read the cache)."""
while True:
try:
from .doctor import running_mcp_servers
fresh = running_mcp_servers() # compute first, then swap: no empty moment
RUNNING.clear()
RUNNING.update(fresh)
except Exception:
traceback.print_exc()
for name in REGISTRY.names():
try:
shared, _ = REGISTRY.get(name)
agents = [a for a in shared.project.agents if a["kind"] != "human" and a.get("workdir")]
if not agents:
continue
own = Harbor(shared.project) # its own connection: git never holds the request lock
try:
radar = {a["name"]: own.git_status(a["name"], max_age=50) for a in agents}
GIT[name] = {"at": time.monotonic(), "radar": radar}
for a in agents: # fresh edits and commits are work, hooks or not
try:
note_worktree_work(own, name, a["name"], radar.get(a["name"]) or {})
except Exception: # noqa: BLE001 - a broken worktree must not stop the loop
traceback.print_exc()
finally:
own.close()
except Exception:
traceback.print_exc()
time.sleep(60)
def toast_loop():
"""Windows notifications for the human (when switched on for a project)."""
from . import notify
while True:
time.sleep(10)
for name in REGISTRY.names():
try:
h, lock = REGISTRY.get(name)
with lock:
if not notify.enabled(h):
continue
pending, top = notify.pending_for_human(h)
notify.mark(h, top)
for m in pending:
title = (f"{m['sender']} asks you" if m["kind"] == "question" else
f"{m['sender']} (urgent)" if m["importance"] == "urgent" else m["sender"])
notify.toast(f"{title} · {h.project.title}", m["body"].split("\n", 1)[0])
except Exception:
traceback.print_exc()
def git_attention(h, radar):
out = []
for agent, g in (radar or {}).items():
if not g or g.get("error") or h.stopped(agent):
continue
waiting = g.get("unsubmitted") or []
if len(waiting) >= 3 and h.minutes_since(waiting[-1]["at"]) >= 120:
out.append({"kind": "unsubmitted", "agent": agent, "minutes": h.minutes_since(waiting[-1]["at"]),
"text": f"{agent} has {len(waiting)} commits on {g['branch']} since its last submit "
f"(oldest {h.minutes_since(waiting[-1]['at']) // 60} h ago) - nothing in the review queue"})
if (g.get("behind_main") or 0) >= 100:
out.append({"kind": "old_base", "agent": agent, "minutes": 0,
"text": f"{agent}'s branch {g['branch']} is {g['behind_main']} commits behind main"})
return out
def overview(h):
agents = []
links = h.connections()
hosts = {a["name"]: a.get("host", "") for a in h.project.agents}
for a in h.agents():
unread, urgent = h.unread_count(a["name"])
handoff = h.last_handoff(a["name"]) if a["kind"] != "human" else None
agents.append({**a, "unread": unread, "urgent": urgent, "idle_minutes": h.minutes_since(a["last_seen"]),
"doing_minutes": h.minutes_since(a.get("doing_at")), "host": hosts.get(a["name"], ""),
"working": h.working(a), "active_minutes": h.minutes_since(a.get("active_at")),
"last_target": (h.activity_feed(limit=1, agent=a["name"]) or [{}])[0].get("target"),
"trial_until": h.trial(a["name"]) if a["kind"] != "human" else None,
"connections": links.get(a["name"], []),
"handoff": {"at": handoff["created_at"], "minutes": h.minutes_since(handoff["created_at"]),
"text": handoff["body"][:600]} if handoff else None,
"stopped": [f["key"] for f in h.stopped(a["name"])] if a["kind"] != "human" else []})
queue = [{**i, "age_minutes": h.minutes_since(i["created_at"]), "idle_minutes": h.minutes_since(i["updated_at"])}
for i in h.items()]
claims = [{**c, "age_minutes": h.minutes_since(c["created_at"])} for c in h.active_claims()]
acks = [{**r, "body": r["body"].split("\n", 1)[0][:160], "age_minutes": h.minutes_since(r["created_at"])}
for r in h.pending_acks()]
attention = h.attention() + git_attention(h, (GIT.get(h.project.name) or {}).get("radar"))
return {"now": h.now(), "project": h.project.public(), "flags": list(h.flags().values()), "agents": agents,
"queue": queue, "claims": claims, "threads": h.threads(limit=30, agent=h.human),
"pending_acks": acks, "attention": attention, "projects": REGISTRY.names(),
"presence": h.presence(), "tasks": h.tasks(), "pins": [chat_shape(m) for m in h.pins(limit=10)],
"questions": h.questions(open_only=True, to=h.human), "collisions": h.recent_collisions(),
"metrics": h.metrics(points=40), "wins": h.wins("24h"),
"git": (GIT.get(h.project.name) or {}).get("radar") or {},
"templates": list(h.project.templates), "toasts": __import__("harbor.notify", fromlist=["x"]).enabled(h)}
def address_mentions(h, to, text):
"""'@Codex @Kimi please look' sent to All goes to Codex and Kimi."""
if to not in (None, "", "All"):
return to
names = []
for word in text.split():
m = re.fullmatch(r"@([A-Za-z][A-Za-z0-9_-]*)[,:]?", word)
if not m:
break
match = next((n for n in h.project.names if n.lower() == m.group(1).lower()), None)
if not match:
break
names.append(match)
return names or to
class Handler(http.server.BaseHTTPRequestHandler):
server_version = "Harbor/2"
# ------------------------------------------------------------------ plumbing
def _reply(self, code, body, kind="application/json; charset=utf-8", extra=None):
data = body if isinstance(body, bytes) else (
body.encode("utf-8") if isinstance(body, str) else json.dumps(body, ensure_ascii=False, default=str).encode("utf-8"))
self.send_response(code)
self.send_header("Content-Type", kind)
self.send_header("Content-Length", str(len(data)))
extra = dict(extra or {})
self.send_header("Cache-Control", extra.pop("Cache-Control", "no-store"))
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "no-referrer")
for k, v in extra.items():
self.send_header(k, v)
self.end_headers()
self.wfile.write(data)
def _host_ok(self):
return self.headers.get("Host", "") in (f"127.0.0.1:{PORT}", f"localhost:{PORT}")
def _route(self):
"""-> (project name or None, path inside the project, query dict)."""
url = urlsplit(self.path)
path = url.path
name = None
m = re.fullmatch(r"/p/([a-z0-9][a-z0-9._-]{0,63})(/.*)?", path)
if m:
name, path = m.group(1), m.group(2) or "/"
return name, path, {k: v[-1] for k, v in parse_qs(url.query).items()}
def _page(self, filename, project_name):
text = (WEB / filename).read_text(encoding="utf-8")
base = f"/p/{project_name}/" if project_name else "/"
text = text.replace("<head>", f'<head>\n<meta name="harbor-token" content="{TOKEN}">\n<base href="{base}">', 1)
self._reply(200, text, "text/html; charset=utf-8")
def _body(self):
length = int(self.headers.get("Content-Length") or 0)
if length > 100_000:
raise HarborError("request too large")
try:
return json.loads(self.rfile.read(length) or b"{}")
except ValueError:
raise HarborError("the request body is not JSON")
def _from_page(self):
origin = self.headers.get("Origin")
if origin and origin not in (f"http://127.0.0.1:{PORT}", f"http://localhost:{PORT}"):
return "wrong origin"
if self.headers.get("Sec-Fetch-Site") != "same-origin":
return "not sent by the chat page in a browser"
if not secrets.compare_digest(self.headers.get("X-Harbor-Token", ""), TOKEN):
return "stale page; reload it"
if not (self.headers.get("Content-Type") or "").startswith("application/json"):
return "JSON only"
return None
def log_message(self, *args):
pass
# ------------------------------------------------------------------ GET
def do_GET(self):
if not self._host_ok():
return self._reply(403, {"error": "unknown host"})
name, path, query = self._route()
try:
if name and name not in REGISTRY.names():
return self._reply(404, {"error": f"no project {name}"})
if path.startswith("/static/") and path[8:] in STATIC:
kind, age = STATIC[path[8:]]
return self._reply(200, (WEB / path[8:]).read_bytes(), kind, {"Cache-Control": f"max-age={age}"})
if path == "/":
return self._page("app.html", name)
if path == "/board":
return self._page("app.html", name)
if path == "/api/projects":
out = []
for n in REGISTRY.names():
h, lock = REGISTRY.get(n)
with lock:
out.append({"name": n, "title": h.project.title, "unread": h.unread_count(h.human)[0],
"stopped": any(h.stopped(a) for a in h.project.ai_names)})
return self._reply(200, out)
h, lock = REGISTRY.get(name)
if path.startswith("/files/"):
filename = unquote(path[len("/files/"):])
if not re.fullmatch(r"[0-9a-f]{12}-[A-Za-z0-9._-]{1,80}", filename):
return self._reply(404, {"error": "no such file"})
target = h.project.attachments / filename
if not target.is_file():
return self._reply(404, {"error": "no such file"})
return self._reply(200, target.read_bytes(), "application/octet-stream",
{"Content-Disposition": f'attachment; filename="{filename[13:]}"'})
with lock:
if path == "/participants":
return self._reply(200, [{"name": a["name"], "initials": a["initials"], "color": a["color"],
"role": a["role"], "kind": a["kind"], "doing": a.get("doing"),
"last_seen": a["last_seen"]} for a in h.agents()])
if path == "/messages":
after = int(query.get("after", 0))
before = int(query.get("before", 0))
rows = (h.messages_after(after) if after else h.messages_before(before) if before
else h.recent(LEGACY_LIMIT))
return self._reply(200, shaped(h, rows))
if path == "/api/receipts":
ids = [int(i) for i in query.get("ids", "").split(",") if i.strip().isdigit()][:200]
return self._reply(200, h.receipts(ids))
if path == "/api/overview":
return self._reply(200, overview(h))
if path == "/api/thread":
thread, messages = h.thread(query.get("id", ""))
return self._reply(200, {"thread": thread, "messages": shaped(h, messages),
"summary": h.summary(thread["id"])})
if path == "/api/tasks":
from .work import OPEN_TASK_STATES, TASK_STATES
return self._reply(200, h.tasks(states=TASK_STATES if query.get("all") == "1" else OPEN_TASK_STATES))
if path == "/api/task":
task, events = h.task(query.get("id", "0"))
return self._reply(200, {"task": task, "events": events})
if path == "/api/prompt":
return self._reply(200, {"agent": query.get("agent"), "prompt": h.restart_prompt(query.get("agent", ""))})
if path == "/api/handoffs":
return self._reply(200, h.handoffs(agent=query.get("agent") or None, limit=int(query.get("limit", 10))))
if path == "/api/pins":
return self._reply(200, {"pins": shaped(h, h.pins()), "decisions": shaped(h, h.decisions())})
if path == "/api/launch":
agents = h.launch_status()
for a in agents:
if RUNNING.get(a["agent"]) and not a["connected"]:
a["connected"] = True
a["connected_via"] = a["connected_via"] + ["Harbor tools running in its app"]
return self._reply(200, {"agents": agents, "rollcall": h.rollcall_status(), "presence": h.presence()})
if path == "/api/feed":
return self._reply(200, h.activity_feed(limit=min(int(query.get("limit", 60)), 300),
agent=query.get("agent") or None))
if path == "/api/pulse":
return self._reply(200, h.pulse(hours=min(int(query.get("hours", 12)), 72)))
if path == "/api/context":
return self._reply(200, {"text": h.context_text(query.get("scope", ""), limit=20),
**{k: v for k, v in h.context(query.get("scope", "")).items()
if k in ("scope", "notes", "tasks", "items")}})
if path == "/api/export":
text = h.export_thread(query.get("thread", ""))
name = re.sub(r"[^A-Za-z0-9._-]+", "_", query.get("thread", "thread"))[:60] or "thread"
return self._reply(200, text, "text/markdown; charset=utf-8",
{"Content-Disposition": f'attachment; filename="{name}.md"'})
if path == "/api/doctor":
from . import doctor
return self._reply(200, doctor.run(h.project, h, port=PORT, server_known=True))
if path == "/api/threads":
return self._reply(200, h.threads(limit=int(query.get("limit", 60)), agent=h.human,
include_legacy=query.get("archive") == "1"))
if path == "/api/search":
return self._reply(200, shaped(h, h.search(query.get("q", ""), limit=60)))
if path == "/api/item":
item, events = h.item(query.get("id", "0"))
return self._reply(200, {"item": item, "events": events})
if path == "/api/inbox":
return self._reply(200, [chat_shape(m) for m in h.inbox(h.human, unread_only=True, limit=100,
mark_read=False)])
if path == "/api/digest":
d = h.digest(query.get("since", "8h"))
d["to_human"] = [chat_shape(m) for m in d["to_human"]]
return self._reply(200, d)
except (HarborError, projects.ProjectError) as error:
return self._reply(400, {"error": str(error)})
except Exception:
traceback.print_exc()
return self._reply(500, {"error": "server error; see the server log"})
self._reply(404, {"error": "not found"})
# ------------------------------------------------------------------ POST
def do_POST(self):
if not self._host_ok():
return self._reply(403, {"error": "unknown host"})
refused = self._from_page()
if refused:
return self._reply(403, {"error": refused})
name, path, _ = self._route()
try:
if name and name not in REGISTRY.names():
return self._reply(404, {"error": f"no project {name}"})
payload = self._body()
h, lock = REGISTRY.get(name)
with lock:
if path in ("/send", "/api/send"):
text = str(payload.get("text", ""))
to = payload.get("to") or (None if payload.get("reply_to") else "All")
if isinstance(to, str) and "," in to:
to = [n.strip() for n in to.split(",") if n.strip()]
to = address_mentions(h, to, text)
result = h.send(h.human, to, text, subject=payload.get("subject"),
thread=payload.get("thread") or None, kind=payload.get("kind") or "note",
importance=payload.get("importance") or "normal",
ack_required=bool(payload.get("ack")), reply_to=payload.get("reply_to"),
origin="web")
return self._reply(200, {"ok": True, **result})
if path == "/api/flag":
target = str(payload.get("target", ""))
key = "stop:all" if target in ("all", "All") else f"stop:{target}"
flags = h.set_flag(key, str(payload.get("value", "")), h.human, "web",
reason=payload.get("reason") or None, until=payload.get("until") or None)
return self._reply(200, {"ok": True, "flags": list(flags.values())})
if path == "/api/ack":
done = h.ack(h.human, [int(i) for i in payload.get("ids", [])], note=payload.get("note"))
return self._reply(200, {"ok": True, "acked": done})
if path == "/api/read":
h.mark_read(h.human, payload.get("ids"))
return self._reply(200, {"ok": True})
if path == "/api/rollcall":
return self._reply(200, {"ok": True, "rollcall": h.rollcall(h.human, "web",
payload.get("agents") or None)})
if path == "/api/bulk":
r = h.bulk_tasks(h.human, str(payload.get("text", "")), template=payload.get("template") or None,
priority=payload.get("priority") or "normal",
assignee=payload.get("assignee") or None, origin="web")
return self._reply(200, {"ok": True, "created": len(r["created"]), "skipped": r["skipped"]})
if path == "/api/toasts":
from . import notify
notify.set_enabled(h, bool(payload.get("on")))
if payload.get("on"):
threading.Thread(target=notify.toast, args=("Harbor", f"Notifications are on for "
f"{h.project.title}: questions, urgent messages and messages to you."),
daemon=True).start()
return self._reply(200, {"ok": True, "on": notify.enabled(h)})
if path == "/api/answer":
q = h.answer(h.human, int(payload.get("question", 0)), str(payload.get("answer", "")), origin="web")
return self._reply(200, {"ok": True, "question": q})
if path == "/api/presence":
return self._reply(200, {"ok": True, "presence": h.set_presence(
h.human, "web", str(payload.get("status", "here")), payload.get("until") or None,
payload.get("note") or None)})
if path == "/api/ping":
agent = str(payload.get("agent", ""))
if agent not in h.project.ai_names:
raise HarborError(f"no agent {agent!r}")
r = h.send(h.human, [agent], "Are you there? Please acknowledge this message so I know you are "
"running and reading Harbor.", thread="ping", subject="Ping", kind="question",
importance="urgent", ack_required=True, origin="web")
return self._reply(200, {"ok": True, **r})
if path == "/api/pin":
pins = (h.unpin(h.human, int(payload["id"])) if payload.get("unpin")
else h.pin(h.human, int(payload["id"]), payload.get("note") or None))
return self._reply(200, {"ok": True, "pins": len(pins)})
if path == "/api/task":
action = payload.get("action")
if action == "add":
task = h.add_task(h.human, str(payload.get("title", "")), body=payload.get("body") or None,
scope=payload.get("scope") or None, assignee=payload.get("assignee") or None,
priority=payload.get("priority") or "normal",
after=[int(x) for x in payload.get("after") or []],
due=payload.get("due") or None, origin="web")
else:
task = h.update_task(h.human, int(payload.get("id", 0)), str(payload.get("state", "")),
note=payload.get("note") or None, origin="web")
return self._reply(200, {"ok": True, "task": task})
if path == "/api/thread-state":
t = h.set_thread_state(h.human, payload.get("thread"), payload.get("state", "closed"))
return self._reply(200, {"ok": True, "thread": t})
except (HarborError, projects.ProjectError) as error:
return self._reply(400, {"ok": False, "error": str(error)})
except Exception:
traceback.print_exc()
return self._reply(500, {"ok": False, "error": "server error; see the server log"})
self._reply(404, {"error": "not found"})
def backup_loop():
"""Snapshot every project's database at start and every BACKUP_HOURS hours."""
while True:
for name in REGISTRY.names():
try:
h, lock = REGISTRY.get(name)
newest = max(h.project.backups.glob("harbor-*.db"), default=None)
if newest is None or time.time() - newest.stat().st_mtime > BACKUP_HOURS * 3600:
with lock:
h.backup()
except Exception:
traceback.print_exc()
time.sleep(1800)
def main(argv=None):
global REGISTRY, PORT
parser = argparse.ArgumentParser(prog="harbor serve")
parser.add_argument("port", nargs="?", type=int)
parser.add_argument("--port", dest="port_option", type=int)
parser.add_argument("--project")
args = parser.parse_args(argv)
PORT = args.port_option or args.port or 8766
REGISTRY = Registry(projects.resolve(args.project))
REGISTRY.get()
server = http.server.ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
server.daemon_threads = True
threading.Thread(target=backup_loop, daemon=True).start()
threading.Thread(target=git_loop, daemon=True).start()
threading.Thread(target=toast_loop, daemon=True).start()
print(f"Harbor on http://127.0.0.1:{PORT} - projects: {', '.join(REGISTRY.names())}", flush=True)
server.serve_forever()
if __name__ == "__main__":
try:
main()
except (HarborError, projects.ProjectError) as error:
sys.exit(f"harbor serve: {error}")