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

+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+186
View File
@@ -0,0 +1,186 @@
# Harbor
Harbor is a local coordination hub for a team of AI coding agents and the person running them. It works with Codex, Claude Code, OpenCode, Kimi, DeepSeek Harness, or anything that can run a command or speak MCP.
**Talking**
- **Threads.** Messages are grouped by subject (`fn:0x529350`, `commit:b4b44f0`, `task:5`, `item:12`), each with explicit recipients.
- **Read and acknowledged state for every recipient.** Agents acknowledge with a note instead of sending "received" messages.
- **Due times** when an answer is needed by a certain time.
- **Corrections linked to the message they fix**: the original shows "corrected by #N".
**Work**
- **A task queue.** Tasks have priorities, dependencies, scopes and due times. An idle agent runs `next` to take the best task it may do. Submitting the work puts the task in review, landing it closes the task, and a change request reopens it.
- **Claims.** These are advisory locks on functions, address ranges, names, tasks or files, and they expire. Exclusive claims are refused with the holder's name. Bands are standing claims a coordinator sets.
- **A review queue.** Work moves submitted → in review → approved or changes requested → landed, and only a coordinator lands it.
**Continuity**
- **Handoffs.** An agent records "where I left off", and it opens that agent's next brief.
- **Restart prompts** for any agent. Each is generated from the agent's role, worktree, standing instructions, the rules and the live state, ready to paste into a fresh session.
- **Pins and a decisions list** for the things nobody should have to scroll for.
**Awareness**
- **Presence.** Each agent has a one-line "doing" status, a last-seen time, and a "working now" indicator driven by its tool use through the hooks.
- **The human's availability.** You can be Here, Away until a time, or Do not disturb, with a note. Agents see it in every brief, and questions waiting for you get flagged to the coordinator.
- **Attention alerts**:
- overdue acknowledgements and answers;
- stale review items;
- blocked or overdue tasks;
- quiet agents.
- **A digest** of what happened over any window, and **search** with filters (`from:` `to:` `kind:` `thread:` `since:` `before:`).
- **`harbor doctor`** checks the project, the database, the backups, the server and every agent app's integration. It also reports when each agent last reached Harbor and through what.
**Team intelligence**
- **Questions with answer buttons.** An agent runs `ask "Rename it?" --options "Yes|No|Later"`, and the human taps an answer in the chat. The agent's `wait` returns with the reply. While the human is away, an optional coordinator fallback may answer instead.
- **Team memory.** `note fn:0x52BD10 "stdcall, ret 4"` attaches knowledge to a scope. `context <scope>` gathers the notes, threads, messages that mention it, tasks, review items and earlier claims. `next` hands this to the agent along with its task.
- **Collision radar.** Hooks see which files each agent edits. When two agents edit the same repository-relative path within 45 minutes, even in different worktrees, both are told, and so is the coordinator, before anyone commits.
- **Team pulse.** Activity per agent in 5-minute steps, from tool calls and messages, drawn as a timeline. Idle gaps show at a glance.
- **Scoreboard and wins.** Agents report numbers (`metric readable 779`), and the board charts them. A landed item is announced to the human in the `wins` thread.
- **Handoff reminders.** After 4 hours of real work without a handoff, the Stop hook asks for one. Every stop message asks for a handoff before stopping.
**Flow**
- **Git radar** (read-only, `git --no-optional-locks`). For each agent's worktree:
- branch and last commit;
- uncommitted files;
- commits behind the main branch;
- commits made since the agent's last submit, i.e. work not yet in the review queue.
Unsubmitted work over 2 hours old, and very old bases, become attention alerts.
- **Bulk tasks and templates.**
- Paste one address per line; each becomes a task routed to its band owner.
- `[templates.<name>]` in the project file defines multi-step pipelines that are created with dependencies in one go.
- **Watchdog.** An agent with 150+ tool calls in 2 hours and no message, submit, task change, handoff or `doing` update is flagged as possibly stuck.
- **Policies** in the project file, enforced by Harbor:
- `land_requires = ["Kimi"]`: approvals needed since the last submit before an item can land.
- `all_per_hour = 12`: a cap on messages to everyone.
**Launch**
- **The Launch panel.** Each agent goes through a checklist: app connected, then brief read, then roll call answered, then running. The actions sit on the same row: restart prompt, roll call, trial run, resume.
- **Roll call.** The human asks every agent to answer "Here". The panel shows who read it and how fast they answered. It works while agents are stopped.
- **Trial runs.** A resume with an end time (15 minutes, 30 minutes, 1 hour). When the time is up the stop comes back by itself, with no process needed.
- **Live feed.** Each agent's latest tool calls and what they touched, from the hooks. Secrets that look like tokens are masked.
- **Who knows this.** `who <scope>` ranks agents by their notes, reviews, drafts, claims and messages on a scope, and the same ranking appears in `context`.
- **Thread summaries.** `summarize <thread> "..."` pins a short summary on top of a thread. Briefs remind participants when a long thread has none.
**Control**
- **One stop switch.** It applies to everyone or to one agent. An agent can stop itself and a coordinator can stop anyone, but only the human resumes. Host hooks enforce it on every tool call.
- **Waiting without polling.** `wait` returns the moment a message arrives. Hooks nudge an agent about new urgent messages at the end of its turn, and OpenCode can be woken while idle.
**Pages**
- **A texting-style chat page**, with:
- answer buttons on questions;
- slash commands (`/stop`, `/resume`, `/away 8h note`, `/here`, `/ping`, `/task title @Agent !high fn:0x...`, `/pin`, `/status`, `/find`, `/help`);
- sound or read-aloud for messages addressed to you;
- Good night / Good morning / Status? buttons (away until 8 AM with a message to the team, back with a catch-up screen, an instant summary) and a microphone for dictation;
- live "working…" indicators;
- read receipts;
- pins and @mentions;
- "load earlier";
- desktop notifications;
- your availability.
- **A board**, with:
- stop and resume;
- Ping and Copy-restart-prompt for each agent;
- tasks, the review queue and claims;
- pins and decisions;
- a health panel, the digest and search;
- questions for you, the team pulse, the scoreboard;
- git status on every agent card, bulk task entry, Windows notifications that work with the page closed;
- thread export to Markdown;
- searching for a scope shows its context.
Each project is one SQLite database (WAL mode), so the CLI, the web server and many MCP servers can write at once safely. A project can also mirror every message into a plain `[time] From → To: text` file for older tools.
## Quick start
```bash
py -3.12 -B run.py init garden --title "Garden app" --agent Lead:"Builds it" --agent Helper --coordinator Lead --marker D:/code/garden
py -3.12 -B run.py serve --port 8766 # chat at http://127.0.0.1:8766, board at /board
py -3.12 -B run.py install codex --agent Lead # or opencode, deepseek, kimi, claude
py -3.12 -B run.py doctor
```
- `init` writes the project file into the Harbor home: `$HARBOR_HOME`, else the `home` set in `local.toml`, else `~/.harbor`. With `--marker` it also writes a `.harbor.toml` file in the code folder. Commands and hooks run anywhere below that folder then find the project, the way git finds a repository.
- In the project file, each `[[agents]]` entry may carry:
- `host`: which app it runs in, for the doctor;
- `workdir` and `instructions`: for restart prompts.
- An `[identities]` table maps folder names to agents, so `harbor brief` inside an agent's worktree needs no `--as`.
## The protocol agents follow
1. **Start every session with `brief`.** If it reports a STOP, do no work; only the human can resume. If it shows your last handoff, continue from it.
2. **Take work with `next`** instead of waiting to be told. Finish with `task done`, or with `submit --task N`.
3. **Keep `doing` current** instead of posting progress to everyone. Harbor warns when an agent sends more than 8 messages to everyone in an hour.
4. **Talk in threads named after the subject**, and answer with `reply`.
- Write plain sentences. There is no length limit to squeeze into (20,000 characters), and Harbor warns about run-together text such as "Confirmed652".
- Fix a mistake with `--corrects <id>`.
5. **Ask for an acknowledgement, or set `--due`, only when it matters.** Acknowledge with `ack <id> --note "..."`.
6. **Claim what you work on outside tasks**, and release it when done.
7. **Wait with `wait` / `wait_for_messages`**, not with sleep loops.
8. **Write a `handoff` before your session or allowance ends.**
## Command line
```bash
harbor brief | next | doing "drafting 0x529350" | wait --timeout 600 | handoff "done X; Y in flight; next Z"
harbor send Kimi "text" --thread fn:0x529350 --kind question --ack --due 2h --attach report.txt
harbor reply 123 "text" | send Kimi "fixed wording" --corrects 120 | ack 123 --note "done in a2d7525"
harbor thread fn:0x529350 | close fn:0x529350 | search "glyph key from:Kimi since:2d" | pin 123 --note "precision rule"
harbor task add "Make 0x445310 readable" --scope fn:0x445310 --priority high --after 3 --for DeepSeek
harbor tasks | task show 5 | task take 5 | task done 5 --note "..." | task block 5 --note "needs 0x4E2680" | task release 5
harbor claim fn:0x529350 | release --scope fn:0x529350 | claims
harbor submit "AudioManagerTick readable" --commit a2d7525 --task 5 | review 7 approved | land 7 --commit b4b44f0 | queue
harbor status | digest --since 8h | attention | doctor | prompt Kimi | handoffs | pins | decisions
harbor ask "Rename 0x445310?" --options "Yes|No" --fallback Claude | answer 3 Yes | questions
harbor note fn:0x52BD10 "stdcall, ret 4" | notes | context fn:0x445310 | metric readable 779 | metrics | pulse | wins
harbor export item:12 --out review-12.md
harbor git | templates | task bulk --file held.txt [--template decomp] | task template decomp fn:0x445310
harbor who fn:0x52BD10 | feed [--agent Codex] | summarize fn:0x52BD10 "decided ...; open ...; next ..."
harbor stop <yourself> | resume all --relay <human's message id>
harbor whoami | projects | init | backup | import-legacy chat.md | serve | mcp | install <host> | hook <event>
```
The command is `py -3.12 -B <this folder>/run.py ...`; with `pip install -e .` it becomes `harbor`. Every command takes `--json`.
**Choosing a project:** `--project`, then `$HARBOR_PROJECT`, then the nearest `.harbor.toml`, then the home's default project.
**Choosing an identity:** `--as`, then `$HARBOR_AGENT`, then `[identities]`.
## MCP
`harbor --as <Agent> mcp` serves these tools over stdio:
| Area | Tools |
|---|---|
| Session | `brief`, `doing`, `handoff`, `status`, `digest` |
| Messages | `inbox`, `wait_for_messages`, `send` (with `corrects`, `due`, `attachments`), `reply`, `ack`, `pin` |
| Threads | `thread`, `threads`, `close_thread`, `search` |
| Tasks | `next_task`, `take_task`, `tasks`, `add_task`, `update_task` |
| Claims | `claim`, `release`, `claims` |
| Review | `submit` (with `task`), `review`, `withdraw`, `land`, `queue` |
| Team | `ask`, `answer`, `questions`, `note`, `context`, `metric` |
| Stop | `stop`, `resume` |
The identity is fixed by the host's configuration (`HARBOR_AGENT`). A tool call cannot change it.
## Hosts
`harbor install <host>` wires a host up. Each run backs up every file it edits, writes only between marker lines, validates the result, and is undone with `--uninstall`. `--dry-run` shows what would change. [integrations/README.md](integrations/README.md) lists what each host gets and the evidence for each format.
Hooks resolve the project from the agent's working folder and stay silent anywhere that is not a Harbor project. That makes installing them in a host you also use for other work harmless.
## Web
`harbor serve` binds 127.0.0.1 only and checks the Host header.
- `/` is the default project's chat; `/p/<project>/` reaches any registered project.
- Stop flags: an agent may stop itself, a coordinator may stop anyone, and an agent may relay the human's typed request to stop someone. **Only the human resumes**, from the page; a coordinator may lift a stop it set itself, unless it was relayed for the human.
- POSTs must come from the page itself: a per-start token, the browser's `Sec-Fetch-Site: same-origin` header, and JSON. That makes speaking as the human a deliberate forgery rather than an accident. It is not a boundary against software running as the same Windows user.
- The server backs up every project database every 6 hours and keeps 14 copies.
## Tests
```bash
py -3.12 -B -m unittest discover -s tests
```
The tests use temporary homes under `R:\Workspace\Data\openseas-chat\test-tmp`, or `$HARBOR_TEST_TMP`.
+6
View File
@@ -0,0 +1,6 @@
"""Harbor: a local coordination hub for teams of AI coding agents and the person running them."""
from .core import Harbor, HarborError
from .projects import Project, ProjectError, resolve
__version__ = "2.0.0"
__all__ = ["Harbor", "HarborError", "Project", "ProjectError", "resolve", "__version__"]
+3
View File
@@ -0,0 +1,3 @@
from .cli import run
run()
+721
View File
@@ -0,0 +1,721 @@
"""harbor: messages, threads, acknowledgements, claims, review items and stop flags for a team of AI agents.
Project: --project <name or file.toml>, $HARBOR_PROJECT, or the nearest .harbor.toml above the working directory.
Identity: --as <Agent>, $HARBOR_AGENT, or the project's [identities] entry for the working directory.
The human speaks from the chat page only, never from this command line.
Start of every session: harbor brief
Take work: harbor next (the best task you may do; claims its scope)
While working: harbor doing "drafting 0x529350" (one line the whole team sees)
Waiting for answers: harbor wait --timeout 600 (returns as soon as a message arrives)
Before the session ends: harbor handoff "done X; Y in flight; next Z"
Tasks: harbor task add "Name 0x445310" --scope fn:0x445310 --priority high --after 3
harbor tasks harbor task done 5 --note "..." harbor task block 5 --note "needs 0x4E2680"
Messages:
harbor send Claude "text" --thread fn:0x529350 --subject "AudioManagerTick" --kind question
harbor reply 123 "text" harbor inbox harbor ack 123 --note "fixed in a2d7525"
harbor thread fn:0x529350 harbor search "AudioManagerTick" harbor threads
Claims: harbor claim fn:0x529350 --note "writing the body" harbor release --scope fn:0x529350
Review queue: harbor submit "AudioManagerTick readable" --scope fn:0x529350 --commit a2d7525
harbor review 7 approved --note "..." harbor land 7 --commit b4b44f0 harbor queue
Overview: harbor status harbor digest --since 8h harbor attention
Stop: harbor stop <yourself> (a coordinator may stop anyone; only the human resumes, from the page)
Long bodies: --file path (or --file - for stdin); attachments: --attach path (repeatable).
"""
import argparse
import json
import os
import sys
from pathlib import Path
from . import projects
from .core import CLAIM_KINDS, IMPORTANCE, ITEM_STATES, KINDS, OPEN_ITEM_STATES, TASK_STATES, Harbor, HarborError
def fmt_message(m):
tags = [f"thread {m['thread_id']} {m.get('topic', '')}".strip()]
if m["kind"] != "note":
tags.append(m["kind"])
if m["importance"] != "normal":
tags.append(m["importance"])
if m.get("ack_required"):
tags.append("ack")
if m.get("reply_to"):
tags.append(f"re #{m['reply_to']}")
if m.get("corrects"):
tags.append(f"corrects #{m['corrects']}")
if m.get("due_at"):
tags.append(f"due {m['due_at']}")
to = ",".join(m["recipients"])
head = f"#{m['id']} {m['created_at'].replace('T', ' ')[:16]}Z {m['sender']} → {to} [{' · '.join(tags)}]"
body = "\n".join(" " + line for line in m["body"].split("\n"))
files = m.get("refs", {}).get("attachments")
return head + "\n" + body + (f"\n attachments: {', '.join(files)}" if files else "")
def fmt_task(task):
who = task["assignee"] or "unassigned"
extra = []
if task["scope"]:
extra.append(task["scope"])
if task["waiting_on"]:
extra.append("after " + ",".join(f"#{d}" for d in task["waiting_on"]))
if task["due_at"]:
extra.append(f"due {task['due_at']}")
if task["item_id"]:
extra.append(f"item #{task['item_id']}")
if task["state"] == "blocked":
extra.append(f"blocked: {task['block_reason']}")
return (f"#{task['id']:>4} {task['state']:<11} {task['priority_name']:<6} {who:<9} {task['title']}"
+ (f" ({'; '.join(extra)})" if extra else ""))
def body_from(args):
if getattr(args, "file", None):
return sys.stdin.read() if args.file == "-" else Path(args.file).read_text(encoding="utf-8")
return " ".join(args.text)
def refs_from(pairs):
refs = {}
for pair in pairs or []:
if "=" not in pair:
raise HarborError(f"--ref {pair!r}: use key=value, e.g. commit=b4b44f0 or fn=0x529350")
key, value = pair.split("=", 1)
refs[key.strip()] = value.strip()
return refs
def recipients(text):
return None if not text else ("All" if text == "All" else [t.strip() for t in text.split(",") if t.strip()])
def build_parser():
parser = argparse.ArgumentParser(prog="harbor", description=__doc__.split("\n")[0],
formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__)
parser.add_argument("--as", dest="agent", default=None)
parser.add_argument("--project", default=None, help="project name or path to its .toml")
parser.add_argument("--json", action="store_true", help="machine-readable output")
sub = parser.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("brief", help="what you need to know at the start of a session")
p.add_argument("--mark-read", action="store_true")
p = sub.add_parser("inbox", help="unread messages (urgent first); marks them read")
p.add_argument("--all", action="store_true", help="recent messages, read or not")
p.add_argument("--peek", action="store_true", help="do not mark read")
p.add_argument("--limit", type=int, default=30)
p = sub.add_parser("wait", help="block until a message for you arrives or a stop applies (then print it)")
p.add_argument("--timeout", type=float, default=300, help="seconds (max 3600)")
p.add_argument("--peek", action="store_true")
for name in ("send", "reply"):
p = sub.add_parser(name)
if name == "send":
p.add_argument("to", help="Agent, Agent,Agent or All")
p.add_argument("--subject")
p.add_argument("--thread", help="thread id or topic, e.g. fn:0x529350, commit:b4b44f0, general")
else:
p.add_argument("message_id", type=int)
p.add_argument("--to", help="override recipients (Agent,Agent or All)")
p.add_argument("text", nargs="*")
p.add_argument("--file")
p.add_argument("--attach", action="append", help="copy a file into Harbor and link it (repeatable)")
p.add_argument("--kind", choices=KINDS, default="answer" if name == "reply" else "note")
p.add_argument("--importance", choices=IMPORTANCE, default="normal")
p.add_argument("--ack", action="store_true", help="ask the recipients to acknowledge")
p.add_argument("--ref", action="append", help="key=value (commit=..., fn=0x..., file=...)")
p.add_argument("--corrects", type=int, help="id of your earlier message this corrects")
p.add_argument("--due", help="answer needed within 30m / 2h / 1d, or by an ISO time")
p = sub.add_parser("ack", help="acknowledge messages addressed to you")
p.add_argument("ids", type=int, nargs="+")
p.add_argument("--note")
p = sub.add_parser("doing", help="set the one line the team sees about what you are doing (no text clears it)")
p.add_argument("text", nargs="*")
p = sub.add_parser("thread", help="show a whole thread")
p.add_argument("thread")
for name in ("close", "reopen"):
p = sub.add_parser(name, help=f"{name} a thread")
p.add_argument("thread")
p = sub.add_parser("threads", help="recent threads")
p.add_argument("--limit", type=int, default=25)
p.add_argument("--archive", action="store_true", help="include imported chat archives")
p.add_argument("--open", action="store_true", help="hide closed threads")
p = sub.add_parser("search", help="full-text search over every message")
p.add_argument("query", nargs="+")
p.add_argument("--limit", type=int, default=15)
p = sub.add_parser("tail", help="the most recent messages")
p.add_argument("count", type=int, nargs="?", default=20)
p = sub.add_parser("claim", help="claim a function, range, name, task or file")
p.add_argument("scope", help="fn:0xADDR, range:0xLO-0xHI, name:Symbol, task:text, file:path")
p.add_argument("--kind", choices=sorted(CLAIM_KINDS), default="draft")
p.add_argument("--ttl", type=float, help="hours (default by kind)")
p.add_argument("--note")
p.add_argument("--for", dest="for_agent", help="coordinator only: record the claim for another agent")
p.add_argument("--override", action="store_true", help="coordinator only")
p = sub.add_parser("release", help="release a claim")
p.add_argument("claim_id", type=int, nargs="?")
p.add_argument("--scope")
p = sub.add_parser("claims", help="active claims")
p.add_argument("--agent", dest="filter_agent")
p.add_argument("--kind")
p = sub.add_parser("submit", help="put a commit in the review queue")
p.add_argument("title")
p.add_argument("--scope")
p.add_argument("--commit")
p.add_argument("--branch")
p.add_argument("--note")
p.add_argument("--reviewer", action="append")
p.add_argument("--task", type=int, help="the task this work completes (landing closes it)")
p = sub.add_parser("review", help="move a review item")
p.add_argument("item", type=int)
p.add_argument("state", choices=[s for s in ITEM_STATES if s not in ("landed", "withdrawn")])
p.add_argument("--note")
p.add_argument("--commit")
p = sub.add_parser("withdraw", help="author withdraws an item")
p.add_argument("item", type=int)
p.add_argument("--note")
p = sub.add_parser("land", help="coordinator: the items are on main (several items: one message per author)")
p.add_argument("item", type=int, nargs="+")
p.add_argument("--commit", required=True)
p.add_argument("--note")
p = sub.add_parser("item", help="one review item with its history")
p.add_argument("item", type=int)
p = sub.add_parser("queue", help="open review items")
p.add_argument("--all", action="store_true")
sub.add_parser("status", help="stop flags, presence, unread counts and the queue at a glance")
p = sub.add_parser("tasks", help="the task list (open work by default)")
p.add_argument("--all", action="store_true", help="include done and cancelled")
p.add_argument("--mine", action="store_true")
p.add_argument("--agent", dest="filter_agent")
p = sub.add_parser("task", help="add, show or move a task")
p.add_argument("action", choices=("add", "show", "take", "done", "block", "release", "cancel", "reopen", "bulk",
"template"))
p.add_argument("args", nargs="*", help="add: the title; template: name and scope; bulk: none (--file); "
"others: the task id")
p.add_argument("--template", help="bulk: run this template for every line")
p.add_argument("--body")
p.add_argument("--file")
p.add_argument("--scope")
p.add_argument("--for", dest="for_agent", help="assign to an agent (coordinators; or yourself)")
p.add_argument("--priority", default="normal", choices=("urgent", "high", "normal", "low"))
p.add_argument("--after", type=int, action="append", help="a task that must be done first (repeatable)")
p.add_argument("--due", help="30m / 2h / 1d or an ISO time")
p.add_argument("--note")
p = sub.add_parser("next", help="take the best task you may do now (claims its scope)")
p.add_argument("--peek", action="store_true", help="show it without taking it")
p = sub.add_parser("handoff", help="record where you left off; shown at the start of your next session")
p.add_argument("text", nargs="*")
p.add_argument("--file")
p = sub.add_parser("handoffs", help="recent handoffs")
p.add_argument("--agent", dest="filter_agent")
p.add_argument("--limit", type=int, default=5)
for name in ("pin", "unpin"):
p = sub.add_parser(name, help=f"{name} a message for the whole team")
p.add_argument("message_id", type=int)
if name == "pin":
p.add_argument("--note", help="a one-line label")
sub.add_parser("pins", help="pinned messages")
sub.add_parser("decisions", help="recent messages of kind decision")
p = sub.add_parser("prompt", help="a restart prompt for an agent (paste it into a fresh session)")
p.add_argument("agent_name")
sub.add_parser("doctor", help="check the project, the database, the server and every host integration")
p = sub.add_parser("ask", help="ask the human (or an agent) a question with answer options")
p.add_argument("question", nargs="+")
p.add_argument("--options", help='answer buttons, separated by | e.g. "Yes|No|Later"')
p.add_argument("--to", help="who answers (default: the human)")
p.add_argument("--due", help="30m / 2h / an ISO time")
p.add_argument("--fallback", help="a coordinator who may answer while the human is away")
p.add_argument("--thread")
p = sub.add_parser("answer", help="answer a question addressed to you")
p.add_argument("question_id", type=int)
p.add_argument("text", nargs="+")
p = sub.add_parser("questions", help="open questions (to you and from you)")
p.add_argument("--all", action="store_true", help="include answered ones")
p = sub.add_parser("note", help="add to the team's memory about a scope (fn:0x..., file:..., name:...)")
p.add_argument("scope")
p.add_argument("text", nargs="+")
p.add_argument("--replaces", type=int, help="the id of an older note this one corrects")
p = sub.add_parser("notes", help="notes, all or for one scope")
p.add_argument("scope", nargs="?")
p = sub.add_parser("context", help="everything the team knows about a scope")
p.add_argument("scope")
p = sub.add_parser("metric", help="report a number the board charts (e.g. readable 779)")
p.add_argument("name")
p.add_argument("value")
p.add_argument("--note")
sub.add_parser("metrics", help="the latest value of every metric")
p = sub.add_parser("pulse", help="who was active when (tool calls and messages per hour)")
p.add_argument("--hours", type=int, default=12)
p = sub.add_parser("wins", help="landed review items")
p.add_argument("--since", default="24h")
sub.add_parser("git", help="each agent's branch, last commit, uncommitted files and unsubmitted commits")
p = sub.add_parser("summarize", help="pin a short summary to a thread (shown on top of it and in context)")
p.add_argument("thread")
p.add_argument("text", nargs="*")
p.add_argument("--file")
p = sub.add_parser("who", help="who has worked on a scope (notes, reviews, claims, messages)")
p.add_argument("scope")
p = sub.add_parser("feed", help="the latest tool calls of the team (or one agent), from the hooks")
p.add_argument("--agent", dest="filter_agent")
p.add_argument("--limit", type=int, default=30)
sub.add_parser("templates", help="task templates defined in the project file")
p = sub.add_parser("export", help="a thread as Markdown")
p.add_argument("thread")
p.add_argument("--out", help="write to this file instead of printing")
p = sub.add_parser("digest", help="what happened in a window (default the last 8 hours)")
p.add_argument("--since", default="8h", help="8h, 90m, 2d or an ISO time")
sub.add_parser("attention", help="overdue acknowledgements, stale review items, quiet agents")
for name in ("stop", "resume"):
p = sub.add_parser(name, help=f"{name} everyone or one agent")
p.add_argument("target", help="all or an agent name")
p.add_argument("--relay", type=int, help="id of the human's chat-page message that asked for this")
p.add_argument("--reason")
p = sub.add_parser("pending-acks", help="messages still waiting for acknowledgement")
p.add_argument("--overdue", type=int, default=0, help="minutes")
p = sub.add_parser("import-legacy", help="import a plain `[time] From → To: text` chat history (idempotent)")
p.add_argument("path")
p = sub.add_parser("backup", help="snapshot the database into the project's backups folder")
p.add_argument("--keep", type=int, default=14)
sub.add_parser("whoami", help="which project and identity this directory resolves to")
sub.add_parser("projects", help="registered projects")
p = sub.add_parser("init", help="create a project")
p.add_argument("name")
p.add_argument("--title")
p.add_argument("--agent", dest="roster", action="append", required=True,
help="Name or Name:role (repeat for each agent; the human is added automatically)")
p.add_argument("--human", default="User")
p.add_argument("--coordinator", action="append", default=[])
p.add_argument("--database")
p.add_argument("--mirror")
p.add_argument("--marker", help="also write a .harbor.toml in this folder pointing at the project")
p = sub.add_parser("hook", help="entry point for an agent host's hooks (see harbor.hooks)")
p.add_argument("event")
p.add_argument("--host", default="generic")
p = sub.add_parser("install", help="connect an agent host (codex, opencode, deepseek, kimi, claude) to Harbor")
p.add_argument("host", choices=("codex", "opencode", "deepseek", "kimi", "claude"))
p.add_argument("--agent", dest="host_agent",
help="the host's Harbor identity (default: Codex, OpenCode, DeepSeek, Kimi, Claude)")
p.add_argument("--bind", action="store_true", help="bind the host to --project instead of the working folder")
p.add_argument("--dry-run", action="store_true", help="show what would change")
p.add_argument("--uninstall", action="store_true", help="remove Harbor's entries again")
p.add_argument("--python", help='interpreter command for hosts to run, e.g. "py -3.12" (default: detected)')
p.add_argument("--no-register", action="store_true", help="kimi: build and validate the plugin only")
p = sub.add_parser("serve", help="run the chat page and board")
p.add_argument("--port", type=int, default=8766)
sub.add_parser("mcp", help="run the MCP server on stdio (identity from --as or $HARBOR_AGENT)")
return parser
def main(argv=None):
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
args = build_parser().parse_args(argv)
cmd = args.cmd
if cmd == "projects":
home = projects.harbor_home()
names = projects.list_names(home)
marker = projects.find_marker()
default = projects.default_name(home)
rows = [{"name": n, "default": n == default} for n in names]
text = f"Harbor home: {home}\n" + ("\n".join(f" {n}{' (default)' if n == default else ''}" for n in names)
or " no registered projects")
if marker:
text += f"\nThis directory belongs to: {marker}"
print(json.dumps(rows) if args.json else text)
return
if cmd == "init":
home = projects.harbor_home()
agents = []
for spec in args.roster:
name, _, role = spec.partition(":")
agents.append({"name": name.strip(), "role": role.strip()})
path = projects.write_project(home / "projects" / f"{args.name}.toml", args.name, args.title or args.name,
agents, human=args.human, coordinators=args.coordinator,
database=args.database, mirror=args.mirror)
print(f"created {path}")
if not projects.default_name(home):
(home / "config.toml").write_text(f'default_project = "{args.name}"\n', encoding="utf-8")
print(f"{args.name} is the default project")
if args.marker:
marker = Path(args.marker) / projects.MARKER
if marker.exists():
raise HarborError(f"{marker} already exists")
marker.write_text(f'project = "{args.name}"\n', encoding="utf-8")
print(f"wrote {marker}")
return
if cmd == "install":
from . import install
bound = args.project if (args.bind or args.host == "kimi") else None
try:
report = install.install(args.host, agent=args.host_agent, project=bound, dry_run=args.dry_run,
remove=args.uninstall, python=args.python, register=not args.no_register)
except install.InstallError as error:
raise HarborError(str(error))
print(("Dry run - nothing written.\n" if args.dry_run else "") + "\n".join(report))
return
if cmd == "hook": # resolves the project from the host's payload, never raises
from . import hooks
sys.exit(hooks.run(args.event, args.project, args.agent, host=args.host))
project = projects.resolve(args.project)
if cmd == "serve":
from . import server
server.main(["--port", str(args.port)] + (["--project", args.project] if args.project else []))
return
if cmd == "mcp":
if args.agent:
os.environ["HARBOR_AGENT"] = args.agent
from . import mcp_server
mcp_server.main(project)
return
harbor = Harbor(project)
source = "--as" if args.agent else None
agent = args.agent or os.environ.get("HARBOR_AGENT")
if agent and not source:
source = "HARBOR_AGENT"
if not agent:
agent = project.identity_for(os.getcwd())
source = "working directory" if agent else None
recorded = []
def who():
if not agent:
raise HarborError("say who you are: --as <Agent>, set HARBOR_AGENT, or work inside a directory listed "
"under [identities] in the project file")
if agent == project.human:
raise HarborError(f"{project.human} speaks from the chat page, not the command line")
if agent not in project.ai_names:
raise HarborError(f"unknown agent {agent!r}; this project has {', '.join(project.ai_names)}")
if not recorded and cmd not in ("whoami", "prompt"):
recorded.append(True)
harbor.activity(agent, f"harbor {cmd}", os.environ.get("HARBOR_HOST", ""), channel="cli")
return agent
def out(value, text):
print(json.dumps(value, ensure_ascii=False, indent=1, default=str) if args.json else text)
if cmd == "whoami":
out({"project": project.name, "source": str(project.source), "agent": agent, "identity_from": source},
f"project {project.name} ({project.title}) from {project.source}\n"
f"agent {agent or '(none)'}" + (f" from {source}" if source else ""))
elif cmd == "brief":
text = harbor.brief(who(), mark_read=args.mark_read)
out({"brief": text}, text)
elif cmd == "inbox":
rows = harbor.inbox(who(), unread_only=not args.all, limit=args.limit, mark_read=not args.peek)
out(rows, "\n\n".join(fmt_message(m) for m in rows) or "No unread messages.")
elif cmd == "wait":
r = harbor.wait(who(), timeout=args.timeout, mark_read=not args.peek)
if r["stopped"]:
text = "STOP in force (" + ", ".join(r["stopped"]) + "): do not start or continue work."
elif r["timed_out"]:
text = "No new messages."
else:
text = "\n\n".join(fmt_message(m) for m in r["messages"])
out(r, text)
elif cmd in ("send", "reply"):
text = body_from(args)
if cmd == "send":
r = harbor.send(who(), recipients(args.to), text, subject=args.subject, thread=args.thread, kind=args.kind,
importance=args.importance, ack_required=args.ack, refs=refs_from(args.ref),
attachments=args.attach, corrects=args.corrects, due=args.due)
else:
r = harbor.send(who(), recipients(args.to), text, reply_to=args.message_id, kind=args.kind,
importance=args.importance, ack_required=args.ack, refs=refs_from(args.ref),
attachments=args.attach, corrects=args.corrects, due=args.due)
lines = [f"sent #{r['id']} in thread {r['thread_id']} ({r['topic']}) to {', '.join(r['targets'])}"]
lines += [f"warning: {w}" for w in r["warnings"]]
out(r, "\n".join(lines))
elif cmd == "ack":
out(harbor.ack(who(), args.ids, args.note), "acknowledged " + ", ".join(f"#{i}" for i in args.ids))
elif cmd == "doing":
r = harbor.doing(who(), " ".join(args.text))
out(r, f"{r['agent']}: {r['doing'] or '(cleared)'}")
elif cmd == "thread":
thread, messages = harbor.thread(args.thread)
out({"thread": thread, "messages": messages},
f"Thread {thread['id']} {thread['topic']}: {thread['subject']} ({thread['state']})\n\n"
+ "\n\n".join(fmt_message(m) for m in messages))
elif cmd in ("close", "reopen"):
t = harbor.set_thread_state(who(), args.thread, "closed" if cmd == "close" else "open")
out(t, f"thread {t['id']} {t['topic']} is {t['state']}")
elif cmd == "threads":
rows = harbor.threads(limit=args.limit, agent=agent if agent and agent != project.human else None,
include_legacy=args.archive, include_closed=not args.open)
out(rows, "\n".join(f"{r['id']:>5} {r['updated_at'][:16]}Z {r['messages']:>4} msgs"
+ (f" {r['unread']} unread" if r.get('unread') else "")
+ (f" [{r['state']}]" if r['state'] != 'open' else "")
+ f" {r['topic']}: {r['subject']}" for r in rows) or "No threads.")
elif cmd == "search":
rows = harbor.search(" ".join(args.query), limit=args.limit)
out(rows, "\n\n".join(fmt_message(m) for m in rows) or "No matches.")
elif cmd == "tail":
rows = harbor.recent(args.count)
out(rows, "\n\n".join(fmt_message(m) for m in rows))
elif cmd == "claim":
r = harbor.claim(who(), args.scope, kind=args.kind, ttl_hours=args.ttl, note=args.note,
override=args.override, on_behalf_of=args.for_agent)
out(r, f"{'renewed' if r['renewed'] else 'claimed'} #{r['id']} {r['kind']} {r['scope']}"
+ (f" until {r['expires_at']}" if r["expires_at"] else " (standing)")
+ (f" overriding {r['conflicts']}" if r["conflicts"] else ""))
elif cmd == "release":
if args.claim_id is None and not args.scope:
raise HarborError("give a claim id or --scope")
out(harbor.release(who(), args.claim_id, args.scope), "released")
elif cmd == "claims":
rows = harbor.active_claims(agent=args.filter_agent, kind=args.kind)
out(rows, "\n".join(f"#{c['id']:>4} {c['agent']:<9} {c['kind']:<7} {c['scope']:<32}"
+ (f" until {c['expires_at']}" if c['expires_at'] else " standing")
+ (f" {c['note']}" if c['note'] else "") for c in rows) or "No active claims.")
elif cmd == "submit":
r = harbor.submit(who(), args.title, scope=args.scope, commit=args.commit, branch=args.branch, note=args.note,
reviewers=args.reviewer, task=args.task)
out(r, f"review item #{r['id']} submitted (thread {r['thread_id']})")
elif cmd == "review":
r = harbor.transition(who(), args.item, args.state, note=args.note, commit=args.commit)
out(r, f"item #{r['id']} is now {r['state']}")
elif cmd == "withdraw":
r = harbor.transition(who(), args.item, "withdrawn", note=args.note)
out(r, f"item #{r['id']} withdrawn")
elif cmd == "land":
if len(args.item) == 1 and harbor.item(args.item[0])[0]["state"] == "approved":
r = harbor.transition(who(), args.item[0], "landed", note=args.note, commit=args.commit)
out(r, f"item #{r['id']} landed in {args.commit}")
else: # approves items still under review on the way
r = harbor.land_many(who(), args.item, args.commit, note=args.note)
out(r, f"{len(r)} items landed in {args.commit}: " + ", ".join(f"#{i['id']}" for i in r))
elif cmd == "item":
item, events = harbor.item(args.item)
out({"item": item, "events": events},
f"#{item['id']} {item['state']} {item['scope'] or ''} {item['title']} (author {item['author']}, "
f"commit {item['commit_hash'] or '-'})\n" + "\n".join(
f" {e['at']} {e['agent']}: {e['from_state'] or '-'} -> {e['to_state']}"
+ (f" ({e['commit_hash']})" if e['commit_hash'] else "") + (f" {e['note']}" if e['note'] else "")
for e in events))
elif cmd == "queue":
rows = harbor.items(states=ITEM_STATES if args.all else OPEN_ITEM_STATES)
out(rows, "\n".join(f"#{i['id']:>4} {i['state']:<17} {i['author']:<9} {i['scope'] or '':<18} {i['title']}"
f" (since {i['created_at'][:16]}Z)" for i in rows) or "The review queue is empty.")
elif cmd == "status":
flags = harbor.flags()
stopped = [a for a in project.ai_names if harbor.stopped(a)]
trials = {a: harbor.trial(a) for a in project.ai_names if harbor.trial(a)}
lines = [f"{project.title}: " + (f"stopped: {', '.join(stopped)}" if stopped else "nobody is stopped")
+ ("; trial runs: " + ", ".join(f"{a} until {u}" for a, u in trials.items()) if trials else "") + "."]
agents = harbor.agents()
for a in agents:
n, u = harbor.unread_count(a["name"])
seen = harbor.minutes_since(a["last_seen"])
lines.append(f" {a['name']:<9} unread {n:>3}" + (f" ({u} urgent)" if u else " ")
+ f" seen {'never' if seen is None else str(seen) + ' min ago':<14}"
+ (f" doing: {a['doing']}" if a.get("doing") else ""))
lines.append(f"Review queue: {len(harbor.items())} open. Active claims: {len(harbor.active_claims())}.")
for a in harbor.attention():
lines.append(f"Attention: {a['text']}")
out({"flags": flags, "agents": agents, "queue": len(harbor.items()), "attention": harbor.attention()},
"\n".join(lines))
elif cmd == "digest":
d = harbor.digest(args.since)
lines = [f"From {d['since']} to {d['until']}:"]
for a in d["agents"]:
if any(a[k] for k in ("messages", "submitted", "reviewed", "landed", "claims")):
lines.append(f" {a['name']:<9} {a['messages']:>3} messages in {a['threads']} threads, "
f"{a['submitted']} submitted, {a['reviewed']} reviewed, {a['landed']} landed, "
f"{a['claims']} claims" + (f"; now: {a['doing']}" if a["doing"] else ""))
for e in d["item_events"]:
lines.append(f" item #{e['item_id']} {e['title']}: {e['from_state'] or '-'} -> {e['to_state']} by {e['agent']}")
for f in d["flags"]:
lines.append(f" {f['key']} {f['value']} by {f['set_by']} at {f['at']}" + (f": {f['reason']}" if f['reason'] else ""))
if d["to_human"]:
lines.append(f"Messages to {project.human}: " + ", ".join(f"#{m['id']} from {m['sender']}" for m in d["to_human"]))
out(d, "\n".join(lines) if len(lines) > 1 else lines[0] + " nothing happened.")
elif cmd == "attention":
rows = harbor.attention()
out(rows, "\n".join(a["text"] for a in rows) or "Nothing needs attention.")
elif cmd in ("stop", "resume"):
key = "stop:all" if args.target.lower() == "all" else f"stop:{args.target}"
flags = harbor.set_flag(key, "on" if cmd == "stop" else "off", who(), "cli", reason=args.reason,
relay_of=args.relay)
out(flags, f"{key} is now {flags[key]['value']}")
elif cmd == "pending-acks":
rows = harbor.pending_acks(sender=args.agent, overdue_minutes=args.overdue)
out(rows, "\n".join(f"#{r['id']} from {r['sender']} to {r['agent']} at {r['created_at']}" for r in rows)
or "Nothing waiting for acknowledgement.")
elif cmd == "import-legacy":
if who() not in project.coordinators:
raise HarborError("only a coordinator imports chat history")
r = harbor.import_legacy(args.path)
out(r, f"parsed {r['parsed']} messages, imported {r['added']} new")
elif cmd == "tasks":
states = TASK_STATES if args.all else ("open", "in_progress", "blocked", "review")
rows = harbor.tasks(states=states, assignee=(who() if args.mine else args.filter_agent))
out(rows, "\n".join(fmt_task(x) for x in rows) or "No tasks.")
elif cmd == "task":
if args.action == "bulk":
text = sys.stdin.read() if not args.file or args.file == "-" else Path(args.file).read_text(encoding="utf-8")
r = harbor.bulk_tasks(who(), text, template=args.template, priority=args.priority, assignee=args.for_agent)
out(r, "\n".join(["added " + fmt_task(x).strip() for x in r["created"]] + [f"skipped: {s}" for s in r["skipped"]])
or "nothing to add")
elif args.action == "template":
if not args.args:
raise HarborError("task template <name> [scope]")
rows = harbor.run_template(who(), args.args[0], scope=args.args[1] if len(args.args) > 1 else None,
title=args.body, owner=args.for_agent, priority=args.priority)
out(rows, "\n".join("added " + fmt_task(x).strip() for x in rows))
elif args.action == "add":
title = " ".join(args.args)
body = Path(args.file).read_text(encoding="utf-8") if args.file else args.body
r = harbor.add_task(who(), title, body=body, scope=args.scope, assignee=args.for_agent,
priority=args.priority, after=args.after, due=args.due)
out(r, "added " + fmt_task(r).strip())
else:
if len(args.args) != 1 or not args.args[0].isdigit():
raise HarborError(f"task {args.action} needs one task id")
tid = int(args.args[0])
if args.action == "show":
task, events = harbor.task(tid)
out({"task": task, "events": events}, fmt_task(task) + (f"\n{task['body']}" if task["body"] else "")
+ "\n" + "\n".join(f" {e['at']} {e['agent']}: {e['from_state'] or '-'} -> {e['to_state']}"
+ (f" ({e['note']})" if e['note'] else "") for e in events))
elif args.action == "take":
r = harbor.take_task(who(), tid, args.note)
out(r, "took " + fmt_task(r).strip())
else:
state = {"done": "done", "block": "blocked", "release": "open", "cancel": "cancelled",
"reopen": "open"}[args.action]
r = harbor.update_task(who(), tid, state, note=args.note)
out(r, fmt_task(r).strip())
elif cmd == "next":
r = harbor.next_task(who(), take=not args.peek)
if r and r["scope"]:
r["context"] = harbor.context_text(r["scope"])
out(r, ("next: " if args.peek else "took ") + fmt_task(r).strip() + (f"\n\n{r['context']}" if r.get("context")
else "") if r else
"No task you can take right now (all assigned, waiting on other tasks, inside someone else's claim, "
"or you are stopped).")
elif cmd == "ask":
q = harbor.ask(who(), " ".join(args.question), options=(args.options or "").split("|") if args.options else (),
to=args.to, due=args.due, fallback=args.fallback, thread=args.thread)
out(q, f"question #{q['id']} sent to {q['to_name']} (message #{q['message_id']}); `wait` returns when it is "
"answered")
elif cmd == "answer":
q = harbor.answer(who(), args.question_id, " ".join(args.text))
out(q, f"answered #{q['id']}: {q['answer']}")
elif cmd == "questions":
me = who()
rows = [q for q in harbor.questions(open_only=not args.all, limit=100) if me in (q["to_name"], q["asker"])]
out(rows, "\n".join(f"#{q['id']} {q['asker']} -> {q['to_name']}: {q['question']}"
+ (f" [{' / '.join(q['options'])}]" if q["options"] else "")
+ (f" => {q['answer']} ({q['answered_by']})" if q["answer"] else "") for q in rows)
or "No open questions.")
elif cmd == "note":
r = harbor.note(who(), args.scope, " ".join(args.text), replaces=args.replaces)
out(r, f"note #{r['id']} on {r['scope']}")
elif cmd == "notes":
rows = harbor.notes(args.scope)
out(rows, "\n".join(f"#{n['id']} {n['scope']} {n['author']} {n['created_at'][:10]}: {n['body']}" for n in rows)
or "No notes.")
elif cmd == "context":
out(harbor.context(args.scope), harbor.context_text(args.scope, limit=15))
elif cmd == "metric":
r = harbor.metric(who(), args.name, args.value, args.note)
out(r, f"{r['name']} = {r['value']:g}")
elif cmd == "metrics":
rows = harbor.metrics()
out(rows, "\n".join(f"{k:<20} {v['latest']['value']:g} ({v['latest']['by_agent']}, {v['latest']['at']})"
+ (f" {v['latest']['note']}" if v['latest']['note'] else "") for k, v in rows.items())
or "No metrics yet.")
elif cmd == "pulse":
data = harbor.pulse(hours=args.hours)
hours = {}
for agent, buckets in data["agents"].items():
for b in buckets:
hours.setdefault(agent, {}).setdefault(b["bucket"][:13], 0)
hours[agent][b["bucket"][:13]] += b["tools"] + b["messages"]
marks = " .:-=+*#%@"
stamps = sorted({h for row in hours.values() for h in row})
lines = [f"Activity per hour since {data['since']} (UTC hours {stamps[0][11:13] if stamps else '-'}"
f"..{stamps[-1][11:13] if stamps else '-'}):"]
for agent in harbor.agent_names():
row = hours.get(agent, {})
peak = max(row.values() or [1])
lines.append(f" {agent:<9} " + "".join(marks[min(9, int(9 * row.get(h, 0) / peak + 0.99))] if row.get(h) else " "
for h in stamps))
out(data, "\n".join(lines))
elif cmd == "wins":
rows = harbor.wins(args.since)
out(rows, "\n".join(f"{w['at']} #{w['item_id']} {w['title']} by {w['author']}" for w in rows) or "No wins yet.")
elif cmd == "git":
radar = harbor.git_radar()
lines = []
for agent, g in radar.items():
if not g:
continue
if g.get("error"):
lines.append(f"{agent:<9} {g['workdir']}: {g['error']}")
continue
last = g["last_commit"]
lines.append(f"{agent:<9} {g['branch']:<18} last {last['sha']} {harbor.minutes_since(last['at'])} min ago: "
f"{last['subject'][:60]}")
lines.append(f"{'':<9} {g['dirty']} uncommitted, {g['behind_main']} behind main, "
f"{len(g['unsubmitted'])} commit(s) since the last submit")
out(radar, "\n".join(lines) or "No agent has a workdir in the project file.")
elif cmd == "summarize":
text = Path(args.file).read_text(encoding="utf-8") if args.file else " ".join(args.text)
r = harbor.summarize(who(), args.thread, text)
out(r, f"summary #{r['id']} pinned to {r['thread']}")
elif cmd == "who":
rows = harbor.experts(args.scope)
out(rows, "\n".join(f"{e['agent']:<9} " + ", ".join(f"{v} {k}" for k, v in e["detail"].items()) for e in rows)
or "Nobody has worked on it yet.")
elif cmd == "feed":
rows = harbor.activity_feed(limit=args.limit, agent=args.filter_agent)
out(rows, "\n".join(f"{r['at'][11:19]} {r['agent']:<9} {r['tool'] or '':<14} {r['target'] or ''}" for r in reversed(rows))
or "No tool activity recorded yet.")
elif cmd == "templates":
out(project.templates, "\n".join(f"{name}: " + " -> ".join(s.get("name", s["title"]) for s in spec["steps"])
for name, spec in project.templates.items()) or "No templates defined.")
elif cmd == "export":
text = harbor.export_thread(args.thread)
if args.out:
Path(args.out).write_text(text, encoding="utf-8")
print(f"wrote {args.out}")
else:
print(text)
elif cmd == "handoff":
text = Path(args.file).read_text(encoding="utf-8") if args.file else " ".join(args.text)
r = harbor.handoff(who(), text)
out(r, f"handoff #{r['id']} saved; it opens your next brief")
elif cmd == "handoffs":
rows = harbor.handoffs(agent=args.filter_agent, limit=args.limit)
out(rows, "\n\n".join(f"#{h['id']} {h['agent']} at {h['created_at']}\n" + "\n".join(
" " + line for line in h["body"].split("\n")) for h in rows) or "No handoffs.")
elif cmd in ("pin", "unpin"):
rows = harbor.pin(who(), args.message_id, args.note) if cmd == "pin" else harbor.unpin(who(), args.message_id)
out(rows, f"{len(rows)} pinned message(s)")
elif cmd in ("pins", "decisions"):
rows = harbor.pins() if cmd == "pins" else harbor.decisions()
out(rows, "\n\n".join(fmt_message(m) for m in rows) or f"No {cmd}.")
elif cmd == "prompt":
text = harbor.restart_prompt(args.agent_name)
out({"prompt": text}, text)
elif cmd == "doctor":
from . import doctor
checks = doctor.run(project, harbor)
out(checks, doctor.render(checks))
elif cmd == "backup":
path = harbor.backup(keep=args.keep)
out({"backup": str(path)}, f"backed up to {path}")
def run():
try:
main()
except (HarborError, projects.ProjectError) as error:
sys.exit(f"harbor: {error}")
if __name__ == "__main__":
run()
+1355
View File
File diff suppressed because it is too large. Load diff
+241
View File
@@ -0,0 +1,241 @@
"""`harbor doctor`: is the project healthy, and is every agent's app really connected?
Each check is {area, check, status (ok|warn|fail|info), detail, fix}. Host checks read the host configuration files
that `harbor install` writes; "live" checks use what Harbor recorded when agents last reached it.
"""
import json
import os
import re
import tomllib
import urllib.request
from pathlib import Path
from . import install
def _check(area, check, status, detail="", fix=""):
return {"area": area, "check": check, "status": status, "detail": detail, "fix": fix}
def _text(path):
try:
return Path(path).read_text(encoding="utf-8")
except OSError:
return None
def _block(text, start=install.START, end=install.END):
return bool(text) and start in text and end in text
def codex_checks(agent):
home = install.user_home() / ".codex"
out = []
config = _text(home / "config.toml")
server = None
try:
server = tomllib.loads(config or "").get("mcp_servers", {}).get("harbor")
except tomllib.TOMLDecodeError as error:
out.append(_check("Codex", "config.toml parses", "fail", str(error), "fix the TOML by hand"))
if server and server.get("env", {}).get("HARBOR_AGENT") == agent:
out.append(_check("Codex", "MCP server", "ok", f"[mcp_servers.harbor] as {agent}"))
else:
out.append(_check("Codex", "MCP server", "fail", "missing or for another agent", "harbor install codex"))
hooks = _text(home / "hooks.json")
events = set()
if hooks:
try:
events = {e for e, entries in json.loads(hooks).get("hooks", {}).items()
if any(install._is_ours(x) for x in entries)}
except ValueError:
pass
wanted = {"SessionStart", "UserPromptSubmit", "PreToolUse", "Stop"}
out.append(_check("Codex", "hooks", "ok" if wanted <= events else "fail",
", ".join(sorted(events)) or "none", "" if wanted <= events else "harbor install codex"))
trusted = len(re.findall(r"trusted_hash", config or ""))
out.append(_check("Codex", "hooks trusted", "ok" if trusted else "warn",
f"{trusted} trusted hook entr{'y' if trusted == 1 else 'ies'} in config.toml",
"" if trusted else "Codex: Settings > Hooks > trust the Harbor hooks"))
out.append(_check("Codex", "AGENTS.md", "ok" if _block(_text(home / "AGENTS.md"), install.MD_START, install.MD_END)
else "warn", str(home / "AGENTS.md"), "harbor install codex"))
return out
def opencode_checks(agent):
home = install.user_home() / ".config" / "opencode"
out = []
config = next((p for p in (home / "opencode.jsonc", home / "opencode.json") if p.exists()), None)
try:
server = json.loads(_text(config) or "{}").get("mcp", {}).get("harbor") if config else None
except ValueError:
server = None
ok = bool(server) and server.get("environment", {}).get("HARBOR_AGENT") == agent and server.get("enabled", True)
out.append(_check("OpenCode", "MCP server", "ok" if ok else "fail", str(config or home), "" if ok else
"harbor install opencode"))
plugin = _text(home / "plugin" / "harbor.js")
ok = bool(plugin) and f'const AGENT = "{agent}";' in plugin
out.append(_check("OpenCode", "plugin", "ok" if ok else "fail", str(home / "plugin" / "harbor.js"),
"" if ok else "harbor install opencode"))
out.append(_check("OpenCode", "AGENTS.md", "ok" if _block(_text(home / "AGENTS.md"), install.MD_START,
install.MD_END) else "warn", "", "harbor install opencode"))
return out
def deepseek_checks(agent):
from . import projects
home = install.user_home() / ".dsh"
out = []
patch = _text(home / "profiles" / "desktop" / "cordis.patch.yml")
rows = []
if patch:
try:
for row in install._TolerantLoader.load(patch):
if isinstance(row, dict) and "insert" in row:
rows += [r for r in row["insert"] if isinstance(r, dict)]
except Exception as error: # noqa: BLE001 - report any YAML problem
out.append(_check("DeepSeek", "cordis.patch.yml parses", "fail", str(error), "restore it from the backup"))
mcp = next((r for r in rows if r.get("id") == "mcp-harbor"), None)
ok = bool(mcp) and (mcp.get("config") or {}).get("env", {}).get("HARBOR_AGENT") == agent
out.append(_check("DeepSeek", "MCP row", "ok" if ok else "fail", "mcp-harbor", "" if ok else "harbor install deepseek"))
hooks_row = next((r for r in rows if r.get("id") == "hooks-harbor"), None)
hooks_file = projects.harbor_home() / "integrations" / "deepseek" / "hooks.json"
ok = bool(hooks_row) and hooks_file.exists()
out.append(_check("DeepSeek", "hooks row and file", "ok" if ok else "fail", str(hooks_file),
"" if ok else "harbor install deepseek"))
out.append(_check("DeepSeek", "AGENTS.md", "ok" if _block(_text(home / "AGENTS.md"), install.MD_START, install.MD_END)
else "warn", "", "harbor install deepseek"))
return out
def kimi_checks(agent, project):
from . import projects
out = []
name = f"harbor-{project.name}"
folder = projects.harbor_home() / "integrations" / "kimi" / name
manifest = _text(folder / "kimi.plugin.json")
ok = bool(manifest) and f'"HARBOR_AGENT": "{agent}"' in manifest
out.append(_check("Kimi", "plugin folder", "ok" if ok else "fail", str(folder),
"" if ok else f"harbor --project {project.name} install kimi"))
share = install.kimi_share() / "daimon"
registered = (share / "plugin-market" / "personal" / f"{name}.json").exists()
out.append(_check("Kimi", "registered", "ok" if registered else "fail", "Personal market",
"" if registered else f"harbor --project {project.name} install kimi"))
installed, enabled = False, False
record = share / "runtime" / "kimi-code" / "home" / "plugins" / "installed.json"
try:
def walk(node):
if isinstance(node, dict):
if node.get("id") == name:
yield node
for value in node.values():
yield from walk(value)
elif isinstance(node, list):
for value in node:
yield from walk(value)
entry = next(walk(json.loads(_text(record) or "{}")), None)
installed, enabled = entry is not None, bool(entry and entry.get("enabled", True))
except ValueError:
pass
installed = installed or (share / "plugin-packages" / name).exists()
out.append(_check("Kimi", "installed", "ok" if installed and enabled else "warn",
f"{name} " + ("enabled" if enabled else "installed but disabled" if installed else "not installed"),
"" if installed and enabled else "Kimi: sidebar Plugins > Personal > install (and enable) Harbor"))
return out
def running_mcp_servers():
"""{agent: count} of Harbor MCP server processes running right now (started by the agents' apps)."""
import subprocess
try:
out = subprocess.run(["powershell", "-NoProfile", "-Command",
"Get-CimInstance Win32_Process -Filter \"Name='python.exe'\" | "
"ForEach-Object { $_.CommandLine }"], capture_output=True, text=True, timeout=20).stdout
except (OSError, subprocess.SubprocessError):
return {}
found = {}
for line in out.splitlines():
m = re.search(r"(?:run|harbor_mcp|harbor)\.py.*?--as\s+(\w+)\s+mcp\b", line) or (
re.search(r"harbor_mcp\.py", line) and None)
if m:
found[m.group(1)] = found.get(m.group(1), 0) + 1
return found
HOST_CHECKS = {"codex": codex_checks, "opencode": opencode_checks, "deepseek": deepseek_checks}
def server_check(project, port, server_known):
url = f"http://127.0.0.1:{port}"
if server_known:
return _check("Server", url, "ok", "this page is served by it")
try:
with urllib.request.urlopen(f"{url}/api/projects", timeout=2) as response:
names = [p["name"] for p in json.loads(response.read())]
except OSError:
return _check("Server", url, "warn", "not running (agents still work)", "start it: harbor serve, or Chat.cmd")
except (ValueError, KeyError, TypeError):
return _check("Server", url, "warn", "something else answers on this port", "free the port or pick another")
ok = project.name in names
return _check("Server", url, "ok" if ok else "warn", "serving " + ", ".join(names), "" if ok else "restart the server")
def run(project, harbor, port=None, server_known=False):
"""server_known: the caller is the server itself (asking it over HTTP would wait on its own lock)."""
port = port or int(os.environ.get("HARBOR_PORT", "8766"))
checks = [_check("Project", "file", "ok", str(project.source)),
_check("Project", "roster", "ok", ", ".join(project.names))]
try:
quick = harbor.conn.execute("PRAGMA quick_check").fetchone()[0]
count = harbor.conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
checks.append(_check("Database", "integrity", "ok" if quick == "ok" else "fail",
f"{project.db} ({count} messages): {quick}", "" if quick == "ok" else "restore a backup"))
except Exception as error: # noqa: BLE001
checks.append(_check("Database", "integrity", "fail", str(error), "restore a backup"))
backups = sorted(project.backups.glob("harbor-*.db")) if project.backups.exists() else []
if backups:
age = (harbor.clock() - backups[-1].stat().st_mtime) / 3600
checks.append(_check("Database", "backups", "ok" if age < 24 else "warn",
f"{len(backups)} copies, newest {age:.1f} h old", "" if age < 24 else "harbor backup"))
else:
checks.append(_check("Database", "backups", "warn", "none yet", "harbor backup (the server also makes them)"))
if project.mirror:
try:
with open(project.mirror, "ab"):
pass
checks.append(_check("Mirror", "writable", "ok", str(project.mirror)))
except OSError as error:
checks.append(_check("Mirror", "writable", "fail", str(error), "check the file's permissions"))
checks.append(server_check(project, port, server_known))
links = harbor.connections()
servers = running_mcp_servers()
for spec in project.agents:
if spec["kind"] == "human":
continue
host = spec.get("host")
if host in HOST_CHECKS:
checks += HOST_CHECKS[host](spec["name"])
elif host == "kimi":
checks += kimi_checks(spec["name"], project)
seen = {(r["channel"], r["host"]): r for r in links.get(spec["name"], [])}
live = ", ".join(f"{ch}{('/' + h) if h else ''} {r['minutes']} min ago" for (ch, h), r in sorted(seen.items()))
running = servers.get(spec["name"], 0)
if running:
live = f"Harbor tools running in its app now ({running} process{'es' if running > 1 else ''})" + (
f"; {live}" if live else "")
status = "ok" if running or any(ch in ("mcp", "hook") for ch, _ in seen) else "info"
checks.append(_check(spec["name"], "live connection", status, live or "not seen through MCP or hooks yet",
"" if status == "ok" else "shows once the app starts a session with Harbor installed"))
return checks
def render(checks):
mark = {"ok": "OK ", "warn": "WARN", "fail": "FAIL", "info": "info"}
lines = []
for c in checks:
lines.append(f"{mark[c['status']]} {c['area']:<9} {c['check']:<22} {c['detail']}")
if c["fix"] and c["status"] in ("warn", "fail"):
lines.append(f"{'':6}{'':<9} -> {c['fix']}")
bad = sum(c["status"] == "fail" for c in checks)
warn = sum(c["status"] == "warn" for c in checks)
lines.append(f"\n{bad} failing, {warn} warnings.")
return "\n".join(lines)
+402
View File
@@ -0,0 +1,402 @@
"""Experimental team features on top of messages and tasks:
questions an agent asks the human (or an agent) a question with answer options; the answer comes back as a reply
notes team memory attached to a scope (fn:0x..., file:..., name:...); `context` gathers everything known
touches which files each agent is editing (from host hooks); two agents on the same file raise a collision
pulse per-agent activity in 5-minute buckets (tool calls and messages) for the board's timeline
metrics numbers agents report (readable functions, tests passing, ...) charted on the board
wins a landed review item is announced to the human
Mixed into Harbor (core.py); every method uses Harbor's connection, clock and write helper.
"""
import datetime
import json
import math
import re
from pathlib import PureWindowsPath
from .util import HarborError, first_line, iso, parse_scope, scopes_overlap, utc
EXTRA_SCHEMA = """
CREATE TABLE IF NOT EXISTS questions(
id INTEGER PRIMARY KEY, message_id INTEGER NOT NULL, asker TEXT NOT NULL, to_name TEXT NOT NULL,
question TEXT NOT NULL, options TEXT NOT NULL, fallback TEXT, answer TEXT, answered_by TEXT,
answer_message_id INTEGER, created_at TEXT NOT NULL, answered_at TEXT);
CREATE INDEX IF NOT EXISTS questions_message ON questions(message_id);
CREATE TABLE IF NOT EXISTS notes(
id INTEGER PRIMARY KEY, scope TEXT NOT NULL, lo INTEGER, hi INTEGER, body TEXT NOT NULL, author TEXT NOT NULL,
created_at TEXT NOT NULL, replaced_by INTEGER);
CREATE TABLE IF NOT EXISTS touches(agent TEXT NOT NULL, path TEXT NOT NULL, tool TEXT, at TEXT NOT NULL,
PRIMARY KEY(agent, path));
CREATE TABLE IF NOT EXISTS pulse(agent TEXT NOT NULL, bucket TEXT NOT NULL, tools INTEGER NOT NULL DEFAULT 0,
messages INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(agent, bucket));
CREATE TABLE IF NOT EXISTS metrics(id INTEGER PRIMARY KEY, name TEXT NOT NULL, value REAL NOT NULL, note TEXT,
by_agent TEXT NOT NULL, at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS metrics_name ON metrics(name, id);
"""
EDIT_TOOL = re.compile(r"edit|write|patch|apply|create|replace|insert|str_replace|multiedit|notebook", re.I)
PATCH_FILE = re.compile(r"^\*\*\* (?:Update|Add|Delete) File: (.+)$", re.M)
def bucket_of(dt):
return iso(dt.replace(minute=dt.minute - dt.minute % 5, second=0))
class ExtrasMixin:
# ----------------------------------------------------------------- questions with answer options
def ask(self, asker, question, options=(), to=None, due=None, fallback=None, origin="cli", thread=None):
"""Ask `to` (default: the human) a question; options are the answer buttons shown on the chat page."""
self._check_agent(asker, allow_human=(origin == "web"))
to = to or self.human
self._check_agent(to)
options = [" ".join(str(o).split())[:60] for o in options if str(o).strip()][:6]
if fallback:
self._check_agent(fallback, allow_human=False)
if fallback not in self.coordinators:
raise HarborError("the fallback answerer must be a coordinator")
if fallback == asker:
raise HarborError("the fallback answerer cannot be the one asking")
text = question.strip() + ("\n\nOptions: " + " / ".join(options) if options else "")
msg = self.send(asker, [to], text, thread=thread or "questions", subject="Questions", kind="question",
importance="high", due=due, origin=origin)
now = self.now()
qid = self._write(lambda c: c.execute(
"INSERT INTO questions(message_id, asker, to_name, question, options, fallback, created_at) "
"VALUES (?,?,?,?,?,?,?)", (msg["id"], asker, to, question.strip(), json.dumps(options), fallback, now)).lastrowid)
self._write(lambda c: c.execute("UPDATE messages SET refs=? WHERE id=?",
(json.dumps({**(self.message(msg["id"])["refs"] or {}), "question": qid}), msg["id"])))
return {**self.question(qid), "warnings": msg["warnings"]}
def question(self, qid):
row = self.conn.execute("SELECT * FROM questions WHERE id=?", (int(qid),)).fetchone()
if not row:
raise HarborError(f"no question #{qid}")
q = dict(row)
q["options"] = json.loads(q["options"])
return q
def questions(self, open_only=True, to=None, asker=None, limit=50):
query, args = "SELECT * FROM questions WHERE 1=1", []
if open_only:
query += " AND answer IS NULL"
if to:
query += " AND to_name=?"
args.append(to)
if asker:
query += " AND asker=?"
args.append(asker)
rows = self.conn.execute(query + " ORDER BY id DESC LIMIT ?", (*args, int(limit)))
return [self.question(r["id"]) for r in rows]
def questions_by_message(self, message_ids):
ids = [int(i) for i in message_ids]
out = {}
for start in range(0, len(ids), 500):
chunk = ids[start:start + 500]
for r in self.conn.execute(f"SELECT id FROM questions WHERE message_id IN ({','.join('?' * len(chunk))})", chunk):
q = self.question(r["id"])
out[q["message_id"]] = q
return out
def answer(self, by, qid, answer, origin="cli"):
q = self.question(qid)
if q["answer"] is not None:
raise HarborError(f"question #{qid} was already answered by {q['answered_by']}: {q['answer']}")
if by == self.human and origin != "web":
raise HarborError(f"only the chat page answers as {self.human}")
allowed = by == q["to_name"] or (q["to_name"] == self.human and by == q["fallback"] and
self.presence()["status"] != "here")
if not allowed:
raise HarborError(f"question #{qid} is for {q['to_name']}" + (
f"; {q['fallback']} may answer while {self.human} is away" if q["fallback"] else ""))
answer = " ".join(str(answer).split())
if not answer:
raise HarborError("empty answer")
if q["options"] and answer not in q["options"] and not answer.lower().startswith("other:"):
raise HarborError(f"answer with one of: {' / '.join(q['options'])} (or 'other: ...')")
now = self.now()
def claim_answer(c):
cur = c.execute("UPDATE questions SET answer=?, answered_by=?, answered_at=? WHERE id=? AND answer IS NULL",
(answer, by, now, int(qid)))
if cur.rowcount == 0:
raise HarborError(f"question #{qid} was answered meanwhile")
c.execute("UPDATE deliveries SET acked_at=COALESCE(acked_at, ?), read_at=COALESCE(read_at, ?) "
"WHERE message_id=? AND agent=?", (now, now, q["message_id"], q["to_name"]))
self._write(claim_answer)
people = [q["asker"]] + ([q["to_name"]] if by != q["to_name"] else []) # a fallback answer reaches the human
reply = self.send(by, people, f"Answer to question #{qid}: {answer}" + (
f" (answered by {by} while {q['to_name']} was away)" if by != q["to_name"] else ""),
reply_to=q["message_id"], kind="answer", importance="high", trusted_refs={"question": int(qid)},
origin="system")
self._write(lambda c: c.execute("UPDATE questions SET answer_message_id=? WHERE id=?", (reply["id"], int(qid))))
return self.question(qid)
# ----------------------------------------------------------------- notes and scope context
def note(self, author, scope, body, replaces=None):
self._check_agent(author)
canonical, lo, hi = parse_scope(scope)
body = (body or "").strip()
if not body:
raise HarborError("empty note")
now = self.now()
if replaces:
old = self.conn.execute("SELECT author FROM notes WHERE id=?", (int(replaces),)).fetchone()
if not old:
raise HarborError(f"no note #{replaces}")
if old["author"] != author and author not in self.coordinators:
raise HarborError(f"note #{replaces} is {old['author']}'s; add your own note instead")
def do(c):
nid = c.execute("INSERT INTO notes(scope, lo, hi, body, author, created_at) VALUES (?,?,?,?,?,?)",
(canonical, lo, hi, body, author, now)).lastrowid
if replaces:
c.execute("UPDATE notes SET replaced_by=? WHERE id=?", (nid, int(replaces)))
self._touch(c, author)
return nid
return {"id": self._write(do), "scope": canonical}
def notes(self, scope=None, include_replaced=False, limit=50):
rows = [dict(r) for r in self.conn.execute(
"SELECT * FROM notes" + ("" if include_replaced else " WHERE replaced_by IS NULL") + " ORDER BY id DESC")]
if scope:
target = parse_scope(scope)
rows = [r for r in rows if scopes_overlap(target, (r["scope"], r["lo"], r["hi"]))]
return rows[:limit]
def context(self, scope, limit=12):
"""Everything Harbor knows about a scope: notes, threads, messages that mention it, tasks, review items,
and who has claimed it before."""
canonical, lo, hi = parse_scope(scope)
target = (canonical, lo, hi)
needles = []
if lo is not None and lo == hi:
needles = [f"{lo:X}", f"0x{lo:X}"]
elif lo is None:
needles = [canonical.split(":", 1)[1]]
seen, messages = set(), []
for needle in needles:
for m in self.search(needle, limit=limit):
if m["id"] not in seen:
seen.add(m["id"])
messages.append(m)
messages.sort(key=lambda m: m["id"], reverse=True)
threads = [t for t in self.threads(limit=500, include_legacy=False) if t["topic"] == canonical]
tasks = [t for t in self.tasks(states=("open", "in_progress", "blocked", "review", "done", "cancelled"))
if t["scope"] and scopes_overlap(target, (t["scope"], t["lo"], t["hi"]))]
items = []
for row in self.conn.execute("SELECT * FROM items WHERE scope IS NOT NULL ORDER BY id DESC"):
try:
if scopes_overlap(target, parse_scope(row["scope"])):
items.append(dict(row))
except HarborError:
pass
claims = [dict(r) for r in self.conn.execute("SELECT * FROM claims ORDER BY id DESC LIMIT 2000")
if scopes_overlap(target, (r["scope"], r["lo"], r["hi"]))]
return {"scope": canonical, "notes": self.notes(canonical), "threads": threads, "messages": messages[:limit],
"tasks": tasks, "items": items, "claims": claims[:limit]}
def context_text(self, scope, limit=8):
ctx = self.context(scope, limit=limit)
lines = [f"What the team knows about {ctx['scope']}:"]
for n in ctx["notes"]:
lines.append(f" note #{n['id']} by {n['author']} ({n['created_at'][:10]}): {first_line(n['body'], 200)}")
for i in ctx["items"][:5]:
lines.append(f" review item #{i['id']} {i['state']} by {i['author']}: {i['title']}")
for t in ctx["tasks"][:5]:
lines.append(f" task #{t['id']} {t['state']} ({t['assignee'] or 'unassigned'}): {t['title']}")
if ctx["claims"]:
who = sorted({c["agent"] for c in ctx["claims"]})
lines.append(f" claimed before by: {', '.join(who)}")
for t in ctx["threads"][:3]:
lines.append(f" thread {t['topic']}: {t['messages']} messages (harbor thread {t['topic']})")
for m in ctx["messages"][:limit]:
lines.append(f" #{m['id']} {m['created_at'][:10]} {m['sender']}: {first_line(m['body'], 160)}")
for t in ctx["threads"][:3]:
s = self.summary(t["id"])
if s:
lines.append(f" summary of {t['topic']} by {s['author']}: {first_line(s['body'], 200)}")
experts = self.experts(ctx["scope"])
if experts:
lines.append(" who knows this: " + "; ".join(
f"{e['agent']} ({', '.join(f'{v} {k}' for k, v in e['detail'].items())})" for e in experts))
if len(lines) == 1:
lines.append(" nothing yet - add what you learn with `note`.")
return "\n".join(lines)
# ----------------------------------------------------------------- file touches and collisions
def _relative(self, agent, path, cwd=None):
"""A path as the team sees it: relative to the agent's workdir (worktrees of one repository compare equal)."""
raw = str(path).strip().strip('"').strip("'").replace("\\", "/")
p = PureWindowsPath(raw)
if not p.is_absolute() and cwd:
p = PureWindowsPath(cwd) / p
raw = str(p).replace("\\", "/")
roots = [a.get("workdir") for a in self.project.agents if a.get("workdir")]
spec = next((a for a in self.project.agents if a["name"] == agent), {})
ordered = ([spec.get("workdir")] if spec.get("workdir") else []) + roots + ([cwd] if cwd else [])
if p.is_absolute():
for root in ordered:
try:
return str(p.relative_to(PureWindowsPath(root))).replace("\\", "/").lower()
except ValueError:
continue
return raw.lower()
while raw.startswith("./"):
raw = raw[2:]
return raw.lower()
@staticmethod
def edited_paths(tool, tool_input):
"""Paths an edit-like tool call writes, from the common shapes of hook payloads."""
def strings(value):
if isinstance(value, str):
yield value
elif isinstance(value, dict):
for v in value.values():
yield from strings(v)
elif isinstance(value, list):
for v in value:
yield from strings(v)
if not EDIT_TOOL.search(str(tool or "")) or not isinstance(tool_input, dict):
return [m.strip() for s in strings(tool_input) for m in PATCH_FILE.findall(s)]
paths = []
for key in ("file_path", "filePath", "path", "notebook_path", "target_file"):
if isinstance(tool_input.get(key), str):
paths.append(tool_input[key])
for key in ("paths", "files"):
if isinstance(tool_input.get(key), list):
paths += [x for x in tool_input[key] if isinstance(x, str)]
for key in ("patch", "input", "command"):
value = tool_input.get(key)
if isinstance(value, list):
value = " ".join(map(str, value))
if isinstance(value, str):
paths += [m.strip() for m in PATCH_FILE.findall(value)]
return paths
def record_touches(self, agent, paths, tool="", cwd=None, window=None):
"""Remember that agent is editing paths; returns collisions [{path, other, minutes}] (and announces new ones)."""
if not paths:
return []
window = window or self.project.threshold("collision_window", 45)
now_dt = utc(self.clock())
now = iso(now_dt)
since = iso(now_dt - datetime.timedelta(minutes=window))
rel = sorted({self._relative(agent, p, cwd) for p in paths})
collisions = []
shared = self.is_shared_file
def do(c):
for path in rel:
c.execute("INSERT INTO touches(agent, path, tool, at) VALUES (?,?,?,?) ON CONFLICT(agent, path) DO UPDATE "
"SET tool=excluded.tool, at=excluded.at", (agent, path, str(tool)[:60], now))
if shared(path):
continue # append-only file the integrator merges: not a collision
for r in c.execute("SELECT agent, at FROM touches WHERE path=? AND agent!=? AND at>=?", (path, agent, since)):
collisions.append({"path": path, "other": r["agent"], "minutes": self.minutes_since(r["at"])})
self._write(do)
fresh = []
for col in collisions:
pair = "|".join(sorted([agent, col["other"]]))
key = f"collision:{pair}:{col['path']}"
row = self.conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
if row and self.minutes_since(row["value"]) < 120:
continue
self._write(lambda c, k=key: c.execute("INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE "
"SET value=excluded.value", (k, now)))
fresh.append(col)
people = sorted({col["other"], *self.project.coordinators} - {agent})
self.send(agent, people, f"Heads-up from Harbor: {agent} is editing {col['path']}, which {col['other']} "
f"edited {col['minutes']} min ago. Agree in this thread who changes what before either of you "
"commits, so the merge does not conflict.", thread=f"file:{col['path']}",
subject=f"Both editing {col['path']}", kind="finding", importance="high",
refs={"file": col["path"]}, origin="hook")
return collisions
def is_shared_file(self, path):
"""True for files the project lists in shared_files (e.g. annotations/*.csv): every agent appends to them on its
own branch and the integrator merges them, so two agents editing one is normal, not a collision."""
import fnmatch
p = str(path).replace("\\", "/")
return any(fnmatch.fnmatch(p, pattern) for pattern in self.project.shared_files)
def recent_collisions(self, minutes=120):
since = iso(utc(self.clock() - minutes * 60))
rows = self.conn.execute("SELECT a.path, a.agent a1, b.agent a2, MAX(a.at, b.at) at FROM touches a JOIN touches b "
"ON a.path=b.path AND a.agent<b.agent WHERE a.at>=? AND b.at>=?", (since, since))
return [dict(r) for r in rows if not self.is_shared_file(r["path"])]
# ----------------------------------------------------------------- pulse (activity timeline)
def _pulse(self, c, agent, tools=0, messages=0):
c.execute("INSERT INTO pulse(agent, bucket, tools, messages) VALUES (?,?,?,?) ON CONFLICT(agent, bucket) DO UPDATE "
"SET tools=tools+excluded.tools, messages=messages+excluded.messages",
(agent, bucket_of(utc(self.clock())), tools, messages))
def pulse(self, hours=12):
since = bucket_of(utc(self.clock() - hours * 3600))
out = {}
for r in self.conn.execute("SELECT * FROM pulse WHERE bucket>=? ORDER BY bucket", (since,)):
out.setdefault(r["agent"], []).append({"bucket": r["bucket"], "tools": r["tools"], "messages": r["messages"]})
return {"since": since, "now": self.now(), "agents": out}
def idle_minutes(self, agent):
row = self.conn.execute("SELECT MAX(bucket) b FROM pulse WHERE agent=?", (agent,)).fetchone()
return self.minutes_since(row["b"]) if row and row["b"] else None
# ----------------------------------------------------------------- metrics and wins
def metric(self, by, name, value, note=None):
self._check_agent(by)
name = re.sub(r"[^a-z0-9_.-]+", "-", str(name).strip().lower())[:40]
if not name:
raise HarborError("a metric needs a name")
try:
value = float(value)
except (TypeError, ValueError):
raise HarborError(f"metric value {value!r} is not a number")
if not math.isfinite(value):
raise HarborError("a metric value must be a finite number")
now = self.now()
mid = self._write(lambda c: c.execute("INSERT INTO metrics(name, value, note, by_agent, at) VALUES (?,?,?,?,?)",
(name, value, note, by, now)).lastrowid)
return {"id": mid, "name": name, "value": value}
def metrics(self, points=60):
out = {}
for r in self.conn.execute("SELECT DISTINCT name FROM metrics ORDER BY name"):
rows = [dict(x) for x in self.conn.execute("SELECT * FROM metrics WHERE name=? ORDER BY id DESC LIMIT ?",
(r["name"], int(points)))]
rows.reverse()
out[r["name"]] = {"latest": rows[-1], "series": [{"at": x["at"], "value": x["value"]} for x in rows]}
return out
def announce_win(self, item, agent, commit):
"""Tell the human, in the 'wins' thread, that an item landed."""
if agent == self.human:
return
text = f"Landed: {item['title']} by {item['author']}" + (f" ({item['scope']})" if item.get("scope") else "") + \
(f" in {commit}" if commit else "") + "."
self.send(agent, [self.human], text, thread="wins", subject="Wins", kind="status", refs={"item": item["id"]},
origin="system")
def export_thread(self, thread):
"""A thread as Markdown, for records and reports."""
info, messages = self.thread(thread)
lines = [f"# {info['subject'] or info['topic']}", "", f"Thread `{info['topic']}` in {self.project.title}, "
f"{len(messages)} messages, exported {self.now()}.", ""]
for m in messages:
to = ", ".join(m["recipients"])
tags = [t for t in (m["kind"] if m["kind"] != "note" else "", m["importance"] if m["importance"] != "normal"
else "", f"corrects #{m['corrects']}" if m.get("corrects") else "") if t]
lines.append(f"## #{m['id']} {m['sender']} → {to} · {m['created_at'].replace('T', ' ')[:16]} UTC"
+ (f" ({', '.join(tags)})" if tags else ""))
lines += ["", m["body"], ""]
return "\n".join(lines)
def wins(self, since="24h"):
from .util import parse_since
start = parse_since(since, utc(self.clock()))
return [dict(r) for r in self.conn.execute(
"SELECT e.*, i.title, i.author, i.scope FROM item_events e JOIN items i ON i.id=e.item_id "
"WHERE e.to_state='landed' AND e.at>=? ORDER BY e.id DESC", (start,))]
+255
View File
@@ -0,0 +1,255 @@
"""How work flows: git radar, bulk tasks and templates, the stuck-agent watchdog, and team policies.
git radar read-only git facts per agent workdir: branch, last commit, uncommitted files, commits behind the main
branch, and commits made since the agent last submitted work (work not yet in the review queue)
templates multi-step task pipelines defined in the project file, created in one call with dependencies
bulk many tasks from a pasted list ("0x445310 RouteCellCost"), routed to the band owner
watchdog an agent busy with tools for hours without any visible progress
policies rules from the project file that Harbor enforces (approvals before landing, message caps)
Mixed into Harbor (core.py).
"""
import re
import subprocess
import time
from pathlib import Path
from .util import HarborError, first_line, iso, parse_iso, parse_scope, scopes_overlap, utc
GIT_TIMEOUT = 10
_git_cache = {}
class _Values(dict):
"""Template placeholders; an unknown {name} stays as written instead of failing."""
def __missing__(self, key):
return "{" + key + "}"
def _git(workdir, *args):
"""A read-only git command (never takes locks, never writes the index)."""
# The worktree's own config is agent-controlled: never let it start programs (fsmonitor, pagers) from here.
r = subprocess.run(["git", "--no-pager", "--no-optional-locks", "-c", "core.fsmonitor=false",
"-c", "core.untrackedCache=false", "-C", str(workdir), *args], capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=GIT_TIMEOUT,
env={**__import__("os").environ, "GIT_TERMINAL_PROMPT": "0", "GIT_OPTIONAL_LOCKS": "0"})
if r.returncode != 0:
raise RuntimeError(r.stderr.strip() or f"git {' '.join(args)} failed")
return r.stdout
class FlowMixin:
def worktree_change(self, agent):
"""(mtime, path) of the most recently modified uncommitted file in agent's worktree, or None."""
spec = next((a for a in self.project.agents if a["name"] == agent), {})
workdir = spec.get("workdir")
if not workdir or not Path(workdir).is_dir():
return None
newest = None
for line in _git(workdir, "status", "--porcelain", "-uall").splitlines()[:3000]:
path = line[3:].split(" -> ")[-1].strip().strip('"')
try:
mtime = (Path(workdir) / path).stat().st_mtime
except OSError:
continue
if newest is None or mtime > newest[0]:
newest = (mtime, path)
return newest
# ----------------------------------------------------------------- git radar
def git_status(self, agent, max_age=60):
"""Git facts for agent's workdir (cached for max_age seconds)."""
spec = next((a for a in self.project.agents if a["name"] == agent), None)
workdir = spec and spec.get("workdir")
if not workdir or not Path(workdir).is_dir():
return None
key = (str(workdir), self.project.name)
hit = _git_cache.get(key)
if hit and time.monotonic() - hit[0] < max_age:
return hit[1]
main = self.project.git_main
out = {"agent": agent, "workdir": workdir}
try:
out["branch"] = _git(workdir, "rev-parse", "--abbrev-ref", "HEAD").strip()
sha, stamp, subject = (_git(workdir, "log", "-1", "--format=%h%x1f%ct%x1f%s").strip().split("\x1f") + ["", "", ""])[:3]
out["last_commit"] = {"sha": sha, "at": iso(utc(int(stamp))) if stamp else None, "subject": subject}
out["dirty"] = len([l for l in _git(workdir, "status", "--porcelain").splitlines() if l.strip()])
out["behind_main"] = out["ahead_of_main"] = 0
if out["branch"] != main:
try:
behind, ahead = _git(workdir, "rev-list", "--left-right", "--count", f"{main}...HEAD").split()
out["behind_main"], out["ahead_of_main"] = int(behind), int(ahead)
except (RuntimeError, ValueError):
out["main_missing"] = True
since = self._last_submit_at(agent)
# Author dates survive a rebase (commit dates do not), so rebased work does not look new again.
cutoff = since or iso(utc(self.clock() - 86400))
args = ["log", "--format=%h%x1f%at%x1f%s", "-200"]
if out["branch"] != main and not out.get("main_missing"):
args.append(f"{main}..HEAD")
commits = []
for line in _git(workdir, *args).splitlines():
parts = line.split("\x1f")
if len(parts) == 3 and iso(utc(int(parts[1]))) > cutoff:
commits.append({"sha": parts[0], "at": iso(utc(int(parts[1]))), "subject": parts[2]})
commits = commits[:50]
known = {r[0][:7] for r in self.conn.execute("SELECT commit_hash FROM items WHERE commit_hash IS NOT NULL")
if r[0]}
submits = out["branch"] != main and str(spec.get("git_submits", True)).lower() not in ("false", "0", "no")
out["unsubmitted"] = [c for c in commits if c["sha"][:7] not in known] if submits else []
out["since"] = since
except (RuntimeError, OSError, subprocess.SubprocessError, ValueError) as error:
out["error"] = str(error)[:200]
_git_cache[key] = (time.monotonic(), out)
return out
def _last_submit_at(self, agent):
row = self.conn.execute("SELECT MAX(at) a FROM item_events WHERE agent=? AND to_state='submitted'",
(agent,)).fetchone()
return row["a"] if row and row["a"] else None
def git_radar(self):
return {a["name"]: self.git_status(a["name"]) for a in self.project.agents
if a["kind"] != "human" and a.get("workdir")}
# ----------------------------------------------------------------- bulk tasks and templates
def band_owner(self, scope):
canonical, lo, hi = parse_scope(scope)
if lo is None:
return None
now = self.now()
for c in self.conn.execute("SELECT * FROM claims WHERE kind='band' AND released_at IS NULL AND "
"(expires_at IS NULL OR expires_at > ?)", (now,)):
if scopes_overlap((canonical, lo, hi), (c["scope"], c["lo"], c["hi"])):
return c["agent"]
return None
def run_template(self, by, template, scope=None, title=None, owner=None, priority="normal", origin="cli"):
"""Create the steps of a template as linked tasks. Placeholders: {scope}, {title}, {owner}."""
spec = self.project.templates.get(template)
if not spec:
raise HarborError(f"no template {template!r}; the project file defines: "
+ (", ".join(self.project.templates) or "none"))
owner = owner or (self._route(by, scope) if scope else None)
values = _Values(scope=scope or "", title=title or scope or template, owner=owner or "")
for step in spec.get("steps", []): # check every step before creating any
assignee = (step.get("assignee") or "").format_map(values) or None
if assignee:
self._check_agent(assignee, allow_human=False)
if assignee != by and by not in self.coordinators:
raise HarborError(f"template {template!r} assigns a step to {assignee}; only a coordinator does that")
created, by_name = [], {}
for step in spec.get("steps", []):
assignee = (step.get("assignee") or "").format_map(values) or None
after = [by_name[n] for n in step.get("after", []) if n in by_name]
task = self.add_task(by, step.get("title", "{title}").format_map(values), body=step.get("body"),
scope=scope if step.get("scoped", True) else None, assignee=assignee,
priority=step.get("priority", priority), after=after, origin=origin)
by_name[step.get("name", str(len(by_name)))] = task["id"]
created.append(task)
return created
LINE = re.compile(r"^\s*(?:fn:)?(0x[0-9A-Fa-f]+)\b[\s:|,-]*(.*)$")
def bulk_tasks(self, by, text, template=None, priority="normal", assignee=None, origin="cli"):
"""One task (or template pipeline) per line: '0x445310 RouteCellCost' or 'fn:0x445310 | Make it readable'.
Lines without an address become plain tasks. Each address goes to its band owner unless assignee is set."""
if assignee and assignee != by and by not in self.coordinators:
raise HarborError("only a coordinator assigns tasks to someone else")
created, skipped = [], []
open_scopes = {t["scope"] for t in self.tasks(states=("open", "in_progress", "blocked", "review"), limit=100000)}
for raw in str(text).splitlines(): # validate everything first: no half-created batches
m = self.LINE.match(raw.strip()) if raw.strip() and not raw.strip().startswith("#") else None
if m:
parse_scope(f"fn:{m.group(1)}")
for raw in str(text).splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
m = self.LINE.match(line)
scope = f"fn:{m.group(1)}" if m else None
name = (m.group(2).strip() if m else line) or None
if scope:
canonical = parse_scope(scope)[0]
if canonical in open_scopes:
skipped.append(f"{canonical} already has an open task")
continue
open_scopes.add(canonical)
if template:
created += self.run_template(by, template, scope=scope, title=name, owner=assignee, priority=priority,
origin=origin)
else:
title = (f"Make {scope.split(':', 1)[1]} readable" + (f" ({name})" if name else "")) if scope else name
created.append(self.add_task(by, title, scope=scope,
assignee=assignee or (self._route(by, scope) if scope else None),
priority=priority, origin=origin, notify=template is not None))
if not template: # one notice per agent, not one thread per task
mine = {}
for t in created:
if t.get("assignee") and t["assignee"] != by:
mine.setdefault(t["assignee"], []).append(t)
for agent, items in mine.items():
lines = "\n".join(f"#{t['id']} {t['title']}" for t in items)
self.send(by, [agent], f"{len(items)} new task{'s' if len(items) > 1 else ''} for you; take them "
f"with next:\n{lines}", thread="tasks", subject="New tasks", kind="request",
origin="system")
return {"created": created, "skipped": skipped}
def _route(self, by, scope):
"""The band owner of scope - when the caller may assign to them (a coordinator, or the owner itself)."""
owner = self.band_owner(scope)
return owner if owner and (by in self.coordinators or owner == by) else None
# ----------------------------------------------------------------- stuck-agent watchdog
def busy_without_progress(self, hours=2, min_tools=150):
"""Agents with many tool calls in the window but no message, submit, review move, task change, handoff or
new `doing` line - possibly stuck in a loop."""
since = iso(utc(self.clock() - hours * 3600))
out = []
for a in self.agents():
if a["kind"] == "human" or self.stopped(a["name"]):
continue
tools = self.conn.execute("SELECT COALESCE(SUM(tools), 0) FROM pulse WHERE agent=? AND bucket>=?",
(a["name"], since)).fetchone()[0]
if tools < min_tools:
continue
progress = any(self.conn.execute(sql, (a["name"], since)).fetchone()[0] for sql in (
"SELECT COUNT(*) FROM messages WHERE sender=? AND created_at>=? AND origin NOT IN ('hook', 'system')",
"SELECT COUNT(*) FROM item_events WHERE agent=? AND at>=?",
"SELECT COUNT(*) FROM task_events WHERE agent=? AND at>=?",
"SELECT COUNT(*) FROM handoffs WHERE agent=? AND created_at>=?",
"SELECT COUNT(*) FROM agents WHERE name=? AND doing_at>=?"))
if not progress:
out.append({"agent": a["name"], "tools": tools, "hours": hours})
return out
# ----------------------------------------------------------------- policies
def policy(self, key, default=None):
return self.project.policy.get(key, default)
def check_landing_policy(self, item_id):
required = list(self.policy("land_requires") or [])
author = self.conn.execute("SELECT author FROM items WHERE id=?", (int(item_id),)).fetchone()
required = [r for r in required if not author or r != author["author"]] # authors cannot approve
if not required:
return
last_submit = self.conn.execute("SELECT MAX(id) FROM item_events WHERE item_id=? AND to_state='submitted'",
(int(item_id),)).fetchone()[0] or 0
approvers = {r["agent"] for r in self.conn.execute(
"SELECT agent FROM item_events WHERE item_id=? AND to_state='approved' AND id>?", (int(item_id), last_submit))}
missing = [r for r in required if r not in approvers]
if missing:
raise HarborError(f"policy: item #{item_id} needs approval from {', '.join(missing)} before it lands "
"(since its last submission)")
def check_message_policy(self, sender, to_all):
cap = self.policy("all_per_hour")
if not cap or not to_all or sender in self.coordinators:
return
since = iso(utc(self.clock() - 3600))
count = self.conn.execute("SELECT COUNT(*) FROM messages WHERE sender=? AND created_at>=? AND "
"recipients='[\"All\"]' AND origin NOT IN ('legacy', 'system', 'hook')",
(sender, since)).fetchone()[0]
if count >= int(cap):
raise HarborError(f"policy: at most {cap} messages to everyone per hour - use `doing` for progress, or "
"a thread with the people concerned")
+335
View File
@@ -0,0 +1,335 @@
"""Entry points for agent hosts' hook systems:
harbor --as <Agent> [--project <name>] hook <event> --host claude|codex|deepseek|kimi|opencode|generic
The host passes its hook payload as JSON on stdin (cwd, tool name, ...). The project comes from --project,
$HARBOR_PROJECT or the .harbor.toml above the payload's working directory - never the home's default project, so
hooks installed globally in a host stay silent in folders that are not Harbor projects.
session-start put the brief in the agent's context
prompt-submit on each user prompt: a notice when messages or a stop are waiting (Kimi: the brief itself,
because Kimi drops session-start output)
pre-tool refuse the tool call while a STOP applies to the agent (exit 2, reason on stderr). Harbor's own
tools and plain harbor commands stay allowed so a stopped agent can still read and acknowledge
stop end of a turn: when urgent or acknowledgement-requested messages arrived that the agent has not
been nudged about yet, keep it going once so it reads them (exit 2, reason on stderr)
idle print JSON {unread, urgent, stopped, prompt}; prompt is set only for messages not yet nudged about
(the OpenCode plugin injects it into the idle session)
turn-end record presence only
Output formats, from the hosts' own code (see integrations/README.md):
claude, codex, deepseek context as {"hookSpecificOutput": {"hookEventName": ..., "additionalContext": ...}}
kimi, generic context as plain stdout
claude, codex, deepseek, kimi
decisions as Claude-compatible JSON at exit 0 - {"hookSpecificOutput": {"permissionDecision":
"deny", ...}} before a tool, {"decision": "block", "reason": ...} at a stop - because a
host that runs the command through PowerShell turns exit code 2 into 1
opencode, generic decisions as exit code 2 with the reason on stderr (the OpenCode plugin spawns directly)
Hooks fail open: if Harbor itself is broken they print a warning to stderr and let the agent continue.
"""
import json
import os
import re
import sys
from . import projects
from .core import Harbor
BLOCK = 2
JSON_CONTEXT_HOSTS = ("claude", "codex", "deepseek")
JSON_DECISION_HOSTS = ("claude", "codex", "deepseek", "kimi")
EVENT_NAMES = {"session-start": "SessionStart", "prompt-submit": "UserPromptSubmit"}
def _payload():
try:
if sys.stdin is None or sys.stdin.isatty():
return {}
text = sys.stdin.read()
data = json.loads(text) if text.strip() else {}
return data if isinstance(data, dict) else {}
except (ValueError, OSError):
return {}
# While stopped, an agent may still talk and read through Harbor - and nothing else.
ALLOWED_COMMANDS = {"brief", "inbox", "ack", "thread", "threads", "search", "tail", "status", "whoami", "queue", "claims",
"tasks", "pins", "decisions", "handoffs", "handoff", "item", "pending-acks", "wait", "doing", "send",
"reply", "answer", "questions", "context", "notes", "note", "digest", "attention", "stop", "who",
"feed", "summarize", "git", "templates", "pulse", "metrics", "wins"}
ALLOWED_TOOLS = {"brief", "inbox", "ack", "thread", "threads", "search", "status", "queue", "claims", "tasks", "handoff",
"send", "reply", "wait_for_messages", "doing", "questions", "answer", "context", "notes", "note",
"digest", "pin", "close_thread", "stop", "summarize", "who_knows", "git_status"}
TOOL_NAME = re.compile(r"^(?:mcp__harbor__|harbor[_.])([a-z_]+)$")
# A host's own tool for loading other tools' schemas (Kimi's select_tools, Claude Code's ToolSearch). It runs nothing,
# and a stopped agent needs it to reach Harbor's MCP tools at all.
LOADER_TOOLS = {"select_tools", "toolsearch", "tool_search", "search_tools"}
SHELL_FLAGS = {"bash": {"-c", "-lc"}, "sh": {"-c"}, "zsh": {"-c"}, "cmd": {"/c", "/d"}, "powershell": {"-command", "-c"},
"pwsh": {"-command", "-c"}}
CURLY_QUOTES = "“”„‟" # PowerShell reads these as double quotes; bash and cmd do not
FORBIDDEN = set("\n\r&|;<>`$%^(){}@" + CURLY_QUOTES)
IN_QUOTES = set("\n\r`$%" + CURLY_QUOTES) # what bash, PowerShell or cmd still act on inside double quotes
TOKEN = re.compile(r'"[^"]*"|\'[^\']*\'|\S+')
def _norm(path):
return str(path).strip().strip('"').strip("'").replace("\\", "/").lower()
def _scripts():
from .projects import PACKAGE_ROOT
return {_norm(PACKAGE_ROOT / "run.py"), _norm(PACKAGE_ROOT.parent / "harbor.py")}
def _command_text(tool_input):
"""The command a shell tool will run. Shell wrappers are unwrapped only in the exact form
[shell, flag, script]; anything else is taken literally (and then fails the checks)."""
command = tool_input.get("command") if isinstance(tool_input, dict) else None
if isinstance(command, list):
parts = [str(x) for x in command]
shell = parts[0].replace("\\", "/").split("/")[-1].lower().removesuffix(".exe") if parts else ""
if len(parts) == 3 and parts[1].lower() in SHELL_FLAGS.get(shell, ()):
return parts[2]
return " ".join(parts)
return command if isinstance(command, str) else None
def _harbor_tool(name):
"""The Harbor tool a host's MCP tool name refers to, or None. Hosts prefix tools in their own way (mcp__harbor__send,
harbor_send, harbor.send, mcp__harbor-open-seas__send, ...); the part before the tool must name Harbor."""
n = name.lower()
m = TOOL_NAME.match(n)
if m:
return m.group(1)
for sep in ("__", ".", ":", "/"):
if sep in n:
head, tail = n.rsplit(sep, 1)
if "harbor" in head and re.fullmatch(r"[a-z_]+", tail):
return tail.removeprefix("harbor_")
return None
return None
def _unsafe(command):
"""True if a character any shell acts on appears where a shell would act on it. Inside double quotes bash,
PowerShell and cmd all read ( ) ; & | < > { } @ ^ as plain text, so a message may say "fixed (finally)"; $, `, %
and line breaks still act there, so they are refused anywhere. cmd does not know single quotes, so single-quoted
text is checked as if unquoted, and it may not hold a double quote (bash would read that one literally, the other
shells would start a string). A backslash next to a quote could move where a string ends, so it is refused."""
i, n = 0, len(command)
while i < n:
ch = command[i]
if ch in "\"'":
j = command.find(ch, i + 1)
if j < 0:
return True
part = command[i + 1:j]
if ch == '"' and (IN_QUOTES & set(part) or part.endswith("\\")):
return True
if ch == "'" and ('"' in part or FORBIDDEN & set(part)):
return True
i = j + 1
continue
if ch in FORBIDDEN or (ch == "\\" and command[i + 1:i + 2] in ('"', "'")):
return True
i += 1
return False
def _tool_is_harbor(payload):
"""True only for Harbor's own talk-and-read operations: an MCP tool of the harbor server from the allowed list,
or one plain harbor command - an exact Harbor launcher, an allowed subcommand, and no character any shell
treats specially (pipes, chaining, redirection, substitution, sub-expressions, variables, newlines)."""
name = str(payload.get("tool_name") or payload.get("tool") or payload.get("name") or "").strip()
if name.lower() in LOADER_TOOLS:
return True
tool = _harbor_tool(name)
if tool is not None:
return tool in ALLOWED_TOOLS
tool_input = payload.get("tool_input") or payload.get("input") or payload.get("args") or {}
command = _command_text(tool_input)
if not command or _unsafe(command):
return False
tokens = TOKEN.findall(command.strip())
if not tokens:
return False
first = _norm(tokens[0]).split("/")[-1]
if first in ("harbor", "harbor.exe"):
rest = tokens[1:]
elif first in ("py", "py.exe", "python", "python.exe", "python3", "python3.exe"):
i = 1
while i < len(tokens) and re.fullmatch(r"-(?:3(?:\.\d+)?|B|u|E|s|I)", tokens[i]):
i += 1
if i >= len(tokens) or _norm(tokens[i]) not in _scripts():
return False
rest = tokens[i + 1:]
else:
return False
while rest and rest[0] in ("--as", "--project", "--json"):
rest = rest[2:] if rest[0] != "--json" else rest[1:]
return bool(rest) and rest[0].lower() in ALLOWED_COMMANDS
def _deny_tool(host, text):
if host in JSON_DECISION_HOSTS:
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny",
"permissionDecisionReason": text}}))
return 0
print(text, file=sys.stderr)
return BLOCK
def _keep_going(host, text):
if host in JSON_DECISION_HOSTS:
print(json.dumps({"decision": "block", "reason": text}))
return 0
print(text, file=sys.stderr)
return BLOCK
def _context(host, event, text):
if host in JSON_CONTEXT_HOSTS:
print(json.dumps({"hookSpecificOutput": {"hookEventName": EVENT_NAMES[event], "additionalContext": text}}))
else:
print(text)
def run(event, project_spec, agent, host="generic"):
payload = _payload()
try:
cwd = payload.get("cwd") or payload.get("directory") or os.getcwd()
if isinstance(project_spec, projects.Project):
project = project_spec
else:
try:
project = projects.resolve(project_spec, cwd=cwd, use_default=False)
except projects.ProjectError:
if project_spec or os.environ.get("HARBOR_PROJECT"):
raise
return 0 # not a Harbor project: stay out of the way
agent = agent or os.environ.get("HARBOR_AGENT") or project.identity_for(cwd)
if not agent or agent not in project.ai_names:
return 0
harbor = Harbor(project, busy_ms=1500, write_attempts=2)
try:
return _run(event, harbor, agent, host, payload)
finally:
harbor.close()
except Exception as error: # never break the host because of Harbor
print(f"harbor hook {event}: {error}", file=sys.stderr)
return 0
def _stop_text(harbor, agent, stops):
f = stops[0]
return (f"STOP in force for {agent} ({f['key']}, set by {f['set_by']} at {f['set_at']}"
+ (f": {f['reason']}" if f["reason"] else "") + f"). Do not start or continue work; only {harbor.human} "
"can resume. You may still use Harbor to read and talk: brief, inbox, ack, send, reply, answer, doing, "
"handoff and the other Harbor tools, either the Harbor MCP tools or one plain harbor command per call. "
"Put message text in double quotes, without $, backticks or %.")
HANDOFF_EVERY_MINUTES = 240
def handoff_reminder(harbor, agent):
"""Once every 4 hours of real work without a handoff, ask for one at the end of a turn."""
last = harbor.last_handoff(agent)
if last and harbor.minutes_since(last["created_at"]) < HANDOFF_EVERY_MINUTES:
return None
since = harbor.pulse(hours=HANDOFF_EVERY_MINUTES / 60)["agents"].get(agent, [])
if sum(b["tools"] for b in since) < 30:
return None
key = f"handoff_nudge:{agent}"
row = harbor.conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
if row and harbor.minutes_since(row["value"]) < HANDOFF_EVERY_MINUTES:
return None
harbor._write(lambda c: c.execute("INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET "
"value=excluded.value", (key, harbor.now())))
age = f"{harbor.minutes_since(last['created_at']) // 60} h old" if last else "missing"
return ("Before you finish: your Harbor handoff is " + age + ". Write a short one now (`handoff`: what is "
"finished, what is in flight with branch and files, what comes next), then finish.")
def _run(event, harbor, agent, host, payload):
stops = harbor.stopped(agent)
if event == "session-start":
harbor.connect(agent, "hook", host)
text = harbor.brief(agent)
text += ("\n\nThis folder is part of the Harbor project above. Use Harbor for all team communication "
"(threads, ack, claims, review queue); set `doing` when you start something; call brief again "
"after long work.")
_context(host, event, text)
return 0
if event == "pre-tool":
tool = payload.get("tool_name") or payload.get("tool") or payload.get("name") or "tool"
result = 0
if stops and not _tool_is_harbor(payload):
result = _deny_tool(host, _stop_text(harbor, agent, stops)) # decide from reads, before any write
sys.stdout.flush()
try: # bookkeeping is best effort
from .launch import describe_target
tool_input = payload.get("tool_input") or payload.get("input") or {}
paths = harbor.edited_paths(tool, tool_input)
harbor.activity(agent, tool, host, target=describe_target(tool, tool_input, paths))
if not stops:
harbor.record_touches(agent, paths, tool, cwd=payload.get("cwd"))
except Exception as error: # noqa: BLE001
print(f"harbor hook: {error}", file=sys.stderr)
return result
if event == "prompt-submit":
total, urgent = harbor.unread_count(agent)
owed = harbor.conn.execute("SELECT COUNT(*) FROM deliveries d JOIN messages m ON m.id=d.message_id "
"WHERE d.agent=? AND m.ack_required=1 AND d.acked_at IS NULL", (agent,)).fetchone()[0]
if host == "kimi":
if total or stops or owed:
print(harbor.brief(agent))
else:
harbor.connect(agent, "hook", host)
return 0
harbor.connect(agent, "hook", host)
notes = []
if stops:
notes.append(_stop_text(harbor, agent, stops))
if total:
notes.append(f"Harbor: {total} unread message(s) for you" + (f", {urgent} urgent" if urgent else "")
+ ". Read them (Harbor inbox) before continuing.")
if notes:
_context(host, event, " ".join(notes))
return 0
if event == "stop":
harbor.connect(agent, "hook", host)
if stops:
return 0 # the human's stop: always let it rest
if not payload.get("stop_hook_active"):
needing = harbor.nudge_candidates(agent, urgent_only=True)
if needing:
harbor.record_nudge(agent, [m["id"] for m in needing])
ids = ", ".join(f"#{m['id']} from {m['sender']}" for m in needing[:8])
return _keep_going(host, f"Before you finish: Harbor has messages for you that need attention ({ids}). "
"Read them with the Harbor inbox, answer or acknowledge them, then go on.")
reminder = handoff_reminder(harbor, agent)
if reminder:
return _keep_going(host, reminder)
text = harbor.continuation(agent) # autopilot; its own loop guard rests a stuck agent
return _keep_going(host, text) if text else 0
if event == "idle":
total, urgent = harbor.unread_count(agent)
prompt = None
if not stops:
fresh = harbor.nudge_candidates(agent)
if fresh:
harbor.record_nudge(agent, [m["id"] for m in fresh])
prompt = (f"Harbor: {len(fresh)} new message(s) for you" + (f" ({urgent} urgent)" if urgent else "")
+ ". Read them with the Harbor inbox tool and act on anything addressed to you.")
else:
prompt = harbor.continuation(agent) # autopilot for hosts that wake idle sessions
print(json.dumps({"agent": agent, "unread": total, "urgent": urgent, "stopped": [f["key"] for f in stops],
"prompt": prompt}))
return 0
if event == "turn-end":
harbor.connect(agent, "hook", host)
return 0
print(f"harbor hook: unknown event {event!r}", file=sys.stderr)
return 0
+395
View File
@@ -0,0 +1,395 @@
"""Connect agent hosts to Harbor: `harbor install <host> --agent <Name> [--project <name>] [--dry-run|--uninstall]`.
Hosts and what gets written (every edited file is backed up first; Harbor's part sits between marker lines so it
can be replaced or removed without touching anything else):
codex ~/.codex/config.toml [mcp_servers.harbor] (Harbor's MCP tools)
~/.codex/hooks.json SessionStart, UserPromptSubmit, PreToolUse, Stop -> `harbor hook ...`
~/.codex/AGENTS.md the Harbor protocol, applied only inside Harbor projects
Codex runs hooks only after you trust them: Settings > Hooks > Trust.
opencode ~/.config/opencode/opencode.jsonc "mcp": {"harbor": ...}
~/.config/opencode/plugin/harbor.js STOP gate, session brief, idle nudges
~/.config/opencode/AGENTS.md
deepseek ~/.dsh/profiles/desktop/cordis.patch.yml MCP client row + Claude-format hooks bridge row
<harbor home>/integrations/deepseek/hooks.json
~/.dsh/AGENTS.md
MCP tools hot-reload; the hooks file is read when DeepSeek Harness starts.
kimi <harbor home>/integrations/kimi/harbor-<project>/ a Kimi personal plugin (MCP, hooks, system prompt),
validated and registered with Kimi's own tools; you install it from Kimi's Plugins > Personal tab.
Kimi works outside the project folder, so its plugin is bound to one project.
claude prints how to add the Claude Code plugin in integrations/claude-code.
Hooks resolve the project from the agent's working directory (or --project when bound), and stay silent in folders
that are not Harbor projects, so installing them in a host used for other work is harmless.
"""
import datetime
import json
import os
import re
import shutil
import subprocess
import sys
import tomllib
from pathlib import Path
from . import projects
from .texts import launcher, protocol as agents_section # noqa: F401
PACKAGE_ROOT = projects.PACKAGE_ROOT
INTEGRATIONS = PACKAGE_ROOT / "integrations"
START, END = "# >>> harbor >>>", "# <<< harbor <<<"
MD_START, MD_END = "<!-- >>> harbor >>> -->", "<!-- <<< harbor <<< -->"
HOSTS = ("codex", "opencode", "deepseek", "kimi", "claude")
DEFAULT_AGENT = {"codex": "Codex", "opencode": "OpenCode", "deepseek": "DeepSeek", "kimi": "Kimi", "claude": "Claude"}
class InstallError(Exception):
pass
def user_home():
return Path(os.environ.get("HARBOR_INSTALL_HOME") or Path.home())
def identity_args(agent, project):
return ["--as", agent] + (["--project", project] if project else [])
class Plan:
"""Collects file writes so --dry-run can show them and a real run can back up and apply them."""
def __init__(self, host, dry_run):
self.host = host
self.dry_run = dry_run
self.writes = []
self.notes = []
self.stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
def write(self, path, text, validate=None):
if validate and text is not None:
validate(text)
self.writes.append((Path(path), text))
def remove(self, path):
self.writes.append((Path(path), None))
def apply(self):
report = []
for path, text in self.writes:
old = path.read_text(encoding="utf-8") if path.exists() else None
if old == text:
report.append(f"unchanged {path}")
continue
action = "remove" if text is None else ("update" if old is not None else "create")
report.append(f"{action:<10} {path}")
if self.dry_run:
continue
if old is not None:
backup = projects.harbor_home() / "backups" / "integrations" / self.stamp / self.host / path.name
backup.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, backup)
report[-1] += f" (backup: {backup})"
if text is None:
path.unlink()
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8", newline="\n")
return report + self.notes
def replace_block(text, block, start=START, end=END):
"""Put block between the markers (replacing an earlier Harbor block); block=None removes it."""
pattern = re.compile(re.escape(start) + r".*?" + re.escape(end) + r"\n?", re.S)
text = pattern.sub("", text or "")
if block is None:
return text.rstrip("\n") + "\n" if text.strip() else ""
return (text.rstrip("\n") + "\n\n" if text.strip() else "") + f"{start}\n{block.rstrip()}\n{end}\n"
def _hook_entries(cmd, agent, project, host, events):
base = " ".join(cmd + identity_args(agent, project) + ["hook"])
names = {"SessionStart": "session-start", "UserPromptSubmit": "prompt-submit", "PreToolUse": "pre-tool",
"Stop": "stop"}
return {event: [{"hooks": [{"type": "command", "command": f"{base} {names[event]} --host {host}", "timeout": 30}]}]
for event in events}
def _is_ours(entry):
return "run.py" in json.dumps(entry) and " hook " in json.dumps(entry)
def merge_hooks_json(text, ours, remove=False):
data = json.loads(text) if text and text.strip() else {}
hooks = data.setdefault("hooks", {})
for event in list(hooks):
hooks[event] = [e for e in hooks[event] if not _is_ours(e)]
if not hooks[event]:
del hooks[event]
if not remove:
for event, entries in ours.items():
hooks.setdefault(event, []).extend(entries)
if not hooks:
data.pop("hooks")
return json.dumps(data, indent=2) + "\n"
def _read(path):
return path.read_text(encoding="utf-8") if path.exists() else ""
def _md_block(path, section, remove):
return replace_block(_read(path), None if remove else section, MD_START, MD_END)
# ----------------------------------------------------------------------------------------------- hosts
def install_codex(plan, agent, project, cmd, remove):
home = user_home() / ".codex"
config = home / "config.toml"
text = _read(config)
outside = replace_block(text, None)
if re.search(r"^\[mcp_servers\.harbor\]", outside, re.M):
raise InstallError(f"{config} already defines [mcp_servers.harbor] outside Harbor's block; remove it first")
if re.search(r"^\[hooks\]", outside, re.M):
raise InstallError(f"{config} has a [hooks] table; Codex refuses hooks in both config.toml and hooks.json")
args = cmd[1:] + identity_args(agent, project) + ["mcp"]
env = {"HARBOR_AGENT": agent, "HARBOR_HOST": "codex", **({"HARBOR_PROJECT": project} if project else {})}
block = (f"# Managed by `harbor install codex`; remove with `harbor install codex --uninstall`.\n"
f"[mcp_servers.harbor]\ncommand = {json.dumps(cmd[0])}\nargs = {json.dumps(args)}\n"
f"startup_timeout_sec = 30\ntool_timeout_sec = 1900\n\n[mcp_servers.harbor.env]\n"
+ "".join(f"{k} = {json.dumps(v)}\n" for k, v in env.items()))
plan.write(config, replace_block(text, None if remove else block), validate=tomllib.loads)
hooks_path = home / "hooks.json"
ours = _hook_entries(cmd, agent, project, "codex", ("SessionStart", "UserPromptSubmit", "PreToolUse", "Stop"))
merged = merge_hooks_json(_read(hooks_path), ours, remove=remove)
plan.write(hooks_path, merged if json.loads(merged) else None, validate=json.loads)
plan.write(home / "AGENTS.md", _md_block(home / "AGENTS.md", agents_section(agent, project, cmd), remove))
if not remove:
plan.notes.append("Codex: open Settings > Hooks and trust the Harbor hooks (until then Codex skips them). "
"The MCP tools load in new Codex threads.")
def _jsonc_load(text):
if re.search(r"(^|\s)//|/\*", re.sub(r'"(?:\\.|[^"\\])*"', '""', text)):
raise InstallError("opencode.jsonc has comments; add the \"mcp\" entry by hand (see `--dry-run` output)")
return json.loads(text) if text.strip() else {}
def install_opencode(plan, agent, project, cmd, remove):
home = user_home() / ".config" / "opencode"
config = next((p for p in (home / "opencode.jsonc", home / "opencode.json") if p.exists()), home / "opencode.jsonc")
data = _jsonc_load(_read(config))
mcp = data.setdefault("mcp", {})
mcp.pop("harbor", None)
if not remove:
env = {"HARBOR_AGENT": agent, "HARBOR_HOST": "opencode", **({"HARBOR_PROJECT": project} if project else {})}
mcp["harbor"] = {"type": "local", "command": cmd + identity_args(agent, project) + ["mcp"],
"environment": env, "enabled": True, "timeout": 15000}
if not mcp:
data.pop("mcp")
plan.write(config, json.dumps(data, indent=2) + "\n", validate=json.loads)
plugin = home / "plugin" / "harbor.js"
if remove:
plan.remove(plugin) if plugin.exists() else None
else:
source = (INTEGRATIONS / "opencode" / "harbor.js").read_text(encoding="utf-8")
settings = (f"const AGENT = {json.dumps(agent)};\nconst PROJECT = {json.dumps(project)};\n"
f"const COMMAND = {json.dumps(cmd)};\nconst WAKE_IDLE_SESSIONS = true; // nudge idle sessions "
"when messages arrive (never while stopped)\n")
source = re.sub(r"(// >>> harbor settings >>>\n).*?(// <<< harbor settings <<<)",
lambda m: m.group(1) + settings + m.group(2), source, flags=re.S)
plan.write(plugin, source)
plan.write(home / "AGENTS.md", _md_block(home / "AGENTS.md", agents_section(agent, project, cmd), remove))
if not remove:
plan.notes.append("OpenCode: restart OpenCode to load the plugin and the MCP server.")
class _TolerantLoader:
@staticmethod
def load(text):
import yaml
class Loader(yaml.SafeLoader):
pass
Loader.add_multi_constructor("", lambda loader, suffix, node: None)
Loader.add_multi_constructor("tag:yaml.org,2002:js", lambda loader, suffix, node: None)
value = yaml.load(text, Loader=Loader)
if not isinstance(value, list):
raise InstallError("cordis.patch.yml must stay a YAML list")
return value
def install_deepseek(plan, agent, project, cmd, remove):
home = user_home() / ".dsh"
patch = home / "profiles" / "desktop" / "cordis.patch.yml"
if not patch.exists() and not remove:
raise InstallError(f"{patch} not found: is DeepSeek Harness set up for this user?")
hooks_path = projects.harbor_home() / "integrations" / "deepseek" / "hooks.json"
args = cmd[1:] + identity_args(agent, project) + ["mcp"]
env = {"HARBOR_AGENT": agent, "HARBOR_HOST": "deepseek", **({"HARBOR_PROJECT": project} if project else {})}
q = lambda s: "'" + str(s).replace("'", "''") + "'"
block = ("# Managed by `harbor install deepseek`: Harbor's MCP tools and hooks.\n- insert:\n"
" - id: mcp-harbor\n name: '@deepseek-ai/dsh-mcp-client'\n config:\n"
f" serverName: harbor\n transport: stdio\n command: {q(cmd[0])}\n"
f" args: [{', '.join(q(a) for a in args)}]\n"
f" env: {{{', '.join(f'{k}: {q(v)}' for k, v in env.items())}}}\n"
" toolCallTimeoutMs: 1900000\n"
" - id: hooks-harbor\n name: '@deepseek-ai/dsh-hooks-claude-code'\n config:\n"
f" configPath: {q(hooks_path.as_posix())}\n defaultTimeoutMs: 30000\n")
text = replace_block(_read(patch), None if remove else block)
plan.write(patch, text if text.strip() else "[]\n", validate=_TolerantLoader.load)
if remove:
plan.remove(hooks_path) if hooks_path.exists() else None
else:
ours = _hook_entries(cmd, agent, project, "deepseek", ("SessionStart", "UserPromptSubmit", "PreToolUse", "Stop"))
plan.write(hooks_path, json.dumps({"hooks": ours}, indent=2) + "\n", validate=json.loads)
plan.write(home / "AGENTS.md", _md_block(home / "AGENTS.md", agents_section(agent, project, cmd), remove))
if not remove:
plan.notes.append("DeepSeek Harness: the MCP tools hot-reload; restart the app once to load the hooks file.")
def kimi_share():
return Path(os.environ.get("KIMI_SHARE_DIR") or Path(os.environ.get("APPDATA", user_home())) / "kimi-desktop" / "daimon-share")
def install_kimi(plan, agent, project, cmd, remove):
if not project:
project = projects.resolve().name
folder = projects.harbor_home() / "integrations" / "kimi" / f"harbor-{project}"
name = f"harbor-{project}"
if remove:
for path in sorted(folder.rglob("*"), reverse=True) if folder.exists() else []:
if path.is_file():
plan.remove(path)
plan.notes.append(f"Kimi: uninstall '{name}' from Plugins > Personal in Kimi as well.")
return
base = " ".join(cmd + identity_args(agent, project) + ["hook"])
title = projects.load_named(project).title
manifest = {
"$schema": "https://catalog.msh.team/misc/kimi.plugin.schema.json",
"name": name, "version": "1.3.0",
"description": f"Connects Kimi to the Harbor team hub for {title}: messages, acknowledgements, claims, the "
"review queue and the stop switch shared with the other agents.",
"keywords": ["harbor", "team", "coordination"], "author": "Harbor", "license": "MIT",
"skillInstructions": "Use the Harbor tools for all team communication on this project.",
"interface": {
"displayName": f"Harbor: {title}", "shortDescription": f"Team coordination for {title}",
"longDescription": f"Harbor tools, session brief, stop gate and message nudges for {title}.",
"developerName": "Harbor", "websiteURL": "", "iconUrl": "", "category": "PRODUCTIVITY",
"defaultLocale": "en-US", "locales": "./locales/",
"tryPrompts": ["Check Harbor for messages", "What is the team doing right now?"],
"skillOverrides": {name: {"displayName": f"Harbor: {title}", "description": f"Team coordination for {title}"}},
"hostKind": "local", "platforms": ["windows", "macos", "linux"],
"mcpOverrides": {"harbor": {"displayName": "Harbor", "description": f"Team coordination for {title}"}},
},
"skills": "./skills/",
"mcpServers": {"harbor": {"command": cmd[0], "args": cmd[1:] + identity_args(agent, project) + ["mcp"],
"env": {"HARBOR_AGENT": agent, "HARBOR_PROJECT": project, "HARBOR_HOST": "kimi"},
"toolTimeoutMs": 1900000}},
"systemPromptPath": "./system-prompt.md",
"hooks": [
{"event": "UserPromptSubmit", "command": f"{base} prompt-submit --host kimi", "timeout": 30},
{"event": "PreToolUse", "command": f"{base} pre-tool --host kimi", "timeout": 30},
{"event": "Stop", "command": f"{base} stop --host kimi", "timeout": 30},
],
}
zh = {"description": f"把 Kimi 接入 {title} 的 Harbor 团队协作:消息、确认、认领、评审队列和暂停开关。",
"interface": {"displayName": f"Harbor:{title}", "shortDescription": f"{title} 的团队协作",
"longDescription": f"{title} 的 Harbor 工具、会话简报、暂停闸门和消息提醒。",
"tryPrompts": ["查看 Harbor 消息", "团队现在在做什么?"],
"skillOverrides": {name: {"displayName": f"Harbor:{title}", "description": f"{title} 的团队协作"}},
"mcpOverrides": {"harbor": {"displayName": "Harbor", "description": f"{title} 的团队协作"}}}}
plan.write(folder / "kimi.plugin.json", json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", validate=json.loads)
plan.write(folder / "locales" / "zh-CN.json", json.dumps(zh, indent=2, ensure_ascii=False) + "\n", validate=json.loads)
plan.write(folder / "system-prompt.md", agents_section(agent, project, cmd))
plan.write(folder / "skills" / name / "SKILL.md",
f"---\nname: {name}\ndescription: Team coordination for {title} through Harbor (messages, claims, "
"review queue, stop switch). Use at the start of every session and whenever you talk to the team.\n---\n\n"
+ agents_section(agent, project, cmd))
plan.notes.append(f"Kimi: plugin folder {folder}. After it is registered, install it from Kimi's Plugins > "
"Personal tab (Kimi asks you to review plugins that carry hooks).")
plan.kimi_folder = folder
def kimi_register(folder, dry_run):
scripts = kimi_share() / "daimon" / "skills" / "plugin-builder" / "scripts"
daimon = Path(os.environ.get("APPDATA", "")) / "kimi-desktop" / "daimon-bundle" / "bin" / "kimi-daimon.cmd"
if not (scripts / "register_personal.py").exists() or not daimon.exists():
return "Kimi: its plugin tools were not found; ask Kimi to register the plugin folder (plugin-builder skill)."
validate = subprocess.run([sys.executable, str(scripts / "validate_plugin.py"), str(folder)], capture_output=True,
text=True, encoding="utf-8", errors="replace")
errors = [line for line in validate.stdout.splitlines() + validate.stderr.splitlines() if "[ERROR]" in line]
if errors:
return "Kimi validation failed:\n " + "\n ".join(errors)
if dry_run:
return "Kimi: plugin validates; a real run registers it in the Personal market."
r = subprocess.run([sys.executable, str(scripts / "register_personal.py"), "--share-dir", str(kimi_share()),
"--daimon-bin", str(daimon), str(folder)], capture_output=True, text=True, encoding="utf-8",
errors="replace")
return ("Kimi: registered in the Personal market. " if r.returncode == 0 else
f"Kimi registration failed (exit {r.returncode}): ") + (r.stdout.strip() or r.stderr.strip())[-600:]
def install_claude(plan, agent, project, cmd, remove):
"""A local Claude Code marketplace holding the Harbor plugin (MCP server, hooks, skill)."""
market = projects.harbor_home() / "integrations" / "claude"
plugin = market / "harbor"
if remove:
for path in sorted(market.rglob("*"), reverse=True) if market.exists() else []:
if path.is_file():
plan.remove(path)
plan.notes.append("Claude Code: also run `/plugin uninstall harbor@harbor-local`.")
return
ident = ["--project", project] if project else []
base = " ".join(cmd + ident + ["hook"])
hooks = {event: [{"hooks": [{"type": "command", "command": f"{base} {name} --host claude", "timeout": 30}]}]
for event, name in (("SessionStart", "session-start"), ("UserPromptSubmit", "prompt-submit"),
("PreToolUse", "pre-tool"), ("Stop", "stop"))}
dump = lambda value: json.dumps(value, indent=2) + "\n"
plan.write(market / ".claude-plugin" / "marketplace.json", dump({
"name": "harbor-local", "owner": {"name": "Harbor"},
"plugins": [{"name": "harbor", "source": "./harbor",
"description": "Harbor team coordination: MCP tools, session brief, stop gate, message nudges"}]}),
validate=json.loads)
plan.write(plugin / ".claude-plugin" / "plugin.json", dump({
"name": "harbor", "version": "2.0.0", "author": {"name": "Harbor"},
"description": "Harbor team coordination: MCP tools, session brief, stop gate and message nudges. The agent "
"identity comes from the HARBOR_AGENT environment variable."}), validate=json.loads)
plan.write(plugin / "hooks" / "hooks.json", dump({"hooks": hooks}), validate=json.loads)
env = {"HARBOR_AGENT": "${HARBOR_AGENT:-" + agent + "}", "HARBOR_HOST": "claude",
**({"HARBOR_PROJECT": project} if project else {})}
plan.write(plugin / ".mcp.json", dump({"mcpServers": {"harbor": {"command": cmd[0], "args": cmd[1:] + ident + ["mcp"],
"env": env}}}), validate=json.loads)
plan.write(plugin / "skills" / "harbor" / "SKILL.md",
"---\nname: harbor\ndescription: Team coordination through Harbor (messages, claims, review queue, "
"stop switch). Use at the start of a session in a Harbor project and whenever you talk to the team.\n"
"---\n\n" + agents_section("the agent named by HARBOR_AGENT", project, cmd))
settings = json.dumps({"env": {"HARBOR_AGENT": agent}})
plan.notes.append(f"Claude Code: run `/plugin marketplace add {market.as_posix()}` then `/plugin install "
f"harbor@harbor-local`, and set HARBOR_AGENT for the session, for example {settings} in "
".claude/settings.json.")
def install(host, agent=None, project=None, dry_run=False, remove=False, python=None, register=True):
if host not in HOSTS:
raise InstallError(f"host must be one of {', '.join(HOSTS)}")
agent = agent or DEFAULT_AGENT[host]
if project:
p = projects.load_named(project)
else:
try:
p = projects.resolve()
except projects.ProjectError:
p = None
if p and agent not in p.ai_names:
raise InstallError(f"{agent} is not an agent of project {p.name} ({', '.join(p.ai_names)})")
cmd = launcher(python)
plan = Plan(host, dry_run)
{"codex": install_codex, "opencode": install_opencode, "deepseek": install_deepseek, "kimi": install_kimi,
"claude": install_claude}[host](plan, agent, project, cmd, remove)
report = plan.apply()
if host == "kimi" and not remove and getattr(plan, "kimi_folder", None):
if dry_run:
report.append("Kimi: a real run validates the plugin with Kimi's validator and registers it.")
elif register:
report.append(kimi_register(plan.kimi_folder, dry_run))
return report
+262
View File
@@ -0,0 +1,262 @@
"""Getting a team online and watching it work:
activity log the last tool calls of each agent (tool and target: a file path, a command, a Harbor tool)
roll call the human asks every agent "are you there?" with a one-tap answer; shows who answered and how fast
launch status per agent: app connected, brief read, roll call answered, stopped / on a trial run / running
expert finder who has worked on a scope (notes, reviews, claims, tasks, messages)
summaries a short summary pinned to a long thread
Timed resumes ("trial runs") live in core.set_flag / stopped(): a resume with an end time stops the agent again when
the time is up, with no process needed.
"""
import json
import re
from .util import HarborError, first_line, iso, parse_iso, parse_scope, scopes_overlap, utc
LAUNCH_SCHEMA = """
CREATE TABLE IF NOT EXISTS activity_log(id INTEGER PRIMARY KEY, agent TEXT NOT NULL, at TEXT NOT NULL, tool TEXT,
target TEXT);
CREATE INDEX IF NOT EXISTS activity_agent ON activity_log(agent, id);
CREATE TABLE IF NOT EXISTS summaries(id INTEGER PRIMARY KEY, thread_id INTEGER NOT NULL, body TEXT NOT NULL,
author TEXT NOT NULL, at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS summaries_thread ON summaries(thread_id, id);
"""
KEEP_ACTIVITY = 300
SECRETISH = re.compile(r"(?i)(token|secret|password|passwd|api[_-]?key|bearer)\S*\s*[=:]?\s*\S+")
def describe_target(tool, tool_input, paths):
"""A short, safe description of what a tool call touches."""
if paths:
return ", ".join(paths)[:240]
if isinstance(tool_input, dict):
command = tool_input.get("command") or tool_input.get("cmd")
if isinstance(command, list):
command = command[-1] if command else ""
if isinstance(command, str) and command.strip():
return SECRETISH.sub(r"\1=…", " ".join(command.split()))[:240]
for key in ("file_path", "path", "url", "pattern", "query"):
if isinstance(tool_input.get(key), str):
return tool_input[key][:240]
return None
class LaunchMixin:
# ----------------------------------------------------------------- activity log
def _log_activity(self, c, agent, tool, target):
c.execute("INSERT INTO activity_log(agent, at, tool, target) VALUES (?,?,?,?)",
(agent, self.now(), str(tool)[:60], target))
c.execute("DELETE FROM activity_log WHERE agent=? AND id <= (SELECT id FROM activity_log WHERE agent=? "
"ORDER BY id DESC LIMIT 1 OFFSET ?)", (agent, agent, KEEP_ACTIVITY))
def activity_feed(self, limit=60, agent=None):
query, args = "SELECT * FROM activity_log", []
if agent:
query += " WHERE agent=?"
args.append(agent)
rows = [dict(r) for r in self.conn.execute(query + " ORDER BY id DESC LIMIT ?", (*args, int(limit)))]
for r in rows:
r["seconds"] = int((utc(self.clock()) - parse_iso(r["at"])).total_seconds())
return rows
# ----------------------------------------------------------------- roll call
def rollcall(self, by, origin, agents=None):
"""The human asks each agent (default: all) to answer 'Here'. Works while agents are stopped: answering is
one of the things a stopped agent may still do."""
if by != self.human or origin != "web":
raise HarborError(f"only {self.human} starts a roll call, from the board")
targets = agents or self.project.ai_names
for name in targets:
self._check_agent(name, allow_human=False)
stamp = self.now()
row = self.conn.execute("SELECT value FROM meta WHERE key='rollcall'").fetchone()
latest = json.loads(row["value"]).get("by_agent", {}) if row else {}
for name in targets: # a new roll call replaces the agent's unanswered one
old = latest.get(name)
if old and self.question(old)["answer"] is None:
self._write(lambda c, q=old: c.execute(
"UPDATE questions SET answer='(replaced by a newer roll call)', answered_by=?, answered_at=? "
"WHERE id=? AND answer IS NULL", (self.human, stamp, q)))
msg = self.question(old)["message_id"]
self._write(lambda c, m=msg, a=name: c.execute(
"UPDATE deliveries SET read_at=COALESCE(read_at, ?), acked_at=COALESCE(acked_at, ?) WHERE "
"message_id=? AND agent=?", (stamp, stamp, m, a)))
made = []
for name in targets:
q = self.ask(by, f"Roll call ({stamp}): answer 'Here' so {self.human} knows you are online and reading "
"Harbor - with the Harbor answer tool, or `harbor answer <question id> Here`.",
["Here"], to=name, origin="web", thread="rollcall")
made.append(q["id"])
latest[name] = q["id"]
self._write(lambda c: c.execute("INSERT INTO meta(key, value) VALUES ('rollcall', ?) ON CONFLICT(key) DO UPDATE "
"SET value=excluded.value", (json.dumps({"at": stamp, "by_agent": latest}),)))
return self.rollcall_status()
def rollcall_status(self):
row = self.conn.execute("SELECT value FROM meta WHERE key='rollcall'").fetchone()
if not row:
return None
info = json.loads(row["value"])
out = []
for qid in info.get("by_agent", {}).values() or info.get("questions", []):
q = self.question(qid)
d = self.conn.execute("SELECT read_at FROM deliveries WHERE message_id=? AND agent=?",
(q["message_id"], q["to_name"])).fetchone()
seconds = None
if q["answered_at"]:
seconds = int((parse_iso(q["answered_at"]) - parse_iso(q["created_at"])).total_seconds())
out.append({"agent": q["to_name"], "question": qid, "read_at": d["read_at"] if d else None,
"answered_at": q["answered_at"], "answer": q["answer"], "seconds": seconds})
return {"at": info["at"], "agents": out}
# ----------------------------------------------------------------- launch status
def note_brief(self, agent):
self._write(lambda c: c.execute("INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET "
"value=excluded.value", (f"brief:{agent}", self.now())))
def launch_status(self):
roll = {r["agent"]: r for r in (self.rollcall_status() or {}).get("agents", [])}
links = self.connections()
out = []
for a in self.agents():
if a["kind"] == "human":
continue
name = a["name"]
brief = self.conn.execute("SELECT value FROM meta WHERE key=?", (f"brief:{name}",)).fetchone()
stops = self.stopped(name)
trial = self.trial(name)
seen = [c for c in links.get(name, []) if c["minutes"] is not None and c["minutes"] <= 15]
out.append({
"agent": name, "host": next((x.get("host") for x in self.project.agents if x["name"] == name), ""),
"connected": bool([c for c in seen if c["channel"] in ("mcp", "hook")]),
"hooks": next((c["last_at"] for c in links.get(name, []) if c["channel"] == "hook"
and c["minutes"] is not None and c["minutes"] <= 24 * 60), None),
"connected_via": sorted({f"{c['channel']}{'/' + c['host'] if c['host'] else ''}" for c in seen}),
"brief_at": brief["value"] if brief else None,
"rollcall": roll.get(name),
"state": "stopped" if stops else ("trial" if trial else "running"),
"stop_reason": stops[0]["reason"] if stops else None, "trial_until": trial,
"working": self.working(a), "last_tool": a.get("last_tool"),
})
return out
# ----------------------------------------------------------------- autopilot
AUTOPILOT_REST_AFTER = 3
def continuation(self, agent):
"""What a running agent should do next instead of ending its turn, or None to let it rest.
Stopped agents always rest: the human's stop is the off switch. An agent that ends AUTOPILOT_REST_AFTER
turns in a row without a single tool call in between is let rest as well (attention shows it as resting),
so a stuck agent does not loop. Any tool call resets the count."""
if not self.project.autopilot or agent == self.human or self.stopped(agent):
return None
last = self.conn.execute("SELECT MAX(rowid) FROM activity_log WHERE agent=?", (agent,)).fetchone()[0] or 0
key = f"autopilot:{agent}"
row = self.conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
state = json.loads(row["value"]) if row else {}
streak = state.get("streak", 0) + 1 if state.get("last_tool") == last else 0
resting = streak >= self.AUTOPILOT_REST_AFTER
value = json.dumps({"streak": streak, "last_tool": last, "at": self.now(), "resting": resting})
self._write(lambda c: c.execute("INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET "
"value=excluded.value", (key, value)))
if resting:
return None
mine = [t for t in self.tasks(states=("in_progress",), limit=100000) if t["assignee"] == agent]
band = self.conn.execute("SELECT 1 FROM claims WHERE agent=? AND kind='band' AND released_at IS NULL",
(agent,)).fetchone()
if mine:
t = mine[0]
step = (f"Continue task #{t['id']}: {t['title']}. When it is finished, submit it to the review queue with "
"its task number, then take the next one with next.")
elif (nxt := self.next_task(agent, take=False)) is not None:
step = f"Take your next task with next (the best one now is #{nxt['id']}: {nxt['title']})."
elif band:
step = ("The task queue has nothing for you: take the next eligible game function in your own band "
"(claim it first), make it readable, and submit it to the review queue.")
else:
waiting = [i for i in self.items(states=("submitted", "in_review")) if i["author"] != agent]
spec = next((x for x in self.project.agents if x["name"] == agent), {})
if waiting:
step = (f"Review the next item in the review queue (#{waiting[0]['id']}: {waiting[0]['title']}) and move "
"it to approved or changes_requested with the evidence.")
else:
step = ("Continue the work your instructions describe" + (f": {spec['instructions']}" if
spec.get("instructions") else ".") + " When there is nothing at all to do right now, wait for "
"new work with wait_for_messages instead of ending your turn.")
return ("Harbor autopilot: you are running, so keep working instead of ending your turn. First read any new "
f"Harbor messages (inbox). Then: {step} Keep doing current, and write a handoff at natural checkpoints, "
"not every turn. Only the user's stop ends your work.")
def resting_agents(self):
out = []
for r in self.conn.execute("SELECT key, value FROM meta WHERE key LIKE 'autopilot:%'"):
agent = r["key"].split(":", 1)[1]
info = json.loads(r["value"])
if info.get("resting") and not self.stopped(agent):
out.append({"agent": agent, "since": info.get("at")})
return out
# ----------------------------------------------------------------- expert finder
def experts(self, scope, limit=5):
"""Agents ranked by how much they have worked on a scope."""
target = parse_scope(scope)
score, detail = {}, {}
def add(agent, kind, weight):
if agent == self.human:
return
score[agent] = score.get(agent, 0) + weight
detail.setdefault(agent, {}).setdefault(kind, 0)
detail[agent][kind] += 1
for n in self.notes(target[0]):
add(n["author"], "notes", 3)
for r in self.conn.execute("SELECT * FROM claims ORDER BY id DESC LIMIT 5000"):
if scopes_overlap(target, (r["scope"], r["lo"], r["hi"])) and r["kind"] != "band":
add(r["agent"], "claims", 2)
for r in self.conn.execute("SELECT i.scope, i.author, e.agent, e.to_state FROM item_events e JOIN items i "
"ON i.id=e.item_id WHERE i.scope IS NOT NULL"):
try:
if scopes_overlap(target, parse_scope(r["scope"])):
add(r["agent"], "drafts" if r["agent"] == r["author"] else "reviews", 3)
except HarborError:
pass
ctx = self.context(scope, limit=40)
for m in ctx["messages"]:
add(m["sender"], "messages", 1)
ranked = sorted(score, key=lambda a: (-score[a], -len(detail[a]), a))[:limit]
return [{"agent": a, "score": score[a], "detail": detail[a]} for a in ranked]
# ----------------------------------------------------------------- thread summaries
def summarize(self, author, thread, body):
self._check_agent(author)
info, _ = self.thread(thread)
body = (body or "").strip()
if not body:
raise HarborError("empty summary")
if len(body) > 4000:
raise HarborError("a summary is at most 4,000 characters - it is meant to be short")
now = self.now()
sid = self._write(lambda c: c.execute("INSERT INTO summaries(thread_id, body, author, at) VALUES (?,?,?,?)",
(info["id"], body, author, now)).lastrowid)
return {"id": sid, "thread": info["topic"]}
def summary(self, thread_id):
row = self.conn.execute("SELECT * FROM summaries WHERE thread_id=? ORDER BY id DESC LIMIT 1",
(int(thread_id),)).fetchone()
return dict(row) if row else None
def threads_needing_summary(self, agent, min_messages=20):
rows = self.conn.execute(
"SELECT t.id, t.topic, COUNT(m.id) n, MAX(m.created_at) last FROM threads t JOIN messages m ON m.thread_id=t.id "
"WHERE t.topic NOT LIKE 'legacy:%' AND t.state='open' AND t.topic NOT IN ('general', 'control', 'questions', "
"'rollcall', 'wins') GROUP BY t.id HAVING n>=? AND EXISTS (SELECT 1 FROM messages x WHERE x.thread_id=t.id "
"AND x.sender=?)", (min_messages, agent)).fetchall()
out = []
for r in rows:
s = self.summary(r["id"])
if not s or s["at"] < r["last"] and self.conn.execute(
"SELECT COUNT(*) FROM messages WHERE thread_id=? AND created_at>?", (r["id"], s["at"])).fetchone()[0] >= 10:
out.append({"thread": r["topic"], "messages": r["n"]})
return out
+475
View File
@@ -0,0 +1,475 @@
"""Harbor as MCP tools (stdio), so an agent can coordinate without composing shell commands.
Run with the agent's identity fixed in its environment (it cannot be changed from a tool call):
command: py -3.12 -B <Harbor>\\harbor_mcp.py env: HARBOR_AGENT=<Agent> (HARBOR_PROJECT optional)
The project comes from $HARBOR_PROJECT or the .harbor.toml above the working directory. The human is refused.
"""
import asyncio
import os
import threading
import time
from typing import Literal
os.environ.setdefault("FASTMCP_CHECK_FOR_UPDATES", "off") # no network calls from a local tool
os.environ.setdefault("FASTMCP_SHOW_SERVER_BANNER", "false")
from fastmcp import FastMCP # noqa: E402
from fastmcp.exceptions import ToolError # noqa: E402
from . import projects # noqa: E402
from .core import OPEN_ITEM_STATES, Harbor, HarborError # noqa: E402
INSTRUCTIONS = """Harbor is the team's shared coordination store: messages in threads, acknowledgements, claims, a
review queue, presence and stop flags. Protocol:
1. Call brief() at the start of every session and after long work. If it reports a STOP, do not start or continue
work; only the human can resume. If it shows your last handoff, continue from it.
2. Take work with next_task() instead of waiting to be told; submit(task=...) or update_task(..., "done") finishes it.
Keep the team informed with doing("what you are working on") instead of status messages to everyone.
3. Put each conversation in a thread named after its subject: fn:0x529350, commit:b4b44f0, item:12, or a short
topic. Answer with reply() so the thread and the people stay together.
4. Write plain sentences. There is no length limit to squeeze into (20,000 characters); never run words together.
5. Ask for an acknowledgement (ack=True) only when you need to know the message was acted on; acknowledge with
ack() and a short note instead of sending a "received" message.
6. Before working on a function or file, claim it. A refused claim names the holder: ask them in a thread.
7. Put finished work in the review queue with submit(); do not announce commits in chat. A coordinator lands it.
8. When you are waiting on someone, use wait_for_messages() instead of polling or sleeping.
9. A correction is send(..., corrects=<id of your earlier message>).
10. Before your session ends or your allowance runs out, write handoff(): finished, in flight, next."""
KindName = Literal["note", "question", "answer", "request", "finding", "retraction", "decision", "status", "handoff"]
Importance = Literal["normal", "high", "urgent"]
ReviewState = Literal["in_review", "changes_requested", "approved", "held", "rejected", "submitted"]
ClaimKind = Literal["draft", "naming", "edit", "review", "band"]
mcp = FastMCP("harbor", instructions=INSTRUCTIONS)
_lock = threading.Lock()
_state = {"seen": 0.0}
def harbor():
if "h" not in _state:
project = _state.get("project") or projects.resolve()
_state["h"] = Harbor(project, shared=True)
_state["h"].refresh_project() # project-file edits apply without restarting the app
return _state["h"]
def me():
h = harbor()
agent = os.environ.get("HARBOR_AGENT") or h.project.identity_for(os.getcwd()) or ""
if agent not in h.project.ai_names:
raise HarborError(f"HARBOR_AGENT must be one of {', '.join(h.project.ai_names)} (set it in this server's MCP "
"config)")
if time.monotonic() - _state.get("active", 0.0) > 20: # a tool call is activity (apps without hooks too)
_state["active"] = time.monotonic()
try:
import sys as _sys
h.activity(agent, "harbor " + _sys._getframe(1).f_code.co_name, os.environ.get("HARBOR_HOST", ""),
channel="mcp")
except Exception: # noqa: BLE001 - bookkeeping is best effort
pass
return agent
def compact(m):
out = {"id": m["id"], "at": m["created_at"], "from": m["sender"], "to": m["recipients"],
"thread": m.get("topic"), "text": m["body"]}
for key in ("kind", "importance"):
if m[key] not in ("note", "normal"):
out[key] = m[key]
if m.get("ack_required"):
out["ack_requested"] = True
if m.get("reply_to"):
out["reply_to"] = m["reply_to"]
if m.get("refs"):
out["refs"] = m["refs"]
return out
def recipients(to):
if to is None or to == "All" or to == ["All"]:
return "All"
if isinstance(to, str):
return [name.strip() for name in to.split(",") if name.strip()]
return list(to)
def run(fn):
"""Harbor's refusals go back to the agent as a plain tool error (the message says what to do instead)."""
try:
with _lock:
h = harbor()
if time.monotonic() - _state.get("seen", 0.0) > 60: # record the live MCP connection, once a minute
_state["seen"] = time.monotonic()
try:
h.connect(me(), "mcp", os.environ.get("HARBOR_HOST", ""))
except HarborError:
pass
return fn(h)
except (HarborError, projects.ProjectError) as error:
raise ToolError(str(error)) from None
@mcp.tool
def brief(mark_read: bool = False) -> str:
"""What you need at the start of a session: stop flags, unread messages (urgent first), acknowledgements you
owe, your review items and claims, what the rest of the team is doing."""
return run(lambda h: h.brief(me(), mark_read=mark_read))
@mcp.tool
def doing(text: str = "") -> dict:
"""Set the one line the whole team sees about what you are doing now (empty clears it)."""
return run(lambda h: h.doing(me(), text))
@mcp.tool
def inbox(limit: int = 20, include_read: bool = False, peek: bool = False) -> list[dict]:
"""Your unread messages, urgent first (marked read unless peek=True). include_read=True lists recent ones."""
return run(lambda h: [compact(m) for m in h.inbox(me(), unread_only=not include_read, limit=limit,
mark_read=not peek)])
@mcp.tool
async def wait_for_messages(timeout_seconds: int = 300) -> dict:
"""Wait (up to timeout_seconds, max 1800) until a message for you arrives or a stop applies, then return the
new messages. Use this instead of sleeping or polling when you are waiting on someone."""
agent = run(lambda h: me())
timeout = max(1, min(int(timeout_seconds), 1800))
deadline = asyncio.get_running_loop().time() + timeout
while True:
state = run(lambda h: (h.unread_count(agent)[0], [f["key"] for f in h.stopped(agent)]))
if state[0] or state[1] or asyncio.get_running_loop().time() >= deadline:
messages = run(lambda h: [compact(m) for m in h.inbox(agent, limit=30)]) if state[0] else []
run(lambda h: h.touch(agent))
return {"messages": messages, "stopped": state[1], "timed_out": not state[0] and not state[1]}
await asyncio.sleep(2)
@mcp.tool
def send(to: str | list[str], text: str, thread: str | None = None, subject: str | None = None,
kind: KindName = "note", importance: Importance = "normal", ack: bool = False,
refs: dict[str, str] | None = None, attachments: list[str] | None = None, corrects: int | None = None,
due: str | None = None) -> dict:
"""Send a message. to: an agent, a list of agents, or "All". thread: a topic such as fn:0x529350,
commit:b4b44f0, task:5 or item:12 (default "general"); subject is the thread's title when it is new.
refs: what the message is about, e.g. {"commit": "b4b44f0", "fn": "0x529350"}.
attachments: local file paths to copy into Harbor (reports, logs, diffs).
corrects: the id of your earlier message this corrects (goes to the same thread and people).
due: when you need an answer ("30m", "2h", or an ISO time); missing it raises an alert."""
return run(lambda h: h.send(me(), recipients(to), text, subject=subject, thread=thread, kind=kind,
importance=importance, ack_required=ack, refs=refs, origin="mcp",
attachments=attachments, corrects=corrects, due=due))
@mcp.tool
def reply(message_id: int, text: str, kind: KindName = "answer", importance: Importance = "normal",
ack: bool = False, to: str | list[str] | None = None) -> dict:
"""Reply in the same thread to the same people (or to= someone else)."""
return run(lambda h: h.send(me(), None if to is None else recipients(to), text, reply_to=message_id, kind=kind,
importance=importance, ack_required=ack, origin="mcp"))
@mcp.tool
def ack(message_ids: list[int], note: str | None = None) -> list[int]:
"""Acknowledge messages addressed to you (use instead of a "received" message); note says what you did."""
return run(lambda h: h.ack(me(), message_ids, note))
@mcp.tool
def thread(thread: str) -> dict:
"""A whole thread by id or topic (e.g. "fn:0x529350")."""
def get(h):
t, messages = h.thread(thread)
return {"thread": t, "messages": [compact(m) for m in messages]}
return run(get)
@mcp.tool
def threads(limit: int = 25, include_closed: bool = False) -> list[dict]:
"""Recent threads with your unread count in each."""
return run(lambda h: h.threads(limit=limit, agent=me(), include_closed=include_closed))
@mcp.tool
def close_thread(thread: str, reopen: bool = False) -> dict:
"""Close a thread whose question is settled (or reopen it). A new message reopens it automatically."""
return run(lambda h: h.set_thread_state(me(), thread, "open" if reopen else "closed"))
@mcp.tool
def search(query: str, limit: int = 15) -> list[dict]:
"""Full-text search over every message, including imported chat history."""
return run(lambda h: [compact(m) for m in h.search(query, limit=limit)])
@mcp.tool
def claim(scope: str, kind: ClaimKind = "draft", ttl_hours: float | None = None, note: str | None = None) -> dict:
"""Claim fn:0xADDR, range:0xLO-0xHI, name:Symbol, task:text or file:path before working on it. Claiming the same
scope again renews it. Refused (with the holder named) when someone holds an overlapping exclusive claim."""
return run(lambda h: h.claim(me(), scope, kind=kind, ttl_hours=ttl_hours, note=note))
@mcp.tool
def release(claim_id: int | None = None, scope: str | None = None) -> list[int]:
"""Release your claim by id or scope when the work is submitted or abandoned."""
if claim_id is None and not scope:
raise ToolError("give claim_id or scope")
return run(lambda h: h.release(me(), claim_id, scope))
@mcp.tool
def claims(agent: str | None = None) -> list[dict]:
"""Active claims (everyone's, or one agent's)."""
return run(lambda h: h.active_claims(agent=agent))
@mcp.tool
def submit(title: str, scope: str | None = None, commit: str | None = None, branch: str | None = None,
note: str | None = None, task: int | None = None) -> dict:
"""Put finished work in the review queue (opens thread item:<id> with the reviewers). task: the task this
completes; landing the item closes the task, a change request reopens it for you."""
return run(lambda h: h.submit(me(), title, scope=scope, commit=commit, branch=branch, note=note, task=task))
TaskPriority = Literal["urgent", "high", "normal", "low"]
TaskMove = Literal["done", "blocked", "open", "cancelled", "in_progress"]
@mcp.tool
def next_task(take: bool = True) -> dict:
"""Take the best open task you may do now: highest priority first, dependencies finished, scope not claimed by
someone else. Taking it claims its scope and sets your `doing` line; the result includes what the team already
knows about that scope (notes, past threads, review items). take=False only shows it."""
def get(h):
task = h.next_task(me(), take=take)
if not task:
return {"task": None, "reason": "no open task you can take right now (or you are stopped)"}
return {"task": task, "context": h.context_text(task["scope"]) if task["scope"] else None}
return run(get)
@mcp.tool
def ask(question: str, options: list[str] | None = None, to: str | None = None, due: str | None = None,
fallback: str | None = None) -> dict:
"""Ask the human (default) or an agent a question. options become answer buttons on the chat page (keep them
short: "Yes", "No", "Later"). fallback: a coordinator who may answer while the human is away. Then use
wait_for_messages(): the answer arrives as a reply."""
return run(lambda h: h.ask(me(), question, options or (), to=to, due=due, fallback=fallback, origin="mcp"))
@mcp.tool
def answer(question_id: int, answer: str) -> dict:
"""Answer a question addressed to you (one of its options, or 'other: ...')."""
return run(lambda h: h.answer(me(), question_id, answer, origin="mcp"))
@mcp.tool
def questions(include_answered: bool = False) -> list[dict]:
"""Questions to you and from you."""
def get(h):
agent = me()
return [q for q in h.questions(open_only=not include_answered, limit=100) if agent in (q["to_name"], q["asker"])]
return run(get)
@mcp.tool
def note(scope: str, text: str, replaces: int | None = None) -> dict:
"""Add to the team's memory about a scope (fn:0x..., range:..., file:..., name:...): an ABI detail, a trap, a
decision. Everyone who later takes work on that scope sees it."""
return run(lambda h: h.note(me(), scope, text, replaces=replaces))
@mcp.tool
def context(scope: str) -> str:
"""Everything the team knows about a scope: notes, threads, messages that mention it, tasks, review items and
who claimed it before. Read it before you start on a function."""
return run(lambda h: h.context_text(scope, limit=15))
@mcp.tool
def metric(name: str, value: float, note: str | None = None) -> dict:
"""Report a number the board charts over time (e.g. readable=779 after a landing, tests_passing=58)."""
return run(lambda h: h.metric(me(), name, value, note))
@mcp.tool
def tasks(mine: bool = False, include_closed: bool = False) -> list[dict]:
"""The task list: open, in progress, blocked and in review (include_closed adds done and cancelled)."""
from .core import TASK_STATES
states = TASK_STATES if include_closed else ("open", "in_progress", "blocked", "review")
return run(lambda h: h.tasks(states=states, assignee=me() if mine else None))
@mcp.tool
def add_task(title: str, body: str | None = None, scope: str | None = None, assignee: str | None = None,
priority: TaskPriority = "normal", after: list[int] | None = None, due: str | None = None) -> dict:
"""Add a task (split your work, or record follow-up work). scope: fn:0x..., range:..., file:..., task:...;
after: task ids that must be done first; assignee: yourself, or anyone if you are a coordinator."""
return run(lambda h: h.add_task(me(), title, body=body, scope=scope, assignee=assignee, priority=priority,
after=after or [], due=due, origin="mcp"))
@mcp.tool
def take_task(task_id: int) -> dict:
"""Take a specific open task (claims its scope)."""
return run(lambda h: h.take_task(me(), task_id))
@mcp.tool
def update_task(task_id: int, state: TaskMove, note: str | None = None) -> dict:
"""Move a task: done (with a note on what you did), blocked (note says by what), open (hand it back),
cancelled, in_progress (resume a blocked task)."""
return run(lambda h: h.update_task(me(), task_id, state, note=note))
@mcp.tool
def handoff(text: str) -> dict:
"""Record where you left off: what is finished, what is in flight (branch, files, half-done steps), what comes
next. Shown at the start of your next session and to the team. Write one before your session ends."""
return run(lambda h: h.handoff(me(), text))
@mcp.tool
def pin(message_id: int, note: str | None = None, unpin: bool = False) -> list[dict]:
"""Pin a message the whole team should keep seeing (a rule, a decision, an address list), or unpin it."""
return run(lambda h: [compact(m) for m in (h.unpin(me(), message_id) if unpin else h.pin(me(), message_id, note))])
@mcp.tool
def review(item_id: int, state: ReviewState, note: str | None = None, commit: str | None = None) -> dict:
"""Move a review item: reviewers use in_review / changes_requested / approved / held / rejected; the author
re-submits with state="submitted" (and the new commit)."""
return run(lambda h: h.transition(me(), item_id, state, note=note, commit=commit))
@mcp.tool
def withdraw(item_id: int, note: str | None = None) -> dict:
"""The author withdraws their own review item."""
return run(lambda h: h.transition(me(), item_id, "withdrawn", note=note))
@mcp.tool
def land(item_id: int, commit: str, note: str | None = None) -> dict:
"""Coordinator only: the approved item is on main in this commit."""
return run(lambda h: h.transition(me(), item_id, "landed", note=note, commit=commit))
@mcp.tool
def queue(include_closed: bool = False) -> list[dict]:
"""The review queue (open items; include_closed adds landed, withdrawn and rejected)."""
states = OPEN_ITEM_STATES + (("landed", "withdrawn", "rejected") if include_closed else ())
return run(lambda h: h.items(states=states))
@mcp.tool
def status() -> dict:
"""Stop flags, everyone's presence (what they are doing, when last seen), unread counts, queue size and anything
that needs attention."""
def get(h):
agents = []
for a in h.agents():
unread, urgent = h.unread_count(a["name"])
agents.append({"name": a["name"], "doing": a.get("doing"), "last_seen": a["last_seen"], "unread": unread,
"urgent": urgent})
return {"stopped_for_me": [f["key"] for f in h.stopped(me())], "flags": list(h.flags().values()),
"agents": agents, "open_review_items": len(h.items()), "active_claims": len(h.active_claims()),
"attention": [a["text"] for a in h.attention()]}
return run(get)
@mcp.tool
def digest(since: str = "8h") -> dict:
"""What happened since a time ("8h", "90m", "2d" or an ISO time): per-agent activity, review and claim changes,
stops, and messages to the human."""
def get(h):
d = h.digest(since)
d["to_human"] = [compact(m) for m in d["to_human"]]
return d
return run(get)
@mcp.tool
def stop(target: str = "self", reason: str | None = None, relay_of: int | None = None) -> dict:
"""Stop yourself (target="self"). A coordinator may stop anyone. To stop or resume someone on the human's
request, relay their chat-page message: relay_of = that message id."""
def do(h):
agent = me()
who = agent if target == "self" else target
key = "stop:all" if who.lower() == "all" else f"stop:{who}"
return h.set_flag(key, "on", agent, "mcp", reason=reason, relay_of=relay_of)
return run(do)
@mcp.tool
def summarize(thread: str, text: str) -> dict:
"""Pin a short summary to a long thread: what was decided, what is open, who does what next. It is shown on top
of the thread and in `context` for its scope."""
return run(lambda h: h.summarize(me(), thread, text))
@mcp.tool
def who_knows(scope: str) -> list[dict]:
"""Agents ranked by how much they have worked on a scope - ask them first."""
return run(lambda h: h.experts(scope))
@mcp.tool
def git_status() -> dict:
"""Your worktree as Harbor sees it: branch, last commit, uncommitted files, commits behind the main branch, and
commits made since your last submit (work not yet in the review queue)."""
return run(lambda h: h.git_status(me(), max_age=5) or {"error": "no workdir for you in the project file"})
@mcp.tool
def add_tasks(lines: str, template: str | None = None, priority: TaskPriority = "normal") -> dict:
"""Add many tasks at once, one per line ("0x445310 RouteCellCost" or a plain title). Addresses go to their band
owner. template: run a template from the project file for every line."""
return run(lambda h: h.bulk_tasks(me(), lines, template=template, priority=priority, origin="mcp"))
@mcp.tool
def resume(target: str, relay_of: int | None = None, reason: str | None = None) -> dict:
"""Only the human resumes. A coordinator may lift a stop it set itself (not one it relayed for the human)."""
def do(h):
key = "stop:all" if target.lower() == "all" else f"stop:{target}"
return h.set_flag(key, "off", me(), "mcp", reason=reason, relay_of=relay_of)
return run(do)
def _report_in():
"""Tell Harbor this agent's app has its tools running: at start, then every 5 minutes while the app keeps the
server open (updates the connection only, not the agent's last-seen time)."""
def loop():
while True:
time.sleep(300)
try:
with _lock:
h = harbor()
h._write(lambda c: c.execute(
"UPDATE connections SET last_at=? WHERE agent=? AND channel='mcp' AND host=?",
(h.now(), me(), os.environ.get("HARBOR_HOST", ""))))
except Exception: # noqa: BLE001 - reporting in is best effort
pass
try:
with _lock:
h = harbor()
h.connect(me(), "mcp", os.environ.get("HARBOR_HOST", ""))
_state["seen"] = time.monotonic()
except Exception: # noqa: BLE001
pass
threading.Thread(target=loop, daemon=True).start()
def main(project=None):
if project is not None:
_state["project"] = project
try:
me() # refuse to start without a valid identity
except (HarborError, projects.ProjectError) as error:
raise SystemExit(f"harbor mcp: {error}")
_report_in()
mcp.run(show_banner=False)
if __name__ == "__main__":
main()
+69
View File
@@ -0,0 +1,69 @@
"""Windows notifications from the Harbor server, so the human hears about questions and urgent messages even with the
chat page closed. Off by default; switched on from the board (stored per project in the database)."""
import base64
import os
import subprocess
import sys
from xml.sax.saxutils import escape
APP_ID = r"{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe"
def toast_script(title, body):
xml = ('<toast><visual><binding template="ToastGeneric"><text>' + escape(str(title)[:80]) + "</text><text>"
+ escape(str(body)[:300]) + "</text></binding></visual></toast>")
xml = xml.replace("'", "''") # inside a PowerShell single-quoted string
return ("[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] "
"| Out-Null\n"
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null\n"
"$xml = New-Object Windows.Data.Xml.Dom.XmlDocument\n"
f"$xml.LoadXml('{xml}')\n"
"$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)\n"
f"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{APP_ID}').Show($toast)\n")
def toast(title, body):
"""Show a Windows notification; returns True when PowerShell accepted it."""
if sys.platform != "win32" or os.environ.get("HARBOR_NO_TOASTS"):
return False
encoded = base64.b64encode(toast_script(title, body).encode("utf-16-le")).decode("ascii")
try:
r = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
capture_output=True, timeout=20, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
return r.returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def pending_for_human(h, limit=3):
"""Messages to the human that deserve a notification and were not notified yet (questions, urgent, direct)."""
row = h.conn.execute("SELECT value FROM meta WHERE key='toast_last'").fetchone()
last = int(row["value"]) if row else 0
rows = [h._message_dict(r) for r in h.conn.execute(
h.MESSAGE_SELECT + " JOIN deliveries d ON d.message_id=m.id AND d.agent=? WHERE d.read_at IS NULL AND m.id>? "
"ORDER BY m.id LIMIT 200", (h.human, last))]
worth = sorted((m for m in rows if m["recipients"] != ["All"] or m["importance"] == "urgent"
or m["kind"] == "question"), key=lambda m: m["id"])
top = max([m["id"] for m in rows], default=last)
return worth[-limit:], top
def enabled(h):
row = h.conn.execute("SELECT value FROM meta WHERE key='toasts'").fetchone()
return bool(row and row["value"] == "on")
def set_enabled(h, on):
top = h.conn.execute("SELECT COALESCE(MAX(id), 0) FROM messages").fetchone()[0]
def do(c):
c.execute("INSERT INTO meta(key, value) VALUES ('toasts', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
("on" if on else "off",))
c.execute("INSERT INTO meta(key, value) VALUES ('toast_last', ?) ON CONFLICT(key) DO UPDATE SET "
"value=excluded.value", (str(top),)) # never notify the backlog
h._write(do)
def mark(h, top):
h._write(lambda c: c.execute("INSERT INTO meta(key, value) VALUES ('toast_last', ?) ON CONFLICT(key) DO UPDATE "
"SET value=excluded.value", (str(top),)))
+299
View File
@@ -0,0 +1,299 @@
"""Projects: which team, roster and database a Harbor command talks to.
A project is a TOML file. Registered projects live in <home>/projects/<name>.toml, where <home> is $HARBOR_HOME, or
the `home` set in local.toml next to this package, or ~/.harbor. A directory tree joins a project with a
`.harbor.toml` file at its root (found by walking up from the working directory, the way git finds a repository);
that file either names a registered project (`project = "open-seas"`) or holds a full definition. A marker that says
`boundary = true` ends the walk: nothing below it belongs to a project marked further up.
name = "open-seas"
title = "Open Sea's decomp"
database = 'R:\\Data\\open-seas\\harbor.db' # default: <home>/projects/<name>/harbor.db
mirror = 'R:\\OpenSeas\\agents-chat.md' # optional plain-text copy of every message
human = "User" # the person; only the web page speaks as them
coordinators = ["Claude"] # may set bands, stop others and land review items
reviewers = ["Claude"] # default reviewers of submitted items
[identities] # optional: working-directory name -> agent
"open-seas-decomp-deepseek" = "DeepSeek"
[git] # optional: the branch work lands on
main = "main"
[policy] # optional rules Harbor enforces
land_requires = ["Kimi"] # approvals needed (since the last submit) before landing
all_per_hour = 12 # cap on messages to everyone per agent per hour
[templates.decomp] # optional task pipelines: harbor task template decomp fn:0x...
steps = [
{name = "draft", title = "Make {scope} readable", assignee = "{owner}"},
{name = "names", title = "Name the helpers {scope} calls", assignee = "{owner}", after = ["draft"]},
]
[attention] # optional thresholds in minutes
ack_overdue = 30
item_stale = 120
agent_quiet = 60
[[agents]]
name = "Claude"
role = "Integration and review"
initials = "CL"
color = "#cc8a65"
host = "claude" # optional: codex, opencode, deepseek, kimi, claude
workdir = 'R:/code/project' # optional: shown in restart prompts
instructions = "Owns main; reviews everything." # optional: standing instructions for restart prompts
[[agents]]
name = "User"
kind = "human"
role = "Project owner"
initials = "You"
"""
import os
import re
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
MARKER = ".harbor.toml"
NAME = re.compile(r"[a-z0-9][a-z0-9._-]{0,63}")
PALETTE = ("#cc8a65", "#4688e6", "#8071ce", "#39a48d", "#b45b9b", "#d0913c", "#5f8f3a", "#c0564f", "#3d8fa8")
class ProjectError(Exception):
pass
def harbor_home():
if os.environ.get("HARBOR_HOME"):
return Path(os.environ["HARBOR_HOME"])
local = PACKAGE_ROOT / "local.toml"
if local.exists():
home = tomllib.loads(local.read_text(encoding="utf-8")).get("home")
if home:
return Path(home)
return Path.home() / ".harbor"
@dataclass
class Project:
name: str
title: str
db: Path
mirror: Path | None
agents: list = field(default_factory=list)
human: str = "User"
coordinators: tuple = ()
reviewers: tuple = ()
identities: dict = field(default_factory=dict)
attention: dict = field(default_factory=dict)
source: Path | None = None
git_main: str = "main"
templates: dict = field(default_factory=dict)
policy: dict = field(default_factory=dict)
autopilot: bool = False # running agents keep working instead of ending their turns (hooks)
shared_files: tuple = () # glob patterns (repo-relative) every agent edits and the integrator merges: not collisions
@property
def names(self):
return [a["name"] for a in self.agents]
@property
def ai_names(self):
return [a["name"] for a in self.agents if a["kind"] != "human"]
@property
def attachments(self):
return self.db.parent / "attachments"
@property
def backups(self):
return self.db.parent / "backups"
def identity_for(self, cwd):
"""The agent whose working directory this is (from [identities]), or None."""
parts = [p.lower() for p in Path(cwd).resolve().parts]
for key, agent in self.identities.items():
if key.lower() in parts:
return agent
return None
def threshold(self, key, default):
return int(self.attention.get(key, default))
def public(self):
return {"name": self.name, "title": self.title, "human": self.human, "agents": self.agents,
"coordinators": list(self.coordinators)}
def _normalise_agents(raw, human):
agents, seen = [], set()
for i, a in enumerate(raw):
name = str(a.get("name", "")).strip()
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,31}", name):
raise ProjectError(f"agent name {name!r}: letters, digits, _ or -, starting with a letter")
if name.lower() == "all" or name in seen:
raise ProjectError(f"agent name {name!r} is reserved or repeated")
seen.add(name)
kind = "human" if (a.get("kind") == "human" or name == human) else "ai"
agents.append({"name": name, "role": str(a.get("role", "")), "kind": kind,
"initials": str(a.get("initials") or ("You" if kind == "human" else name[:2].upper())),
"color": str(a.get("color") or ("#536070" if kind == "human" else PALETTE[i % len(PALETTE)])),
"workdir": str(a.get("workdir", "")), "instructions": str(a.get("instructions", "")),
"host": str(a.get("host", "")), "git_submits": a.get("git_submits", True)})
if human not in seen:
agents.append({"name": human, "role": "Project owner", "kind": "human", "initials": "You", "color": "#536070"})
return agents
def _templates(raw, names):
out = {}
for name, spec in (raw or {}).items():
steps = spec.get("steps") if isinstance(spec, dict) else None
if not isinstance(steps, list) or not steps:
raise ProjectError(f"template {name!r} needs a list of steps")
seen = set()
for step in steps:
if not isinstance(step, dict) or not step.get("title"):
raise ProjectError(f"template {name!r}: every step needs a title")
for dep in step.get("after", []):
if dep not in seen:
raise ProjectError(f"template {name!r}: step {step.get('name')!r} comes after unknown step {dep!r}")
assignee = step.get("assignee", "")
if assignee and "{" not in assignee and assignee not in names:
raise ProjectError(f"template {name!r}: {assignee!r} is not on the roster")
seen.add(step.get("name", ""))
out[name] = {"steps": steps}
return out
def _policy(raw, names):
policy = dict(raw or {})
if "land_requires" in policy:
value = policy["land_requires"]
value = [value] if isinstance(value, str) else value
if not isinstance(value, list) or not all(v in names for v in value):
raise ProjectError("policy land_requires must list agents on the roster")
policy["land_requires"] = value
if "all_per_hour" in policy:
try:
policy["all_per_hour"] = int(policy["all_per_hour"])
except (TypeError, ValueError):
raise ProjectError("policy all_per_hour must be a whole number")
return policy
def project_from_dict(data, source=None, home=None):
home = Path(home) if home else harbor_home()
name = str(data.get("name", "")).strip()
if not NAME.fullmatch(name):
raise ProjectError(f"project name {name!r}: lower-case letters, digits, '.', '_' or '-'")
human = str(data.get("human", "User"))
agents = _normalise_agents(data.get("agents", []), human)
names = {a["name"] for a in agents}
coordinators = tuple(data.get("coordinators", ()))
reviewers = tuple(data.get("reviewers", coordinators[:1]))
for who in (*coordinators, *reviewers, *data.get("identities", {}).values()):
if who not in names:
raise ProjectError(f"{who!r} is named in the project but is not in its agents")
base = source.parent if source else home
db = Path(data["database"]) if data.get("database") else home / "projects" / name / "harbor.db"
mirror = data.get("mirror")
return Project(name=name, title=str(data.get("title", name)), db=db if db.is_absolute() else base / db,
mirror=(Path(mirror) if Path(mirror).is_absolute() else base / mirror) if mirror else None,
agents=agents, human=human, coordinators=coordinators, reviewers=reviewers,
identities=dict(data.get("identities", {})), attention=dict(data.get("attention", {})),
source=source, git_main=str(data.get("git", {}).get("main", "main")),
templates=_templates(data.get("templates", {}), names), policy=_policy(data.get("policy"), names),
autopilot=bool(data.get("autopilot", False)),
shared_files=tuple(str(p).replace("\\", "/") for p in data.get("shared_files", [])))
def load_file(path, home=None):
path = Path(path)
try:
data = tomllib.loads(path.read_text(encoding="utf-8"))
except (OSError, tomllib.TOMLDecodeError) as error:
raise ProjectError(f"{path}: {error}")
if "project" in data and "name" not in data: # a marker that points at a registered project
return load_named(data["project"], home)
return project_from_dict(data, source=path, home=home)
def load_named(name, home=None):
home = Path(home) if home else harbor_home()
path = home / "projects" / f"{name}.toml"
if not path.exists():
known = ", ".join(list_names(home)) or "none"
raise ProjectError(f"no project {name!r} in {home / 'projects'} (registered: {known})")
return load_file(path, home)
def list_names(home=None):
folder = (Path(home) if home else harbor_home()) / "projects"
return sorted(p.stem for p in folder.glob("*.toml")) if folder.exists() else []
def find_marker(cwd=None):
"""The nearest .harbor.toml at or above cwd, or None (also when the nearest one is a boundary)."""
here = Path(cwd or os.getcwd()).resolve()
for folder in (here, *here.parents):
candidate = folder / MARKER
if candidate.is_file():
try:
boundary = tomllib.loads(candidate.read_text(encoding="utf-8")).get("boundary") is True
except (OSError, tomllib.TOMLDecodeError):
boundary = False # load_file reports the broken marker
return None if boundary else candidate
return None
def default_name(home=None):
config = (Path(home) if home else harbor_home()) / "config.toml"
if config.exists():
return tomllib.loads(config.read_text(encoding="utf-8")).get("default_project")
return None
def resolve(spec=None, cwd=None, home=None, use_default=True):
"""--project value, else $HARBOR_PROJECT, else the nearest .harbor.toml, else (when use_default) the home's
default project. Host hooks pass use_default=False so they stay silent outside Harbor projects."""
spec = spec or os.environ.get("HARBOR_PROJECT")
if spec:
return load_file(spec, home) if spec.endswith(".toml") else load_named(spec, home)
marker = find_marker(cwd)
if marker:
return load_file(marker, home)
name = default_name(home) if use_default else None
if name:
return load_named(name, home)
raise ProjectError("no Harbor project here: pass --project, set HARBOR_PROJECT, add a .harbor.toml, "
"or create one with `harbor init`")
def write_project(path, name, title, agents, human="User", coordinators=(), database=None, mirror=None,
identities=None):
"""Write a new project file (refuses to overwrite)."""
path = Path(path)
if path.exists():
raise ProjectError(f"{path} already exists")
q = lambda s: "'" + str(s).replace("'", "") + "'"
lines = [f'name = "{name}"', f"title = {q(title)}"]
if database:
lines.append(f"database = {q(database)}")
if mirror:
lines.append(f"mirror = {q(mirror)}")
lines.append(f'human = "{human}"')
lines.append("coordinators = [" + ", ".join(f'"{c}"' for c in coordinators) + "]")
if identities:
lines.append("\n[identities]")
lines += [f"{q(k)} = \"{v}\"" for k, v in identities.items()]
for a in agents:
lines.append("\n[[agents]]")
lines += [f"{k} = {q(v)}" for k, v in a.items() if v]
path.parent.mkdir(parents=True, exist_ok=True)
text = "\n".join(lines) + "\n"
project_from_dict(tomllib.loads(text), source=path) # validate before writing
path.write_text(text, encoding="utf-8")
return path
+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}")
+46
View File
@@ -0,0 +1,46 @@
"""Text shared by the installer, the MCP server and restart prompts: how hosts run Harbor and the team protocol."""
import shutil
import sys
from .projects import PACKAGE_ROOT
def launcher(python=None):
"""The command prefix hosts run: forward slashes and no quotes, so cmd, PowerShell and bash all accept it."""
run = (PACKAGE_ROOT / "run.py").as_posix()
if python:
return python.split() + ["-B", run]
if shutil.which("py"):
return ["py", f"-{sys.version_info.major}.{sys.version_info.minor}", "-B", run]
return [sys.executable.replace("\\", "/"), "-B", run]
def protocol(agent, project_name=None, cmd=None):
"""The rules every agent follows, in the second person, as a markdown section."""
run = " ".join(cmd or launcher())
scope = (f"in the Harbor project `{project_name}`" if project_name else
"inside a Harbor project (a `.harbor.toml` file exists in your working folder or a parent folder)")
return f"""## Team coordination (Harbor)
When you work {scope}, you are **{agent}** on a team coordinated through Harbor (MCP tools named `brief`,
`send`, ..., or the command `{run} --as {agent} <command>`):
- Start every session with `brief`. If it reports a STOP for you, do not start or continue work; only the human
can resume. If the brief shows a handoff you wrote earlier, continue from it.
- Take work with `next` (the highest-priority task you may do) instead of waiting to be told. Mark it done, or
`submit` the commit to the review queue with the task number; landing closes the task.
- Keep `doing` current: one line the whole team sees. Do not post progress reports to everyone.
- Talk in threads named after the subject (fn:0x..., commit:..., task:N, item:N) and answer with `reply`. Write
plain sentences; never run words and numbers together. A correction uses `--corrects <id>`.
- Acknowledge with `ack` (and a short note) instead of sending "received". Ask for an acknowledgement, or set a
due time, only when you need the answer.
- Claim before you work on something that is not a task (`claim fn:0x...`); release when done.
- Before you start on a function or file, read `context <scope>`: notes, earlier threads and review items about it.
Record what you learn that others will need (an ABI detail, a trap, a decision) with `note <scope> "..."`.
- Before asking the team about a function, `who <scope>` tells you who has worked on it. When a thread grows long,
write a short `summarize <thread> "..."` (decided, open, who does what next).
- When you need the human to decide, use `ask` with short options ("Yes", "No", "Later"); they answer with one tap.
Never post a bare question to everyone and wait.
- When you wait on someone, use `wait_for_messages` (`wait`) instead of sleeping or polling.
- Before your session ends or your allowance runs out, write a `handoff`: what you finished, what is in flight,
what comes next. It is shown to you (and the team) at the start of the next session.
"""
+122
View File
@@ -0,0 +1,122 @@
"""Small shared helpers: times, scopes, the compressed-text detector and Harbor's error type."""
import datetime
import fnmatch
import re
import time
# Run-together words such as "full222", "stub23HarnessBug" or "correctOffsetsWereWrong". Hex tokens (commit hashes,
# addresses) and ordinary identifiers (PascalCase names, g_snake_case globals) are not counted.
SQUASHED = re.compile(r"[A-Za-z]{3,}\d{2,}[A-Za-z]*|\b[a-z]{3,}[A-Z][a-z]{2,}[A-Z][a-z]{2,}[A-Za-z]*\b")
HEXLIKE = re.compile(r"(?:0x)?[0-9A-Fa-f]+")
PRIORITIES = {"urgent": 0, "high": 1, "normal": 2, "low": 3}
PRIORITY_NAMES = {v: k for k, v in PRIORITIES.items()}
class HarborError(Exception):
"""A refused operation; the message says why and what to do instead."""
def squashed_tokens(text):
found = []
for token in re.split(r"[\s,;:()\[\]{}<>\"'`/|=+]+", text):
token = token.strip(".!?-")
if not token or HEXLIKE.fullmatch(token) or "_" in token:
continue
if SQUASHED.search(token):
found.append(token)
return found
def utc(ts=None):
return datetime.datetime.fromtimestamp(time.time() if ts is None else ts, datetime.timezone.utc)
def iso(dt):
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
def parse_iso(text):
return datetime.datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc)
def _span(text):
m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([mhd])\s*", str(text))
if not m:
return None
return datetime.timedelta(seconds=float(m.group(1)) * {"m": 60, "h": 3600, "d": 86400}[m.group(2)])
def parse_since(text, now):
"""'8h', '90m', '2d' or an ISO time -> ISO time in the past."""
span = _span(text)
if span is not None:
return iso(now - span)
try:
return iso(parse_iso(str(text).strip()))
except ValueError:
raise HarborError(f"since {text!r}: use 8h, 90m, 2d or 2026-10-02T13:25:00Z")
def parse_until(text, now):
"""'30m', '2h', '1d' or an ISO time -> ISO time in the future (due dates, 'away until')."""
span = _span(text)
if span is not None:
return iso(now + span)
try:
return iso(parse_iso(str(text).strip()))
except ValueError:
raise HarborError(f"time {text!r}: use 30m, 2h, 1d or 2026-10-03T13:00:00Z")
def parse_priority(value):
if isinstance(value, int) and value in PRIORITY_NAMES:
return value
key = str(value or "normal").lower()
if key not in PRIORITIES:
raise HarborError(f"priority must be one of {', '.join(PRIORITIES)}")
return PRIORITIES[key]
def parse_scope(scope):
"""'fn:0x529350' / '0x529350' / 'range:0x440000-0x47FFFF' -> (canonical, lo, hi); others keep lo/hi None."""
text = scope.strip()
m = re.fullmatch(r"(?:fn:)?(0x[0-9A-Fa-f]+)", text)
if m:
value = int(m.group(1), 16)
return f"fn:0x{value:06X}", value, value
m = re.fullmatch(r"range:(0x[0-9A-Fa-f]+)-(0x[0-9A-Fa-f]+)", text)
if m:
lo, hi = int(m.group(1), 16), int(m.group(2), 16)
if lo > hi:
raise HarborError(f"range {text}: start is after end")
return f"range:0x{lo:06X}-0x{hi:06X}", lo, hi
m = re.fullmatch(r"(file|name|topic|task):(.+)", text)
if m:
value = m.group(2).strip()
f = re.match(r"(?:.*[/\\])?([0-9A-Fa-f]{6})_[^/\\]*\.c$", value) if m.group(1) == "file" else None
if f: # a function's file: it stands for that address
address = int(f.group(1), 16)
return f"file:{value}", address, address
return f"{m.group(1)}:{value}", None, None
raise HarborError(f"scope {scope!r}: use fn:0xADDR, range:0xLO-0xHI, name:Symbol, file:path, task:text or topic:text")
def scopes_overlap(a, b):
"""a, b: (scope, lo, hi)."""
if a[1] is not None and b[1] is not None:
return a[1] <= b[2] and b[1] <= a[2]
if a[1] is not None or b[1] is not None:
return False
ka, va = a[0].split(":", 1)
kb, vb = b[0].split(":", 1)
if ka != kb:
return False
if ka == "file":
va, vb = va.replace("\\", "/").lower(), vb.replace("\\", "/").lower()
return va == vb or fnmatch.fnmatch(va, vb) or fnmatch.fnmatch(vb, va)
return va.lower() == vb.lower()
def first_line(text, limit=160):
line = (text or "").strip().split("\n", 1)[0]
return line if len(line) <= limit else line[:limit - 1] + "…"
+93
View File
@@ -0,0 +1,93 @@
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+1219
View File
File diff suppressed because it is too large. Load diff
+350
View File
@@ -0,0 +1,350 @@
/* Harbor design system: tokens, themes and components shared by the chat page and the board. The colour themes
from BroadWorks Enhancer live in themes.css and override these tokens; this file is Harbor's own default look. */
@font-face {
font-family: "Inter"; src: url("InterVariable.woff2") format("woff2"); /* OFL, Inter-OFL.txt */
font-weight: 100 900; font-style: normal; font-display: swap;
}
:root {
--font: "Inter", "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
--font-display: "Inter", "Segoe UI Variable Display", "Segoe UI", system-ui, sans-serif;
--mono: "Cascadia Code", "Cascadia Mono", ui-monospace, "SF Mono", Consolas, monospace;
--bg: #f2f4f8;
--bg-tint: radial-gradient(1200px 600px at 100% -10%, #e6ebff 0%, transparent 60%), radial-gradient(900px 500px at -10% 110%, #e8f6f1 0%, transparent 55%);
--surface: #ffffff;
--surface-2: #f7f9fc;
--surface-3: #eef2f8;
--text: #111a2c;
--text-2: #364259;
--muted: #687489;
--faint: #9aa4b6;
--line: #e3e8f0;
--line-2: #d2d9e5;
--accent: #4361ee;
--accent-2: #3650d6;
--accent-soft: #edf0ff;
--accent-ink: #2c3fb5;
--action: #4361ee; --action-2: #3650d6; --on-action: #ffffff; /* filled buttons, the send button, your bubbles */
--on-green: #ffffff; --on-red: #ffffff;
--green: #097a5a; --green-soft: #e3f5ee;
--amber: #b97710; --amber-soft: #fcf1de;
--red: #d63f36; --red-soft: #fdebea;
--violet: #7356f0; --violet-soft: #f0ecff;
--blue: #2a78e4; --blue-soft: #e6f0fd;
--shadow-1: 0 1px 2px rgba(17, 26, 44, .05);
--shadow-2: 0 1px 2px rgba(17, 26, 44, .04), 0 8px 24px -10px rgba(17, 26, 44, .14);
--shadow-3: 0 24px 60px -16px rgba(17, 26, 44, .32);
--ring: 0 0 0 3px rgba(67, 97, 238, .28);
--r-1: 8px; --r-2: 12px; --r-3: 16px; --r-4: 22px;
--green-ink: color-mix(in oklab, var(--green) 66%, var(--text)); --red-ink: color-mix(in oklab, var(--red) 66%, var(--text));
--amber-ink: color-mix(in oklab, var(--amber) 66%, var(--text)); --violet-ink: color-mix(in oklab, var(--violet) 66%, var(--text));
--blue-ink: color-mix(in oklab, var(--blue) 66%, var(--text));
/* the top bar and the sidebar; a theme can colour them apart from the page */
--header: color-mix(in srgb, var(--surface) 58%, transparent);
--side: color-mix(in srgb, var(--surface) 72%, transparent); --side-solid: var(--surface);
--side-text: var(--text); --side-muted: var(--muted); --side-hover: var(--surface-3); --side-line: var(--line);
--side-active: var(--accent-soft); --side-active-text: var(--accent-ink); --side-accent: var(--accent);
--side-green: color-mix(in oklab, var(--green) 34%, var(--side-text));
--side-green-soft: color-mix(in srgb, var(--side-green) 16%, var(--side-solid));
--ease: cubic-bezier(.2, .8, .2, 1);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0b0f17;
--bg-tint: radial-gradient(1200px 600px at 100% -10%, rgba(76, 99, 230, .16) 0%, transparent 60%), radial-gradient(900px 500px at -10% 110%, rgba(30, 160, 120, .08) 0%, transparent 55%);
--surface: #121825; --surface-2: #161d2b; --surface-3: #1c2435;
--text: #e9eef7; --text-2: #c3cbdb; --muted: #8b96ac; --faint: #5d687e;
--line: #222b3c; --line-2: #2c374a;
--accent: #7189ff; --accent-2: #8a9dff; --accent-soft: rgba(113, 137, 255, .14); --accent-ink: #b9c5ff;
--action: #4f6af0; --action-2: #6079f5; --on-green: #0b0f17; --on-red: #0b0f17;
--green: #33c995; --green-soft: rgba(51, 201, 149, .13);
--amber: #f1b44c; --amber-soft: rgba(241, 180, 76, .13);
--red: #ff6b61; --red-soft: rgba(255, 107, 97, .13);
--violet: #a68cff; --violet-soft: rgba(166, 140, 255, .14);
--blue: #5ba3ff; --blue-soft: rgba(91, 163, 255, .13);
--shadow-1: 0 1px 2px rgba(0, 0, 0, .35);
--shadow-2: 0 1px 2px rgba(0, 0, 0, .3), 0 10px 30px -12px rgba(0, 0, 0, .55);
--shadow-3: 0 30px 70px -20px rgba(0, 0, 0, .7);
--ring: 0 0 0 3px rgba(113, 137, 255, .35);
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0b0f17;
--bg-tint: radial-gradient(1200px 600px at 100% -10%, rgba(76, 99, 230, .16) 0%, transparent 60%), radial-gradient(900px 500px at -10% 110%, rgba(30, 160, 120, .08) 0%, transparent 55%);
--surface: #121825; --surface-2: #161d2b; --surface-3: #1c2435;
--text: #e9eef7; --text-2: #c3cbdb; --muted: #8b96ac; --faint: #5d687e;
--line: #222b3c; --line-2: #2c374a;
--accent: #7189ff; --accent-2: #8a9dff; --accent-soft: rgba(113, 137, 255, .14); --accent-ink: #b9c5ff;
--action: #4f6af0; --action-2: #6079f5; --on-green: #0b0f17; --on-red: #0b0f17;
--green: #33c995; --green-soft: rgba(51, 201, 149, .13);
--amber: #f1b44c; --amber-soft: rgba(241, 180, 76, .13);
--red: #ff6b61; --red-soft: rgba(255, 107, 97, .13);
--violet: #a68cff; --violet-soft: rgba(166, 140, 255, .14);
--blue: #5ba3ff; --blue-soft: rgba(91, 163, 255, .13);
--shadow-1: 0 1px 2px rgba(0, 0, 0, .35);
--shadow-2: 0 1px 2px rgba(0, 0, 0, .3), 0 10px 30px -12px rgba(0, 0, 0, .55);
--shadow-3: 0 30px 70px -20px rgba(0, 0, 0, .7);
--ring: 0 0 0 3px rgba(113, 137, 255, .35);
color-scheme: dark;
}
/* ------------------------------------------------------------------------------------------------ base */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
font: 14px/1.5 var(--font); color: var(--text); font-feature-settings: "cv11", "ss03";
background-color: var(--bg); background-image: var(--bg-tint); background-attachment: fixed;
-webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
}
button, input, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code, .mono { font-family: var(--mono); font-size: .92em; }
code { background: var(--surface-3); padding: 1px 5px; border-radius: 5px; }
::selection { background: var(--accent-soft); }
:focus-visible { outline: none; box-shadow: var(--ring); border-radius: var(--r-1); }
* { scrollbar-width: thin; scrollbar-color: var(--line-2) transparent; }
[hidden] { display: none !important; }
svg.i { width: 16px; height: 16px; flex-shrink: 0; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
svg.i.lg { width: 20px; height: 20px; }
svg.i.sm { width: 14px; height: 14px; }
svg.i { pointer-events: none; }
.muted { color: var(--muted); } .faint { color: var(--faint); }
.note { font-size: 12px; color: var(--muted); }
.err { color: var(--red); }
.grow { flex: 1; min-width: 0; }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.stack-v { display: flex; flex-direction: column; gap: 4px; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* ------------------------------------------------------------------------------------------------ buttons */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 34px; padding: 0 13px;
border: 1px solid var(--line-2); background: var(--surface); color: var(--text-2); border-radius: var(--r-1);
font-size: 13px; font-weight: 600; white-space: nowrap; box-shadow: var(--shadow-1);
transition: background .15s, border-color .15s, color .15s, transform .08s, box-shadow .15s;
}
.btn:hover { background: var(--surface-2); border-color: var(--faint); color: var(--text); }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: .45; cursor: default; transform: none; }
.btn.small, .btn.sm { height: 28px; padding: 0 10px; font-size: 12px; border-radius: 7px; }
.btn.primary { background: var(--action); border-color: var(--action); color: var(--on-action); }
.btn.primary:hover { background: var(--action-2); border-color: var(--action-2); color: var(--on-action); }
.btn.go { background: var(--green); border-color: var(--green); color: var(--on-green); }
.btn.go:hover { background: var(--green); filter: brightness(1.07); border-color: var(--green); color: var(--on-green); }
.btn.stop, .btn.danger { background: var(--red); border-color: var(--red); color: var(--on-red); }
.btn.stop:hover, .btn.danger:hover { background: var(--red); filter: brightness(1.07); border-color: var(--red); color: var(--on-red); }
.btn.ghost { background: transparent; border-color: transparent; box-shadow: none; color: var(--muted); }
.btn.ghost:hover { background: var(--surface-3); color: var(--text); }
.btn.icon { width: 34px; padding: 0; }
.btn.icon.sm { width: 28px; }
.btn[aria-pressed="true"] { background: var(--accent-soft); border-color: transparent; color: var(--accent-ink); }
/* ------------------------------------------------------------------------------------------------ tags and pills */
.tag {
display: inline-flex; align-items: center; gap: 5px; height: 21px; padding: 0 8px; border-radius: 999px;
font-size: 11px; font-weight: 650; letter-spacing: .1px; white-space: nowrap; border: 0;
background: var(--surface-3); color: var(--muted);
}
.tag.green { background: var(--green-soft); color: var(--green-ink); }
.tag.red { background: var(--red-soft); color: var(--red-ink); }
.tag.amber { background: var(--amber-soft); color: var(--amber-ink); }
.tag.violet { background: var(--violet-soft); color: var(--violet-ink); }
.tag.blue { background: var(--blue-soft); color: var(--blue-ink); }
.tag.solid-red { background: var(--red); color: var(--on-red); }
.tag .dot, .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; display: inline-block; flex-shrink: 0; }
button.tag { cursor: pointer; }
button.tag:hover { filter: brightness(.96); color: var(--accent); }
.count-badge {
min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; display: inline-grid; place-items: center;
font-size: 11px; font-weight: 700; background: var(--surface-3); color: var(--muted);
}
.count-badge.hot { background: var(--red); color: var(--on-red); }
.count-badge.accent { background: var(--action); color: var(--on-action); }
.count-badge.green { background: var(--green-soft); color: var(--green-ink); }
a.tag:hover { text-decoration: none; filter: brightness(.96); }
/* ------------------------------------------------------------------------------------------------ live dot */
.live { display: inline-flex; align-items: center; gap: 7px; font-size: 12px; color: var(--muted); white-space: nowrap; }
.live .dot { width: 8px; height: 8px; background: var(--amber); box-shadow: 0 0 0 3px var(--amber-soft); }
.live.ok .dot, .connection.connected .dot { background: var(--green); box-shadow: 0 0 0 3px var(--green-soft); animation: breathe 2.4s ease-in-out infinite; }
.live.bad .dot, .connection.failed .dot { background: var(--red); box-shadow: 0 0 0 3px var(--red-soft); }
@keyframes breathe { 0%, 100% { box-shadow: 0 0 0 3px var(--green-soft); } 50% { box-shadow: 0 0 0 6px transparent; } }
@keyframes pulse { 0% { opacity: 1; } 50% { opacity: .35; } 100% { opacity: 1; } }
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
/* ------------------------------------------------------------------------------------------------ avatars */
.avatar {
--avatar: #7b8797; position: relative; width: 36px; height: 36px; flex-shrink: 0; display: grid; place-items: center;
border-radius: 12px; font-size: 11.5px; font-weight: 750; letter-spacing: .2px; color: #fff;
background: linear-gradient(145deg, color-mix(in srgb, var(--avatar) 82%, #fff 18%), var(--avatar));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .14), var(--shadow-1);
}
.avatar.sm { width: 28px; height: 28px; border-radius: 9px; font-size: 10px; }
.avatar.lg { width: 44px; height: 44px; border-radius: 14px; font-size: 13px; }
.avatar.xs { width: 22px; height: 22px; border-radius: 7px; font-size: 8.5px; letter-spacing: 0; }
.avatar.xs[data-state]::after { width: 8px; height: 8px; border-width: 2px; right: -2px; bottom: -2px; }
.avatar[data-state]::after {
content: ""; position: absolute; right: -3px; bottom: -3px; width: 12px; height: 12px; border-radius: 50%;
border: 2.5px solid var(--surface); background: var(--faint);
}
.avatar[data-state="working"]::after { background: var(--green); animation: pulse 1.6s infinite; }
.avatar[data-state="online"]::after { background: var(--green); }
.avatar[data-state="stopped"]::after { background: var(--red); }
.avatar[data-state="trial"]::after { background: var(--amber); }
.claude { --avatar: #cc8a65; } .codex { --avatar: #4688e6; } .deepseek { --avatar: #8071ce; }
.opencode { --avatar: #39a48d; } .kimi { --avatar: #b45b9b; } .user { --avatar: #536070; }
/* ------------------------------------------------------------------------------------------------ cards */
.card {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-3); box-shadow: var(--shadow-2);
min-width: 0; animation: rise .35s var(--ease) both;
}
.card > header {
display: flex; align-items: center; gap: 10px; padding: 14px 18px 12px; border-bottom: 1px solid var(--line);
}
.card > header h2 { margin: 0; font: 650 13.5px/1.3 var(--font-display); letter-spacing: -.1px; display: flex; align-items: center; gap: 8px; }
.card > header h2 svg.i { color: var(--muted); }
.card > header .count { font-size: 12px; color: var(--muted); }
.card .body { padding: 14px 18px 16px; }
.card .body.flush { padding: 0; }
.scroll { overflow-x: auto; }
/* ------------------------------------------------------------------------------------------------ forms */
input[type=text], input[type=search], select, textarea {
background: var(--surface-2); border: 1px solid var(--line-2); border-radius: var(--r-1); padding: 7px 10px;
font-size: 13px; min-width: 0; color: var(--text); transition: border-color .15s, box-shadow .15s, background .15s;
}
input[type=text]:focus, input[type=search]:focus, select:focus, textarea:focus {
outline: none; border-color: var(--accent); box-shadow: var(--ring); background: var(--surface);
}
select { padding-right: 26px; appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%); background-position: calc(100% - 14px) 52%, calc(100% - 9px) 52%; background-size: 5px 5px; background-repeat: no-repeat; }
textarea { width: 100%; resize: vertical; min-height: 72px; line-height: 1.5; }
input::placeholder, textarea::placeholder { color: var(--faint); }
.check { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--text-2); cursor: pointer; user-select: none; }
.check input { accent-color: var(--action); width: 15px; height: 15px; margin: 0; }
.switch { position: relative; display: inline-flex; align-items: center; gap: 9px; cursor: pointer; font-size: 12.5px; color: var(--text-2); user-select: none; }
.switch input { appearance: none; width: 34px; height: 20px; border-radius: 999px; background: var(--line-2); position: relative; margin: 0; cursor: pointer; transition: background .2s; }
.switch input::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: var(--shadow-1); transition: transform .2s var(--ease); }
.switch input:checked { background: var(--action); }
.switch input:checked::after { transform: translateX(14px); }
.toggle-chip { position: relative; display: inline-flex; }
.toggle-chip input { position: absolute; opacity: 0; inset: 0; cursor: pointer; margin: 0; }
.toggle-chip span { display: inline-flex; align-items: center; gap: 5px; height: 26px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--line-2); font-size: 11.5px; font-weight: 600; color: var(--muted); background: var(--surface); transition: all .15s; }
.toggle-chip input:checked + span { background: var(--accent-soft); color: var(--accent-ink); border-color: transparent; }
.toggle-chip input:focus-visible + span { box-shadow: var(--ring); }
.toggle-chip.danger input:checked + span { background: var(--red-soft); color: var(--red); }
/* ------------------------------------------------------------------------------------------------ tables */
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .7px; color: var(--faint); font-weight: 650; padding: 8px 12px; border-bottom: 1px solid var(--line); background: var(--surface-2); }
td { padding: 10px 12px; border-top: 1px solid var(--line); vertical-align: top; overflow-wrap: anywhere; }
tbody tr:first-child td { border-top: 0; }
tr.click { cursor: pointer; }
tr.click:hover td, tbody tr:hover td { background: var(--surface-2); }
td.mono { white-space: nowrap; color: var(--muted); }
/* ------------------------------------------------------------------------------------------------ empty states */
.empty { display: grid; justify-items: center; gap: 6px; text-align: center; color: var(--muted); padding: 22px 12px; font-size: 13px; }
.empty strong { color: var(--text-2); font-size: 14px; }
.empty svg.i { width: 26px; height: 26px; color: var(--faint); stroke-width: 1.5; }
/* ------------------------------------------------------------------------------------------------ dialogs */
dialog {
border: 1px solid var(--line); border-radius: var(--r-4); background: var(--surface); color: var(--text);
padding: 0; width: min(760px, calc(100vw - 32px)); box-shadow: var(--shadow-3); max-height: 86vh;
}
dialog[open] { animation: rise .25s var(--ease); }
dialog::backdrop { background: rgba(8, 12, 20, .5); backdrop-filter: blur(3px); }
dialog > header, dialog form > header { display: flex; align-items: center; gap: 10px; padding: 16px 20px; border-bottom: 1px solid var(--line); }
dialog h2 { margin: 0; font: 650 15px/1.3 var(--font-display); flex: 1; min-width: 0; overflow-wrap: anywhere; }
dialog .body { padding: 16px 20px 20px; overflow: auto; }
dialog footer { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid var(--line); background: var(--surface-2); border-radius: 0 0 var(--r-4) var(--r-4); }
/* ------------------------------------------------------------------------------------------------ segmented radio */
.segmented { display: inline-flex; padding: 3px; background: var(--surface-3); border-radius: 10px; gap: 2px; }
.segmented label { position: relative; }
.segmented input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
.segmented span { display: flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 8px; font-size: 12.5px; font-weight: 600; color: var(--muted); transition: all .15s; }
.segmented input:checked + span { background: var(--surface); color: var(--text); box-shadow: var(--shadow-1); }
.segmented input:focus-visible + span { box-shadow: var(--ring); }
/* ------------------------------------------------------------------------------------------------ availability pill */
.avail {
display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 14px 0 12px; border-radius: 999px; white-space: nowrap;
border: 1px solid var(--line-2); background: var(--surface); color: var(--text-2); font-size: 12.5px; font-weight: 650; box-shadow: var(--shadow-1);
transition: background .15s, border-color .15s;
}
.avail::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 3px var(--green-soft); flex-shrink: 0; }
.avail:hover { background: var(--surface-2); border-color: var(--faint); }
.avail.away { color: var(--amber); border-color: color-mix(in srgb, var(--amber) 45%, var(--line-2)); }
.avail.away::before { background: var(--amber); box-shadow: 0 0 0 3px var(--amber-soft); }
.avail.dnd { color: var(--red); border-color: color-mix(in srgb, var(--red) 45%, var(--line-2)); }
.avail.dnd::before { background: var(--red); box-shadow: 0 0 0 3px var(--red-soft); }
/* ------------------------------------------------------------------------------------------------ misc shared */
.working { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--green); font-weight: 650; }
.working::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--green); animation: pulse 1.4s infinite; }
.kbd { font: 600 11px var(--mono); padding: 1px 6px; border: 1px solid var(--line-2); border-bottom-width: 2px; border-radius: 5px; color: var(--muted); background: var(--surface); }
.divider { height: 1px; background: var(--line); margin: 12px 0; }
/* ------------------------------------------------------------------------------------------------ the sidebar's colours */
/* Inside a sidebar the general tokens point at the theme's side colours, so everything in it follows the theme. */
.side, .sidebar {
--text: var(--side-text); --text-2: var(--side-text); --muted: var(--side-muted); --faint: var(--side-muted);
--line: var(--side-line); --line-2: var(--side-line);
--surface: var(--side-solid); --surface-2: var(--side-hover); --surface-3: var(--side-hover);
--accent: var(--side-accent); --accent-soft: var(--side-active); --accent-ink: var(--side-active-text);
--green: var(--side-green); --green-soft: var(--side-green-soft);
color: var(--text); background: var(--side);
}
.side .segmented input:checked + span, .sidebar .segmented input:checked + span { background: var(--side-active); color: var(--side-active-text); box-shadow: none; }
.theme-btn {
display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 11px; min-width: 0; border-radius: 9px;
border: 1px solid var(--line-2); background: var(--surface-2); color: var(--text-2); font-size: 12.5px; font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: background .15s, border-color .15s;
}
.theme-btn:hover { background: var(--surface-3); border-color: var(--accent); }
.theme-btn svg.i { color: var(--accent); }
/* ------------------------------------------------------------------------------------------------ the theme picker */
.theme-dialog { width: min(860px, calc(100vw - 32px)); }
.theme-row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 18px; }
.theme-row .field-label { margin: 0; }
.theme-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 172px), 1fr)); gap: 12px; }
.tcard {
display: flex; flex-direction: column; gap: 9px; padding: 8px 8px 10px; text-align: left; color: var(--text);
border: 1px solid var(--line); border-radius: var(--r-2); background: var(--surface-2);
transition: border-color .15s, transform .15s var(--ease), box-shadow .15s;
}
.tcard:hover { border-color: var(--line-2); transform: translateY(-2px); box-shadow: var(--shadow-2); }
.tcard[aria-pressed="true"] { border-color: var(--accent); box-shadow: var(--ring); }
.tname { display: flex; align-items: baseline; justify-content: space-between; gap: 6px; padding: 0 3px; font-size: 13px; font-weight: 650; }
.tprev {
display: grid; grid-template-columns: 24% 1fr; grid-template-rows: 13px 1fr; height: 82px; overflow: hidden;
border-radius: 8px; border: 1px solid var(--p-line); background: var(--p-bg);
}
.tprev i, .tprev b, .tprev em { display: block; font-style: normal; }
.tprev .ps { grid-row: 1 / 3; background: var(--p-side); padding: 16px 6px 0; display: flex; flex-direction: column; gap: 6px; }
.tprev .ps b { height: 4px; border-radius: 2px; background: var(--p-side-accent); opacity: .45; }
.tprev .ps b:first-child { opacity: 1; }
.tprev .ph { background: var(--p-header); border-bottom: 1px solid var(--p-line); }
.tprev .pb { padding: 8px; display: flex; }
.tprev .pc { flex: 1; display: flex; flex-direction: column; gap: 5px; padding: 7px; border-radius: 6px; background: var(--p-surface); border: 1px solid var(--p-line); }
.tprev .pc b { height: 4px; width: 78%; border-radius: 2px; background: var(--p-text); opacity: .75; }
.tprev .pc b + b { width: 52%; opacity: .4; }
.tprev .pc em { margin-top: auto; height: 10px; width: 40%; border-radius: 5px; background: var(--p-action); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
+52
View File
@@ -0,0 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="anchor" viewBox="0 0 24 24"><circle cx="12" cy="5" r="3"/><path d="M12 22V8"/><path d="M5 12H2a10 10 0 0 0 20 0h-3"/></symbol>
<symbol id="chat" viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></symbol>
<symbol id="thread" viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M7 8h10M7 12h6"/></symbol>
<symbol id="layout" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2.5"/><path d="M3 9h18M9 21V9"/></symbol>
<symbol id="home" viewBox="0 0 24 24"><path d="M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></symbol>
<symbol id="rocket" viewBox="0 0 24 24"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></symbol>
<symbol id="tasks" viewBox="0 0 24 24"><path d="m3.5 6.5 1.5 1.5 3-3M3.5 13.5 5 15l3-3M11 7h9M11 14h9M11 20h9M4 20h4"/></symbol>
<symbol id="review" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7M6 9v12"/></symbol>
<symbol id="activity" viewBox="0 0 24 24"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></symbol>
<symbol id="book" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></symbol>
<symbol id="heart" viewBox="0 0 24 24"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/><path d="M3.2 12h6.3l.5-1 2 4.5 2-7 1.5 3.5h5.3"/></symbol>
<symbol id="stop" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></symbol>
<symbol id="play" viewBox="0 0 24 24"><path d="M7 4.5v15l12-7.5z"/></symbol>
<symbol id="bell" viewBox="0 0 24 24"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></symbol>
<symbol id="bell-off" viewBox="0 0 24 24"><path d="M13.7 21a2 2 0 0 1-3.4 0M18.6 13A17.9 17.9 0 0 1 18 8M6.3 6.3A5.9 5.9 0 0 0 6 8c0 7-3 9-3 9h14M18 8a6 6 0 0 0-9.3-5M2 2l20 20"/></symbol>
<symbol id="volume" viewBox="0 0 24 24"><path d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M19 5a10 10 0 0 1 0 14"/></symbol>
<symbol id="volume-off" viewBox="0 0 24 24"><path d="M11 5 6 9H2v6h4l5 4z"/><path d="m22 9-6 6M16 9l6 6"/></symbol>
<symbol id="speak" viewBox="0 0 24 24"><path d="M11 5 6 9H2v6h4l5 4z"/><path d="M15 9h6M15 12h4M15 15h6"/></symbol>
<symbol id="mic" viewBox="0 0 24 24"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M19 10v1a7 7 0 0 1-14 0v-1M12 18v4"/></symbol>
<symbol id="send" viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></symbol>
<symbol id="search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7.5"/><path d="m21 21-4.6-4.6"/></symbol>
<symbol id="pin" viewBox="0 0 24 24"><path d="M12 17v5M9 10.8V5h6v5.8a2 2 0 0 0 1.1 1.8l1.8.9A2 2 0 0 1 19 15.3V17H5v-1.7a2 2 0 0 1 1.1-1.8l1.8-.9A2 2 0 0 0 9 10.8zM8 5h8"/></symbol>
<symbol id="users" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8"/></symbol>
<symbol id="alert" viewBox="0 0 24 24"><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/></symbol>
<symbol id="help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01"/></symbol>
<symbol id="clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></symbol>
<symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M6.3 17.7l-1.4 1.4M19.1 4.9l-1.4 1.4"/></symbol>
<symbol id="moon" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/></symbol>
<symbol id="monitor" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></symbol>
<symbol id="x" viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></symbol>
<symbol id="copy" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></symbol>
<symbol id="refresh" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-2.6-6.4L21 8"/><path d="M21 3v5h-5"/></symbol>
<symbol id="download" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></symbol>
<symbol id="plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="zap" viewBox="0 0 24 24"><path d="M13 2 3 14h9l-1 8 10-12h-9z"/></symbol>
<symbol id="terminal" viewBox="0 0 24 24"><path d="m4 17 6-6-6-6M12 19h8"/></symbol>
<symbol id="file" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></symbol>
<symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
<symbol id="git" viewBox="0 0 24 24"><path d="M6 3v12"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol>
<symbol id="flag" viewBox="0 0 24 24"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1zM4 22v-7"/></symbol>
<symbol id="trophy" viewBox="0 0 24 24"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.7V17c0 .6-.5 1-1 1.2C7.9 18.8 7 20.2 7 22M14 14.7V17c0 .6.5 1 1 1.2 1.1.6 2 2 2 3.8"/><path d="M18 2H6v7a6 6 0 0 0 12 0z"/></symbol>
<symbol id="sparkle" viewBox="0 0 24 24"><path d="m12 3 1.9 5.8L20 11l-6.1 2.2L12 19l-1.9-5.8L4 11l6.1-2.2z"/></symbol>
<symbol id="chevron" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="menu" viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></symbol>
<symbol id="external" viewBox="0 0 24 24"><path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></symbol>
<symbol id="list" viewBox="0 0 24 24"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></symbol>
<symbol id="moon-zz" viewBox="0 0 24 24"><path d="M20 13.5A8 8 0 1 1 10.5 4a6 6 0 0 0 9.5 9.5z"/><path d="M15 3h4l-4 4h4"/></symbol>
<symbol id="sunrise" viewBox="0 0 24 24"><path d="M17 18a5 5 0 0 0-10 0M12 2v7M4.2 10.2l1.4 1.4M1 18h2M21 18h2M18.4 11.6l1.4-1.4M23 22H1M8 6l4-4 4 4"/></symbol>
<symbol id="palette" viewBox="0 0 24 24"><path d="M12 22a10 10 0 1 1 10-10c0 2.8-2.2 4-4 4h-2.2a1.8 1.8 0 0 0-1.3 3.1A1.8 1.8 0 0 1 12 22z"/><circle cx="7.5" cy="11.5" r="1.3"/><circle cx="10.5" cy="7" r="1.3"/><circle cx="15.5" cy="7.5" r="1.3"/></symbol>
<symbol id="pulse-dot" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="8"/></symbol>
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

File diff suppressed because it is too large. Load diff
+77
View File
@@ -0,0 +1,77 @@
/* Harbor's theme picker, shared by the chat and the board. A theme is a palette family (themes.css; Harbor's own look in
harbor.css is the default) and a mode: system, light or dark. Both are kept in localStorage (harbor.palette,
harbor.theme), set on <html data-palette data-theme> before the first paint by each page's head script, and kept in
step across open tabs. Any element with data-theme-open opens the Appearance dialog; radios named "theme" set the
mode; elements with data-theme-name show the current theme's name. */
(function () {
var THEMES = [
{ id: "harbor", name: "Harbor", note: "Default" },
{ id: "dmg", name: "DMG" }, { id: "graphite", name: "Duke" }, { id: "atlas", name: "FLUDD" },
{ id: "rosepine", name: "Flowey" }, { id: "gruvbox", name: "Gerudo" }, { id: "contrast", name: "Kill Screen" },
{ id: "snes", name: "Mode 7" }, { id: "terra", name: "Shovel Knight" }, { id: "nord", name: "Snowdin" },
{ id: "solarized", name: "Solaire" }, { id: "tokyo", name: "Tokyo-to" }, { id: "dracula", name: "Vampire Killer" }
];
var MODES = [["auto", "System", "monitor"], ["light", "Light", "sun"], ["dark", "Dark", "moon"]];
var root = document.documentElement, dark = matchMedia("(prefers-color-scheme: dark)"), dialog = null;
function get(k, d) { try { var v = localStorage.getItem("harbor." + k); return v === null ? d : JSON.parse(v); } catch (e) { return d; } }
function put(k, v) { try { localStorage.setItem("harbor." + k, JSON.stringify(v)); } catch (e) {} }
function mode() { var m = get("theme", "auto"); return m === "light" || m === "dark" ? m : "auto"; }
function palette() { var p = get("palette", "harbor"); return THEMES.some(function (t) { return t.id === p; }) ? p : "harbor"; }
function side() { var m = mode(); return m === "auto" ? (dark.matches ? "dark" : "light") : m; }
function icon(n) { return '<svg class="i sm" aria-hidden="true"><use href="static/icons.svg#' + n + '"></use></svg>'; }
function name() { var p = palette(); return THEMES.filter(function (t) { return t.id === p; })[0].name; }
function apply() {
var m = mode(), p = palette();
if (m === "auto") delete root.dataset.theme; else root.dataset.theme = m;
if (p === "harbor") delete root.dataset.palette; else root.dataset.palette = p;
paint();
}
function paint() {
var m = mode(), p = palette(), s = side();
document.querySelectorAll('input[name="theme"], input[name="themeMode"]').forEach(function (r) { r.checked = r.value === m; });
document.querySelectorAll("[data-theme-name]").forEach(function (e) { e.textContent = name(); });
if (!dialog) return;
dialog.querySelectorAll("[data-pick]").forEach(function (b) {
b.setAttribute("aria-pressed", String(b.dataset.pick === p));
b.querySelector(".tprev").dataset.p = b.dataset.pick + "-" + s;
});
}
function set(key, value) { put(key, value); apply(); }
function build() {
dialog = document.createElement("dialog");
dialog.className = "theme-dialog";
dialog.setAttribute("aria-labelledby", "themeDialogTitle");
dialog.innerHTML =
'<header><h2 id="themeDialogTitle">Appearance</h2><button type="button" class="btn icon sm ghost" data-theme-close aria-label="Close">' +
'<svg class="i" aria-hidden="true"><use href="static/icons.svg#x"></use></svg></button></header>' +
'<div class="body"><div class="theme-row"><span class="field-label">Mode</span><div class="segmented" role="radiogroup" aria-label="Mode">' +
MODES.map(function (x) { return '<label><input type="radio" name="themeMode" value="' + x[0] + '"><span>' + icon(x[2]) + x[1] + "</span></label>"; }).join("") +
'</div></div><div class="theme-grid" role="list">' +
THEMES.map(function (t) {
return '<button type="button" class="tcard" role="listitem" data-pick="' + t.id + '" aria-pressed="false"><span class="tprev" data-p="' + t.id + '-light" aria-hidden="true">' +
'<i class="ps"><b></b><b></b><b></b></i><i class="ph"></i><span class="pb"><span class="pc"><b></b><b></b><em></em></span></span></span>' +
'<span class="tname">' + t.name + (t.note ? ' <span class="note">' + t.note + "</span>" : "") + "</span></button>";
}).join("") +
'</div></div><footer><span class="grow note">Every theme has a light and a dark side; the mode picks which one you see. The choice carries over to the chat and the board.</span>' +
'<button type="button" class="btn primary" data-theme-close>Done</button></footer>';
document.body.appendChild(dialog);
dialog.addEventListener("click", function (e) {
if (e.target === dialog || e.target.closest("[data-theme-close]")) return dialog.close();
var card = e.target.closest("[data-pick]");
if (card) set("palette", card.dataset.pick);
});
}
function open() { if (!dialog) build(); paint(); dialog.showModal(); var on = dialog.querySelector('[aria-pressed="true"]'); if (on) on.focus(); }
document.addEventListener("change", function (e) {
var r = e.target;
if (r.matches && r.matches('input[name="theme"], input[name="themeMode"]')) set("theme", r.value);
});
document.addEventListener("click", function (e) { if (e.target.closest && e.target.closest("[data-theme-open]")) open(); });
window.addEventListener("storage", function (e) { if (e.key === "harbor.theme" || e.key === "harbor.palette") apply(); });
if (dark.addEventListener) dark.addEventListener("change", paint);
window.HarborTheme = { themes: THEMES, apply: apply, open: open, mode: mode, palette: palette, side: side };
apply();
})();
+419
View File
@@ -0,0 +1,419 @@
"""Team work on top of messages: tasks, handoffs, pins, the human's availability, connections and restart prompts.
Mixed into Harbor (core.py); every method uses Harbor's connection, clock and write helper.
"""
import json
from .util import (PRIORITY_NAMES, HarborError, first_line, iso, parse_iso, parse_priority, parse_scope, parse_until,
scopes_overlap, utc)
TASK_STATES = ("open", "in_progress", "blocked", "review", "done", "cancelled")
OPEN_TASK_STATES = ("open", "in_progress", "blocked", "review")
TASK_MOVES = {
"open": {"in_progress", "cancelled"},
"in_progress": {"blocked", "open", "done", "cancelled", "review"},
"blocked": {"in_progress", "open", "cancelled"},
"review": {"in_progress", "done", "cancelled"},
"done": {"open"},
"cancelled": {"open"},
}
PRESENCE = ("here", "away", "dnd")
WORK_SCHEMA = """
CREATE TABLE IF NOT EXISTS tasks(
id INTEGER PRIMARY KEY, title TEXT NOT NULL, body TEXT, scope TEXT, lo INTEGER, hi INTEGER,
priority INTEGER NOT NULL DEFAULT 2, state TEXT NOT NULL, assignee TEXT, created_by TEXT NOT NULL,
item_id INTEGER, claim_id INTEGER, due_at TEXT, block_reason TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS tasks_state ON tasks(state, priority, id);
CREATE TABLE IF NOT EXISTS task_deps(task_id INTEGER NOT NULL, depends_on INTEGER NOT NULL, PRIMARY KEY(task_id, depends_on));
CREATE TABLE IF NOT EXISTS task_events(id INTEGER PRIMARY KEY, task_id INTEGER NOT NULL, agent TEXT NOT NULL,
from_state TEXT, to_state TEXT NOT NULL, note TEXT, at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS handoffs(id INTEGER PRIMARY KEY, agent TEXT NOT NULL, body TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS handoffs_agent ON handoffs(agent, id);
CREATE TABLE IF NOT EXISTS pins(message_id INTEGER PRIMARY KEY, pinned_by TEXT NOT NULL, note TEXT, pinned_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS presence(name TEXT PRIMARY KEY, status TEXT NOT NULL, until TEXT, note TEXT, set_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS connections(agent TEXT NOT NULL, channel TEXT NOT NULL, host TEXT NOT NULL DEFAULT '',
last_at TEXT NOT NULL, count INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(agent, channel, host));
"""
class WorkMixin:
# ----------------------------------------------------------------- connections and activity
def _connect(self, c, agent, channel, host=""):
now = self.now()
c.execute("INSERT INTO connections(agent, channel, host, last_at, count) VALUES (?,?,?,?,1) "
"ON CONFLICT(agent, channel, host) DO UPDATE SET last_at=excluded.last_at, count=count+1",
(agent, channel, host or "", now))
c.execute("UPDATE agents SET last_seen=? WHERE name=?", (now, agent))
def connect(self, agent, channel, host=""):
"""Record that agent reached Harbor through channel (mcp, hook, cli, web) from host (codex, kimi, ...)."""
self._write(lambda c: self._connect(c, agent, channel, host))
def activity(self, agent, tool, host="", target=None, channel="hook"):
"""Agent used a tool: drives the 'working now' indicator, the pulse and the live feed. Hooks report every tool
call; agents whose app runs no hooks still show up through their Harbor calls (channel cli or mcp) and the
server's worktree scan (channel files)."""
now = self.now()
def do(c):
self._connect(c, agent, channel, host)
c.execute("UPDATE agents SET active_at=?, last_tool=? WHERE name=?", (now, str(tool)[:80] or None, agent))
self._pulse(c, agent, tools=1)
self._log_activity(c, agent, tool, target)
self._write(do)
def connections(self):
out = {}
for r in self.conn.execute("SELECT * FROM connections ORDER BY agent, last_at DESC"):
out.setdefault(r["agent"], []).append({**dict(r), "minutes": self.minutes_since(r["last_at"])})
return out
def working(self, agent_row, within=3):
minutes = self.minutes_since(agent_row.get("active_at"))
return minutes is not None and minutes < within
# ----------------------------------------------------------------- the human's availability
def set_presence(self, by, origin, status, until=None, note=None):
if by != self.human or origin != "web":
raise HarborError(f"only {self.human} sets their availability, from the chat page")
if status not in PRESENCE:
raise HarborError(f"status must be one of {', '.join(PRESENCE)}")
until_at = parse_until(until, utc(self.clock())) if until else None
note = " ".join((note or "").split())[:300] or None
now = self.now()
def do(c):
c.execute("INSERT INTO presence(name, status, until, note, set_at) VALUES (?,?,?,?,?) ON CONFLICT(name) DO "
"UPDATE SET status=excluded.status, until=excluded.until, note=excluded.note, set_at=excluded.set_at",
(self.human, status, until_at, note, now))
self._write(do)
return self.presence()
def presence(self):
row = self.conn.execute("SELECT * FROM presence WHERE name=?", (self.human,)).fetchone()
if not row:
return {"name": self.human, "status": "here", "until": None, "note": None, "set_at": None}
p = dict(row)
if p["until"] and parse_iso(p["until"]) <= utc(self.clock()):
p.update(status="here", expired=True)
return p
def presence_line(self):
p = self.presence()
if p["status"] == "here":
return None
what = {"away": "is away", "dnd": "does not want to be disturbed"}[p["status"]]
line = f"{self.human} {what}" + (f" until {p['until']}" if p["until"] else "")
return line + (f": {p['note']}" if p["note"] else "") + " - decide with a coordinator instead of waiting."
# ----------------------------------------------------------------- handoffs
def handoff(self, agent, body):
self._check_agent(agent, allow_human=False)
body = (body or "").replace("\r\n", "\n").strip()
if not body:
raise HarborError("empty handoff")
if len(body) > 20000:
raise HarborError("a handoff is at most 20,000 characters; attach a file to a message instead")
now = self.now()
def do(c):
hid = c.execute("INSERT INTO handoffs(agent, body, created_at) VALUES (?,?,?)", (agent, body, now)).lastrowid
self._touch(c, agent)
return hid
return {"id": self._write(do), "agent": agent, "created_at": now}
def handoffs(self, agent=None, limit=10):
if agent:
rows = self.conn.execute("SELECT * FROM handoffs WHERE agent=? ORDER BY id DESC LIMIT ?", (agent, int(limit)))
else:
rows = self.conn.execute("SELECT * FROM handoffs ORDER BY id DESC LIMIT ?", (int(limit),))
return [dict(r) for r in rows]
def last_handoff(self, agent):
rows = self.handoffs(agent, limit=1)
return rows[0] if rows else None
# ----------------------------------------------------------------- pins
def pin(self, by, message_id, note=None):
self._check_agent(by)
if not self.message(message_id):
raise HarborError(f"no message #{message_id}")
now = self.now()
def do(c):
c.execute("INSERT INTO pins(message_id, pinned_by, note, pinned_at) VALUES (?,?,?,?) ON CONFLICT(message_id) "
"DO UPDATE SET note=COALESCE(excluded.note, note)", (int(message_id), by, note, now))
self._write(do)
return self.pins()
def unpin(self, by, message_id):
self._check_agent(by)
self._write(lambda c: c.execute("DELETE FROM pins WHERE message_id=?", (int(message_id),)))
return self.pins()
def pins(self, limit=30):
rows = self.conn.execute("SELECT p.note pin_note, p.pinned_by, p.pinned_at, m.*, t.topic, t.subject FROM pins p "
"JOIN messages m ON m.id=p.message_id JOIN threads t ON t.id=m.thread_id "
"ORDER BY p.pinned_at DESC LIMIT ?", (int(limit),))
return [self._message_dict(r) for r in rows]
def decisions(self, limit=30):
rows = self.conn.execute(self.MESSAGE_SELECT + " WHERE m.kind='decision' ORDER BY m.id DESC LIMIT ?", (int(limit),))
return [self._message_dict(r) for r in rows]
# ----------------------------------------------------------------- tasks
def _task_dict(self, row):
t = dict(row)
t["priority_name"] = PRIORITY_NAMES.get(t["priority"], str(t["priority"]))
t["depends_on"] = [r[0] for r in self.conn.execute("SELECT depends_on FROM task_deps WHERE task_id=? ORDER BY 1",
(t["id"],))]
t["waiting_on"] = [r[0] for r in self.conn.execute(
"SELECT d.depends_on FROM task_deps d JOIN tasks x ON x.id=d.depends_on WHERE d.task_id=? AND "
"x.state NOT IN ('done', 'cancelled') ORDER BY 1", (t["id"],))]
return t
def task(self, task_id):
row = self.conn.execute("SELECT * FROM tasks WHERE id=?", (int(task_id),)).fetchone()
if not row:
raise HarborError(f"no task #{task_id}")
events = [dict(r) for r in self.conn.execute("SELECT * FROM task_events WHERE task_id=? ORDER BY id",
(int(task_id),))]
return self._task_dict(row), events
def tasks(self, states=OPEN_TASK_STATES, assignee=None, limit=200):
query = f"SELECT * FROM tasks WHERE state IN ({','.join('?' * len(states))})"
args = list(states)
if assignee:
query += " AND assignee=?"
args.append(assignee)
query += " ORDER BY CASE state WHEN 'in_progress' THEN 0 WHEN 'blocked' THEN 1 WHEN 'review' THEN 2 ELSE 3 END, "
query += "priority, COALESCE(due_at, '9999'), id LIMIT ?"
args.append(int(limit))
return [self._task_dict(r) for r in self.conn.execute(query, args)]
def add_task(self, by, title, body=None, scope=None, assignee=None, priority="normal", after=(), due=None,
origin="cli", notify=True):
self._check_agent(by, allow_human=(origin == "web"))
title = " ".join((title or "").split())
if not title:
raise HarborError("a task needs a title")
if assignee:
self._check_agent(assignee, allow_human=False)
if assignee != by and by not in self.coordinators:
raise HarborError("only a coordinator assigns tasks to someone else; leave it unassigned for `next`")
canonical, lo, hi = parse_scope(scope) if scope else (None, None, None)
prio = parse_priority(priority)
due_at = parse_until(due, utc(self.clock())) if due else None
after = [int(a) for a in (after or [])]
for dep in after:
self.task(dep)
now = self.now()
def do(c):
tid = c.execute("INSERT INTO tasks(title, body, scope, lo, hi, priority, state, assignee, created_by, due_at, "
"created_at, updated_at) VALUES (?,?,?,?,?,?,'open',?,?,?,?,?)",
(title, body, canonical, lo, hi, prio, assignee, by, due_at, now, now)).lastrowid
for dep in after:
c.execute("INSERT OR IGNORE INTO task_deps(task_id, depends_on) VALUES (?,?)", (tid, dep))
c.execute("INSERT INTO task_events(task_id, agent, from_state, to_state, note, at) VALUES (?,?,NULL,'open',?,?)",
(tid, by, body and first_line(body, 200), now))
self._touch(c, by)
return tid
tid = self._write(do)
if notify and assignee and assignee != by:
self.send(by, [assignee], f"Task #{tid} is yours: {title}" + (f"\n{body}" if body else ""),
thread=f"task:{tid}", subject=f"Task: {title}", kind="request",
importance="high" if prio <= 1 else "normal", trusted_refs={"task": tid}, origin="system")
return self.task(tid)[0]
def _move_task(self, c, task, agent, to_state, note=None, **fields):
now = self.now()
sets = ", ".join(["state=?", "updated_at=?"] + [f"{k}=?" for k in fields])
cur = c.execute(f"UPDATE tasks SET {sets} WHERE id=? AND state=?",
(to_state, now, *fields.values(), task["id"], task["state"]))
if cur.rowcount == 0:
raise HarborError(f"task #{task['id']} changed meanwhile; look at it again")
c.execute("INSERT INTO task_events(task_id, agent, from_state, to_state, note, at) VALUES (?,?,?,?,?,?)",
(task["id"], agent, task["state"], to_state, note, now))
def _eligible(self, task, agent):
if task["state"] != "open" or task["assignee"] not in (None, agent) or task["waiting_on"]:
return False
if task["scope"]:
now = self.now()
for a in self.conn.execute("SELECT * FROM claims WHERE released_at IS NULL AND (expires_at IS NULL OR "
"expires_at > ?) AND agent != ?", (now, agent)):
if a["kind"] in ("draft", "naming", "edit", "band") and scopes_overlap(
(task["scope"], task["lo"], task["hi"]), (a["scope"], a["lo"], a["hi"])):
if a["kind"] == "band" and task["assignee"] == agent:
continue # assigned to me explicitly inside someone's band
return False
return True
def take_task(self, agent, task_id, note=None):
self._check_agent(agent, allow_human=False)
task, _ = self.task(task_id)
if task["state"] != "open":
raise HarborError(f"task #{task_id} is {task['state']}" + (f" (assigned to {task['assignee']})"
if task["assignee"] else ""))
if task["assignee"] not in (None, agent):
raise HarborError(f"task #{task_id} is assigned to {task['assignee']}")
if task["waiting_on"]:
raise HarborError(f"task #{task_id} waits on " + ", ".join(f"#{d}" for d in task["waiting_on"]))
self.require_running(agent, "take tasks")
claim_id = None
if task["scope"]:
if task["assignee"] == agent and task["created_by"] in self.coordinators and task["created_by"] != agent and task["created_by"] != self.human:
# a coordinator assigned it explicitly (possibly inside someone else's band): record it as theirs
claim_id = self.claim(task["created_by"], task["scope"], kind="draft", note=f"task #{task_id}",
on_behalf_of=agent, override=True)["id"]
elif task["assignee"] == agent and task["created_by"] == self.human:
coordinator = next(iter(self.project.coordinators), None)
claim_id = (self.claim(coordinator, task["scope"], kind="draft", note=f"task #{task_id} (assigned by "
f"{self.human})", on_behalf_of=agent, override=True)["id"] if coordinator else
self.claim(agent, task["scope"], kind="draft", note=f"task #{task_id}")["id"])
else:
claim_id = self.claim(agent, task["scope"], kind="draft", note=f"task #{task_id}")["id"]
def do(c):
self._move_task(c, task, agent, "in_progress", note, assignee=agent, claim_id=claim_id, block_reason=None)
now = self.now()
c.execute("UPDATE agents SET doing=?, doing_at=?, last_seen=? WHERE name=?",
(f"task #{task_id}: {task['title']}"[:200], now, now, agent))
try:
self._write(do)
except HarborError:
if claim_id:
self._write(lambda c: c.execute("UPDATE claims SET released_at=? WHERE id=?", (self.now(), claim_id)))
raise
return self.task(task_id)[0]
def next_task(self, agent, take=True):
"""The best open task agent may take (priority, then due time, then age); takes it unless take=False."""
self._check_agent(agent, allow_human=False)
if self.stopped(agent):
return None
for task in self.tasks(states=("open",), limit=100000):
if self._eligible(task, agent):
if not take:
return task
try:
return self.take_task(agent, task["id"])
except HarborError:
continue
return None
def update_task(self, agent, task_id, to_state, note=None, origin="cli"):
self._check_agent(agent, allow_human=(origin == "web"))
if to_state not in TASK_STATES:
raise HarborError(f"task state must be one of {', '.join(TASK_STATES)}")
task, _ = self.task(task_id)
if to_state == "in_progress" and task["state"] == "open":
return self.take_task(agent, task_id, note)
if to_state == "review":
raise HarborError("a task goes to review by `submit --task`")
if to_state not in TASK_MOVES[task["state"]]:
raise HarborError(f"task #{task_id} is {task['state']}; it can move to "
+ ", ".join(sorted(TASK_MOVES[task["state"]])))
owner = (agent == task["assignee"] or agent in self.coordinators or
(agent == task["created_by"] and (not task["assignee"] or task["state"] in ("open", "done", "cancelled"))))
if not owner:
raise HarborError(f"task #{task_id} belongs to {task['assignee'] or task['created_by']}")
if to_state == "in_progress":
self.require_running(agent, "resume tasks")
if to_state == "blocked" and not note:
raise HarborError("say what blocks it (note)")
fields = {}
if to_state == "blocked":
fields["block_reason"] = note
releasing = to_state in ("open", "done", "cancelled") and task["claim_id"]
if releasing:
fields["claim_id"] = None
assigned = task["created_by"] != task["assignee"] and (task["created_by"] in self.coordinators
or task["created_by"] == self.human)
if to_state == "open" and task["state"] in ("in_progress", "blocked") and not assigned:
fields["assignee"] = None # handed back: anyone may take it with `next`
# a task a coordinator (or the human) gave to someone stays theirs when they hand it back for later: clearing it
# would let band routing hand it to the band owner (a load-balanced task inside another agent's band)
def do(c):
if releasing:
c.execute("UPDATE claims SET released_at=? WHERE id=? AND released_at IS NULL", (self.now(), task["claim_id"]))
self._move_task(c, task, agent, to_state, note, **fields)
if to_state in ("done", "cancelled", "open") and task["assignee"]:
c.execute("UPDATE agents SET doing=NULL WHERE name=? AND doing LIKE ?", (task["assignee"], f"task #{task_id}:%"))
self._touch(c, agent)
self._write(do)
people = {task["created_by"], *self.project.coordinators} - {agent}
if to_state == "blocked" and people:
self.send(agent, sorted(people), f"Task #{task_id} ({task['title']}) is blocked: {note}",
thread=f"task:{task_id}", subject=f"Task: {task['title']}", kind="status", importance="high",
trusted_refs={"task": int(task_id)}, origin="system")
elif to_state == "done" and task["created_by"] != agent:
self.send(agent, [task["created_by"]], f"Task #{task_id} ({task['title']}) is done" + (f": {note}" if note else "."),
thread=f"task:{task_id}", subject=f"Task: {task['title']}", kind="status",
trusted_refs={"task": int(task_id)}, origin="system")
return self.task(task_id)[0]
def _task_follows_item(self, item_id, item_state, agent):
"""Keep a task in step with its review item: landed -> done; sent back -> in progress."""
row = self.conn.execute("SELECT * FROM tasks WHERE item_id=?", (int(item_id),)).fetchone()
if not row:
return
task = self._task_dict(row)
target = {"landed": "done", "changes_requested": "in_progress", "withdrawn": "in_progress",
"rejected": "in_progress", "submitted": "review"}.get(item_state)
if not target or target == task["state"] or task["state"] not in ("review", "in_progress", "blocked") \
or target not in TASK_MOVES[task["state"]]:
return
def do(c):
self._move_task(c, task, agent, target, f"review item #{item_id} {item_state}")
if target == "done" and task["claim_id"]:
c.execute("UPDATE claims SET released_at=? WHERE id=? AND released_at IS NULL", (self.now(), task["claim_id"]))
c.execute("UPDATE agents SET doing=NULL WHERE name=? AND doing LIKE ?", (task["assignee"], f"task #{task['id']}:%"))
self._write(do)
# ----------------------------------------------------------------- restart prompt
def restart_prompt(self, agent, cmd=None):
"""A prompt to paste into a fresh session for agent: who it is, where it works, the rules, and the state."""
from .texts import launcher, protocol
self._check_agent(agent, allow_human=False)
cmd = cmd or launcher()
if not any(a["name"] == agent for a in self.project.agents):
raise HarborError(f"{agent} is not on this project's roster")
run = " ".join(cmd)
spec = next(a for a in self.project.agents if a["name"] == agent)
lines = [f"You are {agent} on the {self.project.title} team" + (f" ({spec['role']})." if spec["role"] else ".")]
if spec.get("workdir"):
lines.append(f"Work in {spec['workdir']}.")
if spec.get("instructions"):
lines.append(spec["instructions"].strip())
lines.append(f"Before anything else run `{run} --as {agent} brief` (or the Harbor MCP tool `brief`) and follow it.")
lines.append("")
lines.append(protocol(agent, self.project.name, cmd).strip())
lines.append("")
lines.append(f"State from Harbor at {self.now()}:")
stops = self.stopped(agent)
lines.append("- " + ("STOPPED (" + ", ".join(f["key"] for f in stops) + "): wait for the human to resume you; "
"do not start work." if stops else "Not stopped."))
mine = self.tasks(assignee=agent)
if mine:
lines.append("- Your tasks: " + "; ".join(f"#{t['id']} {t['state']} {t['title']}" for t in mine[:8]))
else:
nxt = self.next_task(agent, take=False)
lines.append("- No task in progress." + (f" Next available: #{nxt['id']} {nxt['title']} (take it with "
"`next`)." if nxt else ""))
claims = self.active_claims(agent=agent)
if claims:
lines.append("- Your claims: " + ", ".join(f"{c['kind']} {c['scope']}" for c in claims[:12]))
total, urgent = self.unread_count(agent)
lines.append(f"- Unread messages: {total}" + (f" ({urgent} urgent)" if urgent else ""))
h = self.last_handoff(agent)
if h:
lines.append(f"- Your last handoff ({h['created_at']}):")
lines += [" " + line for line in h["body"].split("\n")[:40]]
presence = self.presence_line()
if presence:
lines.append("- " + presence)
return "\n".join(lines)
+55
View File
@@ -0,0 +1,55 @@
# Connecting agent hosts
Run `harbor install <host> [--agent Name] [--project name --bind] [--dry-run | --uninstall]`. Every host gets three things:
- **Harbor's MCP tools.**
- **Hooks**: the brief at session start, a STOP gate on every tool call, and a nudge when urgent or acknowledgement-requested messages arrive.
- **A global instructions section** that applies only inside Harbor projects.
All hook commands are `py -3.12 -B <Harbor>/run.py --as <Agent> hook <event> --host <host>`. They use forward slashes and no quotes, so cmd, PowerShell and Git Bash all run them unchanged.
Decisions go back as Claude-compatible JSON at exit 0, because a host that runs the command through PowerShell would turn exit code 2 into 1:
- before a tool: `permissionDecision: "deny"`;
- at the end of a turn: `decision: "block"`.
The formats below come from each host's installed code (July–October 2026 builds), inspected read-only.
| Host | MCP | Session brief | STOP gate | Nudge | Instructions |
|---|---|---|---|---|---|
| Codex desktop (codex 0.159) | `~/.codex/config.toml` `[mcp_servers.harbor]` | `~/.codex/hooks.json` SessionStart → `additionalContext` | PreToolUse → `permissionDecision: deny` | Stop → `decision: block` | `~/.codex/AGENTS.md` |
| OpenCode desktop (1.18) | `opencode.jsonc` `"mcp"` (`type: local`) | plugin, `session.created` → `client.session.prompt({noReply})` | plugin `tool.execute.before` throws | plugin, `session.idle` plus a 60 s check → `client.session.promptAsync` | `~/.config/opencode/AGENTS.md` |
| DeepSeek Harness (0.2) | `~/.dsh/profiles/desktop/cordis.patch.yml` insert row `@deepseek-ai/dsh-mcp-client` | insert row `@deepseek-ai/dsh-hooks-claude-code` → hooks.json | PreToolUse | Stop | `~/.dsh/AGENTS.md` |
| Kimi desktop (daimon 0.6) | personal plugin `kimi.plugin.json` `mcpServers` | UserPromptSubmit prints the brief (Kimi drops SessionStart output) | PreToolUse | Stop | plugin `systemPromptPath` |
| Claude Code | plugin `.mcp.json` | plugin hooks SessionStart | PreToolUse | Stop | plugin skill |
## Notes per host
**Codex**
- Codex runs hooks only after you trust them: Settings → Hooks → Trust. It records that trust in `config.toml` itself.
- Codex refuses hooks defined in both `config.toml` and `hooks.json`, so the installer refuses when a `[hooks]` table already exists.
**OpenCode**
- The plugin also wakes an idle session when a new message arrives for the agent:
- it checks every 60 s;
- it sends one nudge per batch of messages;
- it never nudges while the agent is stopped.
- Set `WAKE_IDLE_SESSIONS = false` at the top of `~/.config/opencode/plugin/harbor.js` to turn this off.
- Restart OpenCode after installing.
**DeepSeek Harness**
- The MCP row hot-reloads.
- The hooks file is read when the app starts, so restart the app once.
- DeepSeek's bridge has no `stop_hook_active` guard. Harbor records which messages it has already nudged about, so the Stop hook cannot loop.
**Kimi**
- Kimi works from its own workspace folder, so its plugin is bound to one project (`harbor-<project>`).
- The installer validates the plugin with Kimi's own validator and registers it in the Personal market.
- You install it from Plugins → Personal. Kimi asks you to review plugins that carry hooks.
**Claude Code**
- Add the plugin with `/plugin marketplace add <harbor home>/integrations/claude`, then `/plugin install harbor@harbor-local`.
- Set `HARBOR_AGENT` in the session's environment.
## Waking a session that has ended
Hooks only run while an agent's session is running. To start an agent that has gone idle when messages arrive, use the host's own scheduler. Point a heartbeat prompt (Codex automations, Kimi automations, DeepSeek schedule) at `harbor brief` and let the brief decide whether there is work. Harbor never creates schedules itself.
+93
View File
@@ -0,0 +1,93 @@
// Harbor for OpenCode: the STOP gate on every tool call, the brief at the start of each session, and a nudge into
// an idle session when new Harbor messages arrive for this agent (one nudge per batch of messages, never while
// stopped). Installed by `harbor install opencode`; the lines between the markers are filled in by the installer.
import { spawn } from "node:child_process";
// >>> harbor settings >>>
const AGENT = "OpenCode";
const PROJECT = null;
const COMMAND = ["py", "-3.12", "-B", "R:/Workspace/Tools/Harbor/run.py"];
const WAKE_IDLE_SESSIONS = true; // check idle sessions every minute and nudge them when messages arrive
// <<< harbor settings <<<
function harbor(args, payload) {
return new Promise((resolve) => {
const argv = [...COMMAND.slice(1), "--as", AGENT, ...(PROJECT ? ["--project", PROJECT] : []), ...args];
let out = "";
let err = "";
let child;
try {
child = spawn(COMMAND[0], argv, { windowsHide: true });
} catch (error) {
return resolve({ code: 0, out: "", err: String(error) });
}
const timer = setTimeout(() => { try { child.kill(); } catch {} }, 20000);
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (err += d));
child.on("error", (error) => { clearTimeout(timer); resolve({ code: 0, out: "", err: String(error) }); });
child.on("close", (code) => { clearTimeout(timer); resolve({ code: code ?? 0, out, err }); });
child.stdin.end(JSON.stringify(payload ?? {}));
});
}
// The Python hook decides what a stopped agent may still run; the plugin only caches "allowed" briefly.
export const HarborPlugin = async ({ client, directory }) => {
let gate = { at: 0, blocked: false, reason: "" };
const idleSessions = new Set();
let checking = false;
async function nudge(sessionID) {
const r = await harbor(["hook", "idle", "--host", "opencode"], { cwd: directory });
let info = {};
try { info = JSON.parse(r.out || "{}"); } catch {}
if (!info.prompt) return false;
idleSessions.delete(sessionID);
await client.session.promptAsync({ path: { id: sessionID }, body: { parts: [{ type: "text", text: info.prompt }] } });
return true;
}
if (WAKE_IDLE_SESSIONS) {
setInterval(async () => {
if (checking || idleSessions.size === 0) return;
checking = true;
try {
const newest = [...idleSessions].pop();
await nudge(newest);
} catch {} finally { checking = false; }
}, 60000);
}
return {
"tool.execute.before": async (input, output) => {
const tool = input?.tool ?? "";
const args = output?.args ?? {};
const now = Date.now();
const edits = /edit|write|patch|apply|create|replace/i.test(String(tool));
if (gate.blocked || edits || now - gate.at > 2000 || /harbor/i.test(tool + JSON.stringify(args))) {
const r = await harbor(["hook", "pre-tool", "--host", "opencode"], { cwd: directory, tool_name: tool, tool_input: args });
gate = { at: now, blocked: r.code === 2, reason: r.err.trim() };
}
if (gate.blocked) throw new Error(gate.reason || "Harbor: STOP in force for this agent. Do not continue work.");
},
event: async ({ event }) => {
try {
if (event?.type === "session.created") {
const id = event.properties?.info?.id ?? event.properties?.sessionID;
if (!id) return;
const r = await harbor(["hook", "session-start", "--host", "generic"], { cwd: directory });
const text = r.out.trim();
if (text) await client.session.prompt({ path: { id }, body: { noReply: true, parts: [{ type: "text", text }] } });
} else if (event?.type === "session.idle") {
const id = event.properties?.sessionID;
if (!id) return;
idleSessions.delete(id);
idleSessions.add(id);
if (!(await nudge(id))) await harbor(["hook", "turn-end", "--host", "opencode"], { cwd: directory });
} else if (event?.type === "session.status" && event.properties?.status?.type === "busy") {
idleSessions.delete(event.properties?.sessionID);
}
} catch {}
},
};
};
+1
View File
@@ -0,0 +1 @@
home = 'R:\Workspace\Data\harbor'
+22
View File
@@ -0,0 +1,22 @@
[project]
name = "harbor-agents"
version = "2.0.0"
description = "Local coordination hub for teams of AI coding agents: threads, acknowledgements, claims, review queue, stop switch, chat page and board"
requires-python = ">=3.11"
dependencies = []
[project.optional-dependencies]
mcp = ["fastmcp>=2.10"]
[project.scripts]
harbor = "harbor.cli:run"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["harbor*"]
[tool.setuptools.package-data]
harbor = ["web/*.html", "web/*.css", "web/*.svg", "web/*.js", "web/*.woff2", "web/*.txt"]
+8
View File
@@ -0,0 +1,8 @@
"""Run Harbor from this folder without installing it: py -3.12 -B <this folder>/run.py <command> ..."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from harbor.cli import run # noqa: E402
run()
+134
View File
@@ -0,0 +1,134 @@
"""Shared test fixtures: temporary projects under R:\\Workspace\\Data\\openseas-chat\\test-tmp (never C:)."""
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
import unittest
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from harbor import projects # noqa: E402
from harbor.core import Harbor # noqa: E402
TMP_ROOT = Path(os.environ.get("HARBOR_TEST_TMP", r"R:\Workspace\Data\openseas-chat\test-tmp"))
LEGACY_LINE = re.compile(r'^\[([^\]]+)\]\s+([A-Za-z]+)\s*(?:→|->)\s*([A-Za-z]+):\s?(.*)$')
ROSTER = [{"name": n, "role": r} for n, r in (("Claude", "Integration"), ("Codex", "Decompilation"),
("DeepSeek", "Decompilation"), ("OpenCode", "Decompilation"),
("Kimi", "Review"))] + [{"name": "User", "kind": "human"}]
PROJECT_TOML = """name = "{name}"
title = "Test {name}"
database = '{db}'
mirror = '{mirror}'
coordinators = ["Claude"]
reviewers = ["Claude"]
[identities]
"worktree-deepseek" = "DeepSeek"
[attention]
ack_overdue = 30
item_stale = 120
agent_quiet = 60
""" + "".join(f'\n[[agents]]\nname = "{a["name"]}"\n' + (f'kind = "{a["kind"]}"\n' if a.get("kind") else "")
for a in ROSTER)
class Clock:
def __init__(self, t=1_790_000_000.0):
self.t = t
def __call__(self):
return self.t
def temp_dir():
TMP_ROOT.mkdir(parents=True, exist_ok=True)
fence = TMP_ROOT / ".harbor.toml" # tests never join a project marked above this folder
if not fence.exists():
fence.write_text("# Test folders belong to no Harbor project above this one.\nboundary = true\n", encoding="utf-8")
return Path(tempfile.mkdtemp(dir=TMP_ROOT))
def make_home(folder, names=("test",)):
"""A Harbor home with registered projects; returns {name: Project}."""
out = {}
for name in names:
pdir = folder / "projects"
pdir.mkdir(parents=True, exist_ok=True)
mirror = folder / name / "agents-chat.md"
mirror.parent.mkdir(parents=True, exist_ok=True)
mirror.write_text("# chat\n\n---\n", encoding="utf-8")
(pdir / f"{name}.toml").write_text(PROJECT_TOML.format(name=name, db=folder / name / "harbor.db",
mirror=mirror), encoding="utf-8")
out[name] = projects.load_named(name, home=folder)
(folder / "config.toml").write_text(f'default_project = "{names[0]}"\n', encoding="utf-8")
return out
class HarborCase(unittest.TestCase):
def setUp(self):
self.dir = temp_dir()
self.project = make_home(self.dir)["test"]
self.mirror = self.project.mirror
self.clock = Clock()
self.h = Harbor(self.project, clock=self.clock)
def tearDown(self):
self.h.close()
shutil.rmtree(self.dir, ignore_errors=True)
def env_for(home, **extra):
env = {k: v for k, v in os.environ.items() if not k.startswith("HARBOR_")}
env.update(HARBOR_HOME=str(home), PYTHONDONTWRITEBYTECODE="1", HARBOR_NO_TOASTS="1", **extra)
return env
def run_cli(home, *args, cwd=None, stdin=None, **env):
return subprocess.run([sys.executable, "-B", "-m", "harbor", *args], cwd=cwd or home, env=env_for(home, **env),
input=stdin, capture_output=True, text=True, encoding="utf-8", timeout=60,
**({} if cwd else {}))
def run_module(home, *args, cwd=None, stdin=None, **env):
environment = env_for(home, **env)
environment["PYTHONPATH"] = str(ROOT)
return subprocess.run([sys.executable, "-B", "-m", "harbor", *args], cwd=cwd or home, env=environment,
input=stdin, capture_output=True, text=True, encoding="utf-8", timeout=60)
def http(port, path, body=None, headers=None, browser=True, token=None):
h = {}
if body is not None:
h["Content-Type"] = "application/json"
if browser:
h.update({"Sec-Fetch-Site": "same-origin", "X-Harbor-Token": token or "",
"Origin": f"http://127.0.0.1:{port}"})
h.update(headers or {})
data = json.dumps(body).encode("utf-8") if body is not None else None
request = urllib.request.Request(f"http://127.0.0.1:{port}{path}", data=data, headers=h)
try:
with urllib.request.urlopen(request, timeout=10) as response:
raw = response.read()
kind = response.headers.get("Content-Type", "")
return response.status, (json.loads(raw or b"null") if "json" in kind else raw.decode("utf-8", "replace"))
except urllib.error.HTTPError as error:
return error.code, json.loads(error.read() or b"null")
def wait_for_port(port, seconds=15):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
try:
urllib.request.urlopen(f"http://127.0.0.1:{port}/participants", timeout=1).read()
return True
except OSError:
time.sleep(0.1)
return False
+369
View File
@@ -0,0 +1,369 @@
"""Harbor store tests (run: py -3.12 -B -m unittest discover -s R:\\Workspace\\Tools\\Harbor\\tests)."""
import shutil
import subprocess
import sys
import threading
import time
import unittest
from pathlib import Path
from helpers import LEGACY_LINE, ROSTER, Clock, HarborCase, make_home, temp_dir
from harbor import projects
from harbor.core import Harbor, HarborError, parse_scope
TOOLS = Path(r"R:\Workspace\Tools")
class Messages(HarborCase):
def test_send_inbox_ack(self):
r = self.h.send("Claude", ["DeepSeek"], "Please fix 0x529350", thread="fn:0x529350",
subject="AudioManagerTick", kind="request", ack_required=True)
self.assertEqual(r["targets"], ["DeepSeek"])
self.assertEqual([m["id"] for m in self.h.inbox("DeepSeek")], [r["id"]])
self.assertEqual(self.h.inbox("DeepSeek"), [])
self.assertEqual(len(self.h.pending_acks(sender="Claude")), 1)
self.h.ack("DeepSeek", [r["id"]], note="fixed")
self.assertEqual(self.h.pending_acks(sender="Claude"), [])
with self.assertRaises(HarborError):
self.h.ack("Kimi", [r["id"]])
def test_all_expands_to_everyone_but_sender(self):
r = self.h.send("Codex", "All", "hello")
self.assertEqual(sorted(r["targets"]), sorted(["Claude", "DeepSeek", "OpenCode", "Kimi", "User"]))
def test_reply_keeps_thread_and_people(self):
first = self.h.send("Claude", ["DeepSeek", "Kimi"], "question", thread="fn:0x50A720")
reply = self.h.send("DeepSeek", None, "answer", reply_to=first["id"])
self.assertEqual(reply["thread_id"], first["thread_id"])
self.assertEqual(sorted(reply["targets"]), ["Claude", "Kimi"])
broadcast = self.h.send("Codex", "All", "status for everyone")
answer = self.h.send("Kimi", None, "noted", reply_to=broadcast["id"])
self.assertEqual(answer["recipients"], ["All"])
def test_urgent_first_and_length_limits(self):
self.h.send("Claude", ["Kimi"], "normal one")
urgent = self.h.send("Claude", ["Kimi"], "urgent one", importance="urgent")
self.assertEqual(self.h.inbox("Kimi")[0]["id"], urgent["id"])
self.assertTrue(self.h.send("Claude", ["Kimi"], "word " * 3000)["id"])
with self.assertRaises(HarborError):
self.h.send("Claude", ["Kimi"], "x" * 20001)
def test_only_web_can_speak_as_the_human(self):
for origin in ("cli", "mcp", "hook", "cli-legacy"):
with self.assertRaises(HarborError):
self.h.send("User", "All", "resume everyone", origin=origin)
self.assertTrue(self.h.send("User", "All", "hi", origin="web")["id"])
def test_idempotent_send(self):
a = self.h.send("Claude", ["Kimi"], "once", idem="k1")
b = self.h.send("Claude", ["Kimi"], "once", idem="k1")
self.assertEqual(a["id"], b["id"])
self.assertEqual(len(self.h.inbox("Kimi")), 1)
def test_lint_compressed_text_and_flooding(self):
squashed = ("Root full222/ALL11 tick relocation audit proves SEVEN bad offsets; correctOffsets24ByteLayout "
"vs yourOffsetsWereWrong and stub23HarnessBug plus relocgen2Output and nativeBaseline6Cases")
self.assertTrue(any("compressed" in w for w in self.h.send("Codex", ["DeepSeek"], squashed)["warnings"]))
self.assertEqual(self.h.send("Codex", ["DeepSeek"], "Commit 4c392e33ae94858b fixed 0x45A970.")["warnings"], [])
for i in range(8):
self.h.send("Codex", "All", f"progress {i}")
self.assertTrue(any("in the last hour" in w for w in self.h.send("Codex", "All", "more")["warnings"]))
self.assertEqual(self.h.send("Codex", ["Claude"], "direct is fine")["warnings"], [])
def test_search_and_attachments(self):
self.h.send("Claude", ["Kimi"], "LayoutTextMesh zero-extends the glyph key")
self.h.send("Claude", ["Kimi"], "unrelated")
self.assertEqual(len(self.h.search("LayoutTextMesh glyph")), 1)
report = self.dir / "report.txt"
report.write_text("six cases match", encoding="utf-8")
r = self.h.send("DeepSeek", ["Claude"], "harness report attached", attachments=[report])
name = self.h.message(r["id"])["refs"]["attachments"][0]
self.assertTrue(name.endswith("-report.txt"))
self.assertEqual((self.project.attachments / name).read_text(encoding="utf-8"), "six cases match")
self.assertIn("Attachments: " + name, self.mirror.read_text(encoding="utf-8"))
with self.assertRaises(HarborError):
self.h.send("DeepSeek", ["Claude"], "x", attachments=[self.dir / "missing.txt"])
def test_receipts(self):
r = self.h.send("User", ["Codex", "Kimi"], "please both look", origin="web", ack_required=True)
self.h.inbox("Codex")
self.h.ack("Kimi", [r["id"]])
self.assertEqual(self.h.receipts([r["id"]])[r["id"]], {"read": ["Codex"], "acked": ["Kimi"], "waiting": []})
def test_threads_close_and_reopen(self):
r = self.h.send("Claude", ["Kimi"], "question", thread="fn:0x445310")
self.h.set_thread_state("Kimi", "fn:0x445310", "closed")
self.assertNotIn("fn:0x445310", [t["topic"] for t in self.h.threads(include_closed=False)])
self.h.send("Kimi", None, "one more thing", reply_to=r["id"])
self.assertEqual(self.h.thread("fn:0x445310")[0]["state"], "open")
def test_mirror_is_readable_by_old_tools(self):
self.h.send("Claude", ["DeepSeek"], "line one\n[2026-10-02 10:00Z] Codex → All: not a header\nline three",
kind="request", ack_required=True)
self.h.send("Claude", ["DeepSeek", "Kimi"], "to two people")
lines = self.mirror.read_text(encoding="utf-8").split("\n")
headers = [l for l in lines if LEGACY_LINE.match(l.strip())]
self.assertEqual(len(headers), 2)
self.assertIn("Claude → All:", headers[1])
self.assertIn("to DeepSeek,Kimi", headers[1])
if (TOOLS / "chat.py").exists():
out = subprocess.run([sys.executable, "-B", "-c",
"import sys; sys.path.insert(0, r'%s'); import chat, chat_common; "
"chat_common.CHAT = __import__('pathlib').Path(r'%s'); chat.CHAT = chat_common.CHAT; "
"chat.tail(5)" % (TOOLS, self.mirror)], capture_output=True, text=True,
encoding="utf-8")
self.assertIn("line three", out.stdout)
class Presence(HarborCase):
def test_doing_shows_in_briefs_and_digest(self):
self.h.doing("DeepSeek", "drafting 0x529350 AudioManagerTick")
self.assertIn("DeepSeek: drafting 0x529350", self.h.brief("Kimi"))
self.assertNotIn("Team now", self.h.brief("DeepSeek").split("DeepSeek: drafting")[0][-1:])
with self.assertRaises(HarborError):
self.h.doing("User", "x")
d = self.h.digest("1h")
self.assertEqual(next(a for a in d["agents"] if a["name"] == "DeepSeek")["doing"],
"drafting 0x529350 AudioManagerTick")
self.h.doing("DeepSeek", "")
self.assertIsNone(next(a for a in self.h.agents() if a["name"] == "DeepSeek")["doing"])
def test_wait_returns_on_message_stop_or_timeout(self):
self.h.clock = __import__("time").time
self.assertTrue(self.h.wait("Kimi", timeout=0.3, poll=0.05)["timed_out"])
def later():
time.sleep(0.4)
sender = Harbor(self.project)
sender.send("Claude", ["Kimi"], "your turn")
sender.close()
threading.Thread(target=later).start()
started = time.monotonic()
r = self.h.wait("Kimi", timeout=10, poll=0.05)
self.assertLess(time.monotonic() - started, 5)
self.assertEqual([m["body"] for m in r["messages"]], ["your turn"])
self.h.set_flag("stop:Kimi", "on", "Claude", "cli")
self.h.mark_read("Kimi")
self.assertEqual(self.h.wait("Kimi", timeout=10, poll=0.05)["stopped"], ["stop:Kimi"])
def test_digest_and_attention(self):
self.h.send("Codex", ["Claude"], "a")
self.h.send("User", ["Codex"], "status please", origin="web")
item = self.h.submit("DeepSeek", "thing", scope="fn:0x445310", commit="abc")
self.h.transition("Claude", item["id"], "in_review")
self.h.send("Claude", ["Kimi"], "please confirm", ack_required=True)
self.h.touch("OpenCode")
d = self.h.digest("1h")
codex = next(a for a in d["agents"] if a["name"] == "Codex")
self.assertEqual((codex["messages"], codex["threads"]), (1, 1))
self.assertEqual([e["to_state"] for e in d["item_events"]], ["submitted", "in_review"])
self.assertEqual(self.h.digest("1h")["to_human"], [])
self.h.send("Codex", ["User"], "here is the status")
self.assertEqual([m["body"] for m in self.h.digest("1h")["to_human"]], ["here is the status"])
self.assertEqual(self.h.attention(), [])
self.clock.t += 3 * 3600
kinds = sorted(a["kind"] for a in self.h.attention())
self.assertEqual(kinds, ["ack_overdue", "agent_quiet", "agent_quiet", "agent_quiet", "agent_quiet", "item_stale"])
self.h.set_flag("stop:all", "on", "Claude", "cli")
self.assertNotIn("agent_quiet", [a["kind"] for a in self.h.attention()])
with self.assertRaises(HarborError):
self.h.digest("yesterday-ish")
class Claims(HarborCase):
def test_claims_conflicts_ttl_and_bands(self):
self.h.claim("Claude", "range:0x440000-0x47FFFF", kind="band", on_behalf_of="DeepSeek")
self.h.claim("Claude", "range:0x480000-0x4FFFFF", kind="band", on_behalf_of="OpenCode")
with self.assertRaises(HarborError):
self.h.claim("Kimi", "range:0x400000-0x401000", kind="band")
d = self.h.claim("DeepSeek", "fn:0x445310", kind="draft")
with self.assertRaises(HarborError):
self.h.claim("OpenCode", "0x445310", kind="draft")
with self.assertRaises(HarborError):
self.h.claim("OpenCode", "fn:0x450000", kind="draft")
self.assertTrue(self.h.claim("OpenCode", "fn:0x490000", kind="draft")["id"])
self.assertTrue(self.h.claim("Kimi", "fn:0x445310", kind="review")["id"])
renewed = self.h.claim("DeepSeek", "fn:0x445310", kind="draft", ttl_hours=1)
self.assertEqual((renewed["renewed"], renewed["id"]), (True, d["id"]))
self.clock.t += 2 * 3600
self.assertEqual([c for c in self.h.active_claims(kind="draft") if c["scope"] == "fn:0x445310"], [])
over = self.h.claim("Claude", "fn:0x450010", kind="draft", override=True)
self.assertEqual(len(over["conflicts"]), 1)
self.assertEqual(self.h.release("Claude", scope="fn:0x450010"), [over["id"]])
self.assertTrue(self.h.claim("Codex", "task:write the README")["id"])
with self.assertRaises(HarborError):
self.h.claim("Kimi", "task:Write the README")
def test_scope_parsing(self):
self.assertEqual(parse_scope("0x445310")[0], "fn:0x445310")
self.assertEqual(parse_scope("range:0x440000-0x47ffff")[1:], (0x440000, 0x47FFFF))
with self.assertRaises(HarborError):
parse_scope("somewhere")
class Review(HarborCase):
def test_review_item_lifecycle(self):
item = self.h.submit("DeepSeek", "RouteCellCost readable", scope="fn:0x445310", commit="abc1234",
branch="deepseek/decomp")
self.assertEqual([m["kind"] for m in self.h.inbox("Claude")], ["request"])
with self.assertRaises(HarborError):
self.h.transition("DeepSeek", item["id"], "approved")
self.h.transition("Claude", item["id"], "in_review")
self.h.transition("Claude", item["id"], "changes_requested", note="movsx: signed read")
self.assertEqual(self.h.inbox("DeepSeek")[-1]["kind"], "request")
self.h.transition("DeepSeek", item["id"], "submitted", commit="def5678")
self.h.transition("Kimi", item["id"], "approved")
with self.assertRaises(HarborError):
self.h.transition("Kimi", item["id"], "landed", commit="x")
with self.assertRaises(HarborError):
self.h.transition("Claude", item["id"], "landed")
done = self.h.transition("Claude", item["id"], "landed", commit="b4b44f0")
self.assertEqual((done["state"], done["commit_hash"]), ("landed", "def5678"))
with self.assertRaises(HarborError):
self.h.transition("Claude", item["id"], "held")
self.assertEqual([e["to_state"] for e in self.h.item(item["id"])[1]],
["submitted", "in_review", "changes_requested", "submitted", "approved", "landed"])
class Control(HarborCase):
def test_stop_and_resume_rules(self):
with self.assertRaises(HarborError):
self.h.set_flag("stop:all", "on", "User", "cli")
self.h.set_flag("stop:all", "on", "User", "web", reason="lets stop for now")
self.assertTrue(self.h.stopped("DeepSeek"))
self.assertIn("STOP", self.h.brief("DeepSeek"))
with self.assertRaises(HarborError):
self.h.set_flag("stop:all", "off", "Codex", "cli")
agent_msg = self.h.send("Kimi", "All", "resume please")
with self.assertRaises(HarborError):
self.h.set_flag("stop:all", "off", "Codex", "cli", relay_of=agent_msg["id"])
with self.assertRaises(HarborError):
self.h.set_flag("stop:all", "off", "Claude", "cli") # the user set it, not Claude
user_msg = self.h.send("User", "All", "okay resume everyone", origin="web")
with self.assertRaises(HarborError):
self.h.set_flag("stop:all", "off", "Codex", "cli", relay_of=user_msg["id"]) # only the human resumes
self.h.set_flag("stop:all", "off", "User", "web")
self.assertFalse(self.h.stopped("DeepSeek"))
self.h.set_flag("stop:Kimi", "on", "Kimi", "cli", reason="out of allowance")
with self.assertRaises(HarborError):
self.h.set_flag("stop:Kimi", "off", "Kimi", "cli")
with self.assertRaises(HarborError):
self.h.set_flag("stop:DeepSeek", "on", "Kimi", "cli")
self.h.set_flag("stop:Codex", "on", "Claude", "cli", reason="mirror the user's stop")
self.h.set_flag("stop:Codex", "off", "Claude", "cli", reason="set by mistake") # own stop, untouched
self.h.set_flag("stop:Codex", "on", "Claude", "cli")
self.h.set_flag("stop:Codex", "on", "User", "web")
with self.assertRaises(HarborError):
self.h.set_flag("stop:Codex", "off", "Claude", "cli") # the user has confirmed it
def test_brief_lists_what_needs_attention(self):
self.h.send("Claude", ["DeepSeek"], "please look", ack_required=True, importance="urgent")
self.h.claim("DeepSeek", "fn:0x445310", kind="draft", note="writing")
self.h.submit("DeepSeek", "thing", scope="fn:0x445310", commit="abc")
text = self.h.brief("DeepSeek")
for part in ("Unread: 1 (1 urgent)", "Waiting for your acknowledgement", "Your claims", "Your review items"):
self.assertIn(part, text)
self.assertIn("Review queue for you: 1 open", self.h.brief("Claude"))
self.assertNotIn("Review queue for you", self.h.brief("Codex"))
def test_legacy_import_is_idempotent_and_read(self):
legacy = self.dir / "old-chat.md"
legacy.write_text("# chat\n\n---\n\n[2026-10-01 13:32Z] User → All: rundown please\n\n"
"[2026-10-01 13:40Z] Codex → User: here it is\nsecond line\n\n"
"[2026-10-01 13:40Z] Codex → User: here it is\nsecond line\n\n"
"[2026-10-02 08:00Z] Kimi → Codex: next day\n", encoding="utf-8")
self.assertEqual(self.h.import_legacy(legacy), {"parsed": 4, "added": 4})
self.assertEqual(self.h.import_legacy(legacy)["added"], 0)
self.assertEqual(self.h.inbox("Codex"), [])
self.assertEqual(sorted(t["topic"] for t in self.h.threads(include_legacy=True)),
["legacy:2026-10-01", "legacy:2026-10-02"])
def test_backup_keeps_newest(self):
self.h.send("Claude", ["Kimi"], "keep me")
for i in range(3):
self.clock.t += 60
self.h.backup(keep=2)
copies = sorted(self.project.backups.glob("harbor-*.db"))
self.assertEqual(len(copies), 2)
import sqlite3
with sqlite3.connect(str(copies[-1])) as c:
self.assertEqual(c.execute("SELECT body FROM messages").fetchone()[0], "keep me")
class Projects(unittest.TestCase):
def setUp(self):
self.dir = temp_dir()
def tearDown(self):
shutil.rmtree(self.dir, ignore_errors=True)
def test_roster_comes_from_the_project(self):
p = projects.project_from_dict({"name": "garden", "human": "Kai", "coordinators": ["Lead"],
"database": str(self.dir / "g.db"),
"agents": [{"name": "Lead"}, {"name": "Helper"}]})
h = Harbor(p)
self.assertEqual(h.agent_names(), ["Lead", "Helper", "Kai"])
self.assertEqual(sorted(h.send("Lead", "All", "hi")["targets"]), ["Helper", "Kai"])
with self.assertRaises(HarborError):
h.send("Kai", "All", "pretend", origin="cli")
with self.assertRaises(HarborError):
h.send("Lead", ["Codex"], "not on this team")
item = h.submit("Helper", "first task") # reviewers default to Lead
self.assertEqual(h.inbox("Lead")[-1]["refs"]["item"], item["id"])
h.close()
smaller = projects.project_from_dict({"name": "garden", "human": "Kai", "database": str(self.dir / "g.db"),
"agents": [{"name": "Lead"}]})
h = Harbor(smaller)
self.assertEqual([a["name"] for a in h.agents()], ["Lead", "Kai"]) # Helper keeps history, inactive
h.close()
other = projects.project_from_dict({"name": "other", "database": str(self.dir / "g.db"),
"agents": [{"name": "Lead"}]})
with self.assertRaises(HarborError):
Harbor(other) # the database belongs to garden
def test_validation(self):
for bad in ({"name": "Bad Name"}, {"name": "x", "agents": [{"name": "All"}]},
{"name": "x", "agents": [{"name": "A"}, {"name": "A"}]},
{"name": "x", "coordinators": ["Ghost"], "agents": [{"name": "A"}]}):
with self.assertRaises(projects.ProjectError):
projects.project_from_dict(bad, home=self.dir)
def test_resolution_order_and_identities(self):
made = make_home(self.dir, names=("first", "second"))
self.assertEqual(projects.resolve(home=self.dir, cwd=self.dir).name, "first") # home default
self.assertEqual(projects.resolve("second", home=self.dir).name, "second") # explicit
tree = self.dir / "repo" / "worktree-deepseek" / "src"
tree.mkdir(parents=True)
(self.dir / "repo" / ".harbor.toml").write_text('project = "second"\n', encoding="utf-8")
found = projects.resolve(home=self.dir, cwd=tree)
self.assertEqual(found.name, "second") # nearest marker
self.assertEqual(found.identity_for(tree), "DeepSeek")
self.assertIsNone(found.identity_for(self.dir))
with self.assertRaises(projects.ProjectError):
projects.resolve("missing", home=self.dir)
self.assertEqual(made["first"].ai_names, ["Claude", "Codex", "DeepSeek", "OpenCode", "Kimi"])
class Concurrency(unittest.TestCase):
def test_many_processes_write_without_loss(self):
d = temp_dir()
project = make_home(d)["test"]
Harbor(project).close()
root = Path(__file__).resolve().parents[1]
script = ("import sys; sys.path.insert(0, r'%s'); from harbor import projects; from harbor.core import Harbor; "
"h = Harbor(projects.load_named('test', home=r'%s')); "
"[h.send(sys.argv[1], ['Claude'], f'{sys.argv[1]} message {i}') for i in range(40)]" % (root, d))
senders = ["Codex", "DeepSeek", "OpenCode", "Kimi", "Codex", "DeepSeek"]
procs = [subprocess.Popen([sys.executable, "-B", "-c", script, s]) for s in senders]
self.assertEqual([p.wait(timeout=180) for p in procs], [0] * len(senders))
h = Harbor(project)
rows = h.conn.execute("SELECT COUNT(*) n, COUNT(DISTINCT id) u FROM messages").fetchone()
self.assertEqual((rows["n"], rows["u"]), (240, 240))
headers = [l for l in project.mirror.read_text(encoding="utf-8").split("\n") if LEGACY_LINE.match(l.strip())]
self.assertEqual(len(headers), 240)
self.assertEqual(h.unread_count("Claude")[0], 240)
h.close()
shutil.rmtree(d, ignore_errors=True)
if __name__ == "__main__":
unittest.main()
+205
View File
@@ -0,0 +1,205 @@
"""Questions with answer buttons, team memory, collision radar, pulse, metrics, wins, export, handoff reminders."""
import json
import re
import shutil
import subprocess
import sys
import unittest
from helpers import ROOT, HarborCase, env_for, http, make_home, run_module, temp_dir, wait_for_port
from harbor import hooks, projects
from harbor.core import Harbor, HarborError
class Questions(HarborCase):
def test_ask_answer_and_fallback(self):
q = self.h.ask("Codex", "Rename 0x445310 to RouteCellCost?", ["Yes", "No", "Later"], due="2h", fallback="Claude")
self.assertEqual((q["to_name"], q["options"]), ("User", ["Yes", "No", "Later"]))
self.assertEqual(self.h.questions_by_message([q["message_id"]])[q["message_id"]]["id"], q["id"])
self.assertTrue(any(a["kind"] == "questions" for a in self.h.attention()))
with self.assertRaises(HarborError):
self.h.answer("Kimi", q["id"], "Yes") # not for Kimi
with self.assertRaises(HarborError):
self.h.answer("Claude", q["id"], "Yes") # the human is here
with self.assertRaises(HarborError):
self.h.answer("User", q["id"], "Yes", origin="cli") # the human answers on the page
with self.assertRaises(HarborError):
self.h.answer("User", q["id"], "Maybe", origin="web") # not an option
self.h.set_presence("User", "web", "away", until="8h")
done = self.h.answer("Claude", q["id"], "Yes") # the fallback while away
self.assertEqual((done["answer"], done["answered_by"]), ("Yes", "Claude"))
reply = self.h.inbox("Codex")[-1]
self.assertEqual((reply["kind"], reply["reply_to"]), ("answer", q["message_id"]))
self.assertIn("Yes", reply["body"])
with self.assertRaises(HarborError):
self.h.answer("User", q["id"], "No", origin="web") # answered already
def test_agent_to_agent_and_free_text(self):
q = self.h.ask("Codex", "Which header owns g_audio_manager?", to="Kimi")
self.assertIn("Questions for you", self.h.brief("Kimi"))
self.assertIn("Your open questions", self.h.brief("Codex"))
self.h.answer("Kimi", q["id"], "audio_manager.h, declared extern")
self.assertEqual(self.h.questions(open_only=True), [])
with self.assertRaises(HarborError):
self.h.ask("Codex", "x", fallback="Kimi") # fallback must coordinate
class Memory(HarborCase):
def test_notes_and_context(self):
self.h.note("Kimi", "fn:0x52BD10", "ABI: stdcall, ret 4; the third push is its argument")
old = self.h.note("Codex", "0x445310", "uses a signed read")
self.h.note("Codex", "fn:0x445310", "uses an unsigned read (movzx)", replaces=old["id"])
self.h.send("DeepSeek", "All", "0x445310 RouteCellCost draft is ready")
self.h.add_task("Claude", "make 0x445310 readable", scope="fn:0x445310")
self.h.submit("DeepSeek", "RouteCellCost readable", scope="fn:0x445310", commit="abc")
text = self.h.context_text("fn:0x445310")
for part in ("unsigned read", "review item", "task #", "RouteCellCost draft"):
self.assertIn(part, text)
self.assertNotIn("uses a signed read", text) # replaced notes drop out
self.assertEqual(len(self.h.notes("range:0x440000-0x47FFFF")), 1)
self.assertIn("nothing yet", self.h.context_text("fn:0x400000"))
task = self.h.next_task("Codex")
self.assertEqual(task["scope"], "fn:0x445310")
class Collisions(unittest.TestCase):
def setUp(self):
self.dir = temp_dir()
data = {"name": "radar", "coordinators": ["Lead"], "database": str(self.dir / "r.db"),
"agents": [{"name": "Lead"}, {"name": "Ann", "workdir": str(self.dir / "wt-ann")},
{"name": "Bob", "workdir": str(self.dir / "wt-bob")}]}
self.h = Harbor(projects.project_from_dict(data))
def tearDown(self):
self.h.close()
shutil.rmtree(self.dir, ignore_errors=True)
def test_same_file_in_two_worktrees(self):
paths = self.h.edited_paths("Edit", {"file_path": str(self.dir / "wt-ann" / "annotations" / "names.csv")})
self.assertEqual(self.h.record_touches("Ann", paths, "Edit"), [])
patch = "*** Begin Patch\n*** Update File: annotations/names.csv\n@@\n-a\n+b\n*** End Patch"
paths = self.h.edited_paths("apply_patch", {"input": patch})
self.assertEqual(paths, ["annotations/names.csv"])
hits = self.h.record_touches("Bob", paths, "apply_patch", cwd=str(self.dir / "wt-bob"))
self.assertEqual([(c["path"], c["other"]) for c in hits], [("annotations/names.csv", "Ann")])
alert = self.h.inbox("Ann")[-1]
self.assertEqual((alert["sender"], alert["kind"], alert["topic"]), ("Bob", "finding", "file:annotations/names.csv"))
self.assertEqual(self.h.inbox("Lead")[-1]["id"], alert["id"])
self.h.record_touches("Bob", paths, "apply_patch")
self.assertEqual(len([m for m in self.h.inbox("Ann", unread_only=False) if m["kind"] == "finding"]), 1)
self.assertIn("Files you share", self.h.brief("Ann"))
self.assertTrue(any(a["kind"] == "collision" for a in self.h.attention()))
self.assertEqual(self.h.edited_paths("Read", {"file_path": "x.c"}), [])
def test_hook_records_edits(self):
(self.dir / ".harbor.toml").write_text(f"project = 'radar'\n", encoding="utf-8")
home = self.dir / "home"
(home / "projects").mkdir(parents=True)
data = (f"name = 'radar'\ncoordinators = ['Lead']\ndatabase = '{self.dir / 'r.db'}'\n"
f"[[agents]]\nname = 'Lead'\n[[agents]]\nname = 'Ann'\nworkdir = '{self.dir / 'wt-ann'}'\n"
f"[[agents]]\nname = 'Bob'\nworkdir = '{self.dir / 'wt-bob'}'\n")
(home / "projects" / "radar.toml").write_text(data, encoding="utf-8")
for agent, wt in (("Ann", "wt-ann"), ("Bob", "wt-bob")):
payload = {"cwd": str(self.dir), "tool_name": "Write",
"tool_input": {"file_path": str(self.dir / wt / "src" / "x.c")}}
r = run_module(home, "--as", agent, "--project", "radar", "hook", "pre-tool", "--host", "claude",
stdin=json.dumps(payload))
self.assertEqual((r.returncode, r.stdout), (0, ""), r.stderr)
self.assertTrue(any(m["kind"] == "finding" for m in self.h.inbox("Ann")))
class PulseScore(HarborCase):
def test_pulse_metrics_wins_export(self):
for _ in range(3):
self.h.activity("Codex", "shell", "codex")
self.h.send("Codex", ["Claude"], "hi")
rows = self.h.pulse(hours=1)["agents"]["Codex"]
self.assertEqual((sum(r["tools"] for r in rows), sum(r["messages"] for r in rows)), (3, 1))
self.h.metric("Claude", "Readable", 778)
self.h.metric("Claude", "readable", 779, note="b4b44f0")
m = self.h.metrics()["readable"]
self.assertEqual(([p["value"] for p in m["series"]], m["latest"]["note"]), ([778, 779], "b4b44f0"))
with self.assertRaises(HarborError):
self.h.metric("Claude", "readable", "lots")
item = self.h.submit("DeepSeek", "RouteCellCost readable", scope="fn:0x445310", commit="abc")
self.h.transition("Claude", item["id"], "approved")
self.h.transition("Claude", item["id"], "landed", commit="b4b44f0")
self.assertEqual([w["title"] for w in self.h.wins()], ["RouteCellCost readable"])
win = self.h.inbox("User")[-1]
self.assertEqual(win["topic"], "wins")
md = self.h.export_thread(f"item:{item['id']}")
self.assertTrue(md.startswith("# Review: RouteCellCost readable"))
self.assertIn("approved -> landed", md)
def test_handoff_reminder_once(self):
for _ in range(31):
self.h.activity("Kimi", "shell")
text = hooks.handoff_reminder(self.h, "Kimi")
self.assertIn("handoff is missing", text)
self.assertIsNone(hooks.handoff_reminder(self.h, "Kimi"))
self.h.handoff("Codex", "x")
self.assertIsNone(hooks.handoff_reminder(self.h, "Codex")) # not active enough anyway
def test_stop_message_asks_for_a_handoff(self):
self.h.set_flag("stop:Kimi", "on", "Claude", "cli")
self.assertIn("write a handoff", self.h.inbox("Kimi")[-1]["body"])
class ServerExtras(unittest.TestCase):
PORT = 8794
@classmethod
def setUpClass(cls):
cls.dir = temp_dir()
cls.project = make_home(cls.dir)["test"]
cls.proc = subprocess.Popen([sys.executable, "-B", "-m", "harbor", "serve", "--port", str(cls.PORT)],
cwd=cls.dir, env=env_for(cls.dir, PYTHONPATH=str(ROOT)),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
assert wait_for_port(cls.PORT)
cls.token = re.search(r'name="harbor-token" content="([^"]+)"', http(cls.PORT, "/")[1]).group(1)
@classmethod
def tearDownClass(cls):
cls.proc.terminate()
cls.proc.wait(timeout=10)
cls.proc.stdout.close()
shutil.rmtree(cls.dir, ignore_errors=True)
def test_answer_from_the_page_and_new_endpoints(self):
h = Harbor(self.project)
q = h.ask("Codex", "Ship it?", ["Yes", "No"])
h.note("Kimi", "fn:0x445310", "unsigned read")
h.activity("Codex", "shell")
h.close()
shown = {m["id"]: m for m in http(self.PORT, "/messages")[1]}[q["message_id"]]
self.assertEqual((shown["question"]["options"], shown["question"]["answer"]), (["Yes", "No"], None))
self.assertEqual(len(http(self.PORT, "/api/overview")[1]["questions"]), 1)
status, r = http(self.PORT, "/api/answer", {"question": q["id"], "answer": "Yes"}, token=self.token)
self.assertEqual((status, r["question"]["answer"]), (200, "Yes"))
shown = {m["id"]: m for m in http(self.PORT, "/messages")[1]}[q["message_id"]]
self.assertEqual(shown["question"]["answered_by"], "User")
self.assertIn("Codex", http(self.PORT, "/api/pulse?hours=2")[1]["agents"])
self.assertIn("unsigned read", http(self.PORT, "/api/context?scope=fn:0x445310")[1]["text"])
status, md = http(self.PORT, "/api/export?thread=questions")
self.assertEqual(status, 200)
self.assertIn("Ship it?", md)
class CliExtras(unittest.TestCase):
def test_commands(self):
d = temp_dir()
make_home(d)
try:
r = run_module(d, "--as", "Codex", "ask", "Merge", "now?", "--options", "Yes|No")
self.assertIn("question #1", r.stdout, r.stderr)
self.assertIn("note #1", run_module(d, "--as", "Kimi", "note", "fn:0x445310", "unsigned", "read").stdout)
self.assertIn("unsigned read", run_module(d, "--as", "Kimi", "context", "0x445310").stdout)
self.assertIn("readable = 779", run_module(d, "--as", "Claude", "metric", "readable", "779").stdout)
self.assertIn("Activity per hour", run_module(d, "--as", "Claude", "pulse").stdout)
self.assertIn("# Questions", run_module(d, "export", "questions").stdout)
finally:
shutil.rmtree(d, ignore_errors=True)
if __name__ == "__main__":
unittest.main()
+166
View File
@@ -0,0 +1,166 @@
"""Git radar, bulk tasks and templates, the watchdog, policies and Windows notifications."""
import os
import re
import shutil
import subprocess
import sys
import unittest
os.environ["HARBOR_NO_TOASTS"] = "1"
from helpers import ROOT, env_for, http, make_home, temp_dir, wait_for_port
from harbor import notify, projects
from harbor.core import Harbor, HarborError
def git(cwd, *args):
subprocess.run(["git", "-C", str(cwd), *args], check=True, capture_output=True,
env={**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@x", "GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@x"})
class Clock:
def __init__(self, t=1_790_000_000.0):
self.t = t
def __call__(self):
return self.t
class Flow(unittest.TestCase):
def setUp(self):
self.dir = temp_dir()
repo = self.dir / "repo"
repo.mkdir()
git(repo, "init", "-q", "-b", "main")
(repo / "a.c").write_text("int a;\n", encoding="utf-8")
git(repo, "add", "a.c")
git(repo, "commit", "-q", "-m", "base")
git(repo, "worktree", "add", "-q", "-b", "ann/work", str(self.dir / "wt-ann"))
for i in range(3):
(self.dir / "wt-ann" / f"f{i}.c").write_text(f"int f{i};\n", encoding="utf-8")
git(self.dir / "wt-ann", "add", f"f{i}.c")
git(self.dir / "wt-ann", "commit", "-q", "-m", f"draft {i}")
(self.dir / "wt-ann" / "scratch.txt").write_text("x", encoding="utf-8")
data = {"name": "flow", "coordinators": ["Lead"], "reviewers": ["Lead"], "database": str(self.dir / "f.db"),
"policy": {"land_requires": ["Rev"], "all_per_hour": 2},
"templates": {"decomp": {"steps": [
{"name": "draft", "title": "Make {scope} readable", "assignee": "{owner}"},
{"name": "names", "title": "Name what {scope} calls", "assignee": "{owner}", "after": ["draft"]}]}},
"agents": [{"name": "Lead", "workdir": str(repo)}, {"name": "Ann", "workdir": str(self.dir / "wt-ann")},
{"name": "Rev"}]}
self.clock = Clock()
self.h = Harbor(projects.project_from_dict(data), clock=self.clock)
def tearDown(self):
self.h.close()
shutil.rmtree(self.dir, ignore_errors=True)
def test_git_radar(self):
g = self.h.git_status("Ann", max_age=0)
self.assertEqual((g["branch"], g["dirty"], g["behind_main"], g["ahead_of_main"]), ("ann/work", 1, 0, 3))
self.assertEqual(len(g["unsubmitted"]), 3)
sha = g["last_commit"]["sha"]
self.h.submit("Ann", "drafts", commit=sha)
self.assertEqual([c["sha"] for c in self.h.git_status("Ann", max_age=0)["unsubmitted"]][:1] != [sha], True)
self.assertEqual(self.h.git_status("Lead", max_age=0)["unsubmitted"], []) # main is the landing branch
self.assertIsNone(self.h.git_status("Rev"))
def test_templates_bulk_and_band_routing(self):
self.h.claim("Lead", "range:0x440000-0x47FFFF", kind="band", on_behalf_of="Ann")
steps = self.h.run_template("Lead", "decomp", scope="fn:0x445310")
self.assertEqual([(t["title"], t["assignee"]) for t in steps],
[("Make fn:0x445310 readable", "Ann"), ("Name what fn:0x445310 calls", "Ann")])
self.assertEqual(steps[1]["waiting_on"], [steps[0]["id"]])
r = self.h.bulk_tasks("Lead", "# held functions\n0x445310 again\nfn:0x450000 | RouteCellCost\n0x500000\n"
"Write the README\n")
self.assertEqual([t["title"] for t in r["created"]],
["Make 0x450000 readable (RouteCellCost)", "Make 0x500000 readable", "Write the README"])
self.assertEqual([t["assignee"] for t in r["created"]], ["Ann", None, None])
self.assertEqual(r["skipped"], ["fn:0x445310 already has an open task"])
with self.assertRaises(HarborError):
self.h.run_template("Lead", "nope")
def test_policies(self):
item = self.h.submit("Ann", "x", commit="abc")
self.h.transition("Lead", item["id"], "approved")
with self.assertRaises(HarborError):
self.h.transition("Lead", item["id"], "landed", commit="b4") # Rev has not approved
self.h.transition("Rev", item["id"], "approved") if False else None
self.h.transition("Lead", item["id"], "changes_requested")
self.h.transition("Ann", item["id"], "submitted", commit="def")
self.h.transition("Rev", item["id"], "approved")
self.h.transition("Lead", item["id"], "landed", commit="b4")
self.h.send("Ann", "All", "one")
self.h.send("Ann", "All", "two")
with self.assertRaises(HarborError):
self.h.send("Ann", "All", "three") # capped at 2 per hour
self.h.send("Ann", ["Lead"], "direct is fine")
self.h.send("Lead", "All", "coordinators are not capped")
def test_watchdog(self):
for _ in range(160):
self.h.activity("Ann", "shell")
self.assertEqual([b["agent"] for b in self.h.busy_without_progress()], ["Ann"])
self.h.doing("Ann", "still on the proof")
self.assertEqual(self.h.busy_without_progress(), [])
def test_project_validation(self):
bad = {"name": "x", "agents": [{"name": "A"}], "templates": {"t": {"steps": [{"title": "a", "after": ["zz"]}]}}}
with self.assertRaises(projects.ProjectError):
projects.project_from_dict(bad, home=self.dir)
class Notifications(unittest.TestCase):
def test_script_and_selection(self):
script = notify.toast_script("Codex asks you", "Rename <x> & 'y'?")
self.assertIn("&lt;x&gt; &amp; ''y''", script)
d = temp_dir()
h = Harbor(make_home(d)["test"])
try:
h.send("Codex", "All", "before")
notify.set_enabled(h, True)
self.assertTrue(notify.enabled(h))
h.send("Codex", "All", "a broadcast")
h.send("Codex", ["User"], "a direct message")
h.ask("Kimi", "Ship?", ["Yes", "No"])
pending, top = notify.pending_for_human(h)
self.assertEqual([m["body"].split("\n")[0] for m in pending], ["a direct message", "Ship?"])
notify.mark(h, top)
self.assertEqual(notify.pending_for_human(h)[0], [])
self.assertFalse(notify.toast("t", "b")) # disabled in tests
finally:
h.close()
shutil.rmtree(d, ignore_errors=True)
class ServerFlow(unittest.TestCase):
PORT = 8793
@classmethod
def setUpClass(cls):
cls.dir = temp_dir()
cls.project = make_home(cls.dir)["test"]
cls.proc = subprocess.Popen([sys.executable, "-B", "-m", "harbor", "serve", "--port", str(cls.PORT)],
cwd=cls.dir, env=env_for(cls.dir, PYTHONPATH=str(ROOT)),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
assert wait_for_port(cls.PORT)
cls.token = re.search(r'name="harbor-token" content="([^"]+)"', http(cls.PORT, "/")[1]).group(1)
@classmethod
def tearDownClass(cls):
cls.proc.terminate()
cls.proc.wait(timeout=10)
cls.proc.stdout.close()
shutil.rmtree(cls.dir, ignore_errors=True)
def test_bulk_and_toasts(self):
status, r = http(self.PORT, "/api/bulk", {"text": "0x445310 One\n0x445320 Two"}, token=self.token)
self.assertEqual((status, r["created"]), (200, 2))
self.assertEqual(len(http(self.PORT, "/api/overview")[1]["tasks"]), 2)
self.assertEqual(http(self.PORT, "/api/toasts", {"on": True}, token=self.token)[1]["on"], True)
self.assertTrue(http(self.PORT, "/api/overview")[1]["toasts"])
self.assertEqual(http(self.PORT, "/api/toasts", {"on": False}, token=self.token)[1]["on"], False)
if __name__ == "__main__":
unittest.main()
+155
View File
@@ -0,0 +1,155 @@
"""Installer tests against a fake user home (HARBOR_INSTALL_HOME) - real host configs are never touched."""
import json
import os
import shutil
import tomllib
import unittest
import yaml
from helpers import make_home, temp_dir
from harbor import install
CODEX_CONFIG = '''model = "gpt"
notify = ["x.exe", "turn-ended"]
[features]
[mcp_servers.node_repl]
command = "node"
[tui.model_availability_nux]
seen = true
'''
DSH_PATCH = '''# Your patch layer
- id: ui-chat
name: "@deepseek-ai/dsh-client-ui-chat"
config:
transcriptView: standard
- id: tools
config:
mode: !!js process.env.DSH_TOOLS_MODE
'''
class Installer(unittest.TestCase):
def setUp(self):
self.dir = temp_dir()
self.home = self.dir / "user"
(self.home / ".codex").mkdir(parents=True)
(self.home / ".codex" / "config.toml").write_text(CODEX_CONFIG, encoding="utf-8")
(self.home / ".codex" / "AGENTS.md").write_text("# My rules\n\nBe brief.\n", encoding="utf-8")
(self.home / ".config" / "opencode").mkdir(parents=True)
(self.home / ".config" / "opencode" / "opencode.jsonc").write_text('{\n "$schema": "https://opencode.ai/config.json"\n}', encoding="utf-8")
(self.home / ".dsh" / "profiles" / "desktop").mkdir(parents=True)
(self.home / ".dsh" / "profiles" / "desktop" / "cordis.patch.yml").write_text(DSH_PATCH, encoding="utf-8")
self.harbor_home = self.dir / "harbor-home"
make_home(self.harbor_home)
self.saved = {k: os.environ.get(k) for k in ("HARBOR_INSTALL_HOME", "HARBOR_HOME", "HARBOR_PROJECT", "APPDATA")}
os.environ.update(HARBOR_INSTALL_HOME=str(self.home), HARBOR_HOME=str(self.harbor_home),
APPDATA=str(self.dir / "appdata"))
os.environ.pop("HARBOR_PROJECT", None)
self.cwd = os.getcwd()
os.chdir(self.dir) # resolve projects from the test folder, not from wherever the suite runs
def tearDown(self):
os.chdir(self.cwd)
for k, v in self.saved.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
shutil.rmtree(self.dir, ignore_errors=True)
def read(self, *parts):
return (self.home.joinpath(*parts)).read_text(encoding="utf-8")
def test_codex_install_is_idempotent_and_reversible(self):
install.install("codex", python="py -3.12")
config = tomllib.loads(self.read(".codex", "config.toml"))
server = config["mcp_servers"]["harbor"]
self.assertEqual(server["env"]["HARBOR_AGENT"], "Codex")
self.assertEqual(server["args"][-3:], ["--as", "Codex", "mcp"])
self.assertEqual(config["mcp_servers"]["node_repl"]["command"], "node") # untouched
self.assertTrue(config["tui"]["model_availability_nux"]["seen"])
hooks = json.loads(self.read(".codex", "hooks.json"))["hooks"]
self.assertEqual(sorted(hooks), ["PreToolUse", "SessionStart", "Stop", "UserPromptSubmit"])
self.assertIn("hook pre-tool --host codex", hooks["PreToolUse"][0]["hooks"][0]["command"])
self.assertIn("Be brief.", self.read(".codex", "AGENTS.md"))
self.assertIn("you are **Codex**", self.read(".codex", "AGENTS.md"))
once = self.read(".codex", "config.toml")
report = install.install("codex", python="py -3.12")
self.assertEqual(self.read(".codex", "config.toml"), once)
self.assertTrue(all(line.startswith("unchanged") for line in report if line.startswith(("unchanged", "update", "create"))))
install.install("codex", remove=True)
self.assertEqual(self.read(".codex", "config.toml"), CODEX_CONFIG)
self.assertEqual(self.read(".codex", "AGENTS.md"), "# My rules\n\nBe brief.\n")
self.assertFalse((self.home / ".codex" / "hooks.json").exists())
backups = list((self.harbor_home / "backups" / "integrations").rglob("config.toml"))
self.assertTrue(backups)
def test_codex_keeps_other_hooks(self):
(self.home / ".codex" / "hooks.json").write_text(json.dumps(
{"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "mine.exe"}]}]}}), encoding="utf-8")
install.install("codex", python="py -3.12")
stop = json.loads(self.read(".codex", "hooks.json"))["hooks"]["Stop"]
self.assertEqual(len(stop), 2)
install.install("codex", remove=True)
self.assertEqual(json.loads(self.read(".codex", "hooks.json")),
{"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "mine.exe"}]}]}})
def test_opencode(self):
install.install("opencode", python="py -3.12")
config = json.loads(self.read(".config", "opencode", "opencode.jsonc"))
self.assertEqual(config["$schema"], "https://opencode.ai/config.json")
self.assertEqual(config["mcp"]["harbor"]["environment"], {"HARBOR_AGENT": "OpenCode", "HARBOR_HOST": "opencode"})
plugin = self.read(".config", "opencode", "plugin", "harbor.js")
self.assertIn('const AGENT = "OpenCode";', plugin)
self.assertIn('const COMMAND = ["py", "-3.12", "-B",', plugin)
self.assertIn("export const HarborPlugin", plugin)
install.install("opencode", remove=True)
self.assertEqual(json.loads(self.read(".config", "opencode", "opencode.jsonc")),
{"$schema": "https://opencode.ai/config.json"})
self.assertFalse((self.home / ".config" / "opencode" / "plugin" / "harbor.js").exists())
def test_deepseek(self):
install.install("deepseek", python="py -3.12")
patch = self.read(".dsh", "profiles", "desktop", "cordis.patch.yml")
self.assertTrue(patch.startswith(DSH_PATCH.rstrip("\n")))
rows = install._TolerantLoader.load(patch)
inserted = rows[-1]["insert"]
self.assertEqual([r["id"] for r in inserted], ["mcp-harbor", "hooks-harbor"])
self.assertEqual(inserted[0]["config"]["env"], {"HARBOR_AGENT": "DeepSeek", "HARBOR_HOST": "deepseek"})
hooks = json.loads((self.harbor_home / "integrations" / "deepseek" / "hooks.json").read_text(encoding="utf-8"))
self.assertIn("--host deepseek", hooks["hooks"]["Stop"][0]["hooks"][0]["command"])
install.install("deepseek", remove=True)
self.assertEqual(self.read(".dsh", "profiles", "desktop", "cordis.patch.yml"), DSH_PATCH)
def test_kimi_plugin_is_bound_to_a_project(self):
install.install("kimi", project="test", python="py -3.12", register=False)
folder = self.harbor_home / "integrations" / "kimi" / "harbor-test"
manifest = json.loads((folder / "kimi.plugin.json").read_text(encoding="utf-8"))
self.assertEqual(manifest["mcpServers"]["harbor"]["env"], {"HARBOR_AGENT": "Kimi", "HARBOR_PROJECT": "test", "HARBOR_HOST": "kimi"})
self.assertEqual([h["event"] for h in manifest["hooks"]], ["UserPromptSubmit", "PreToolUse", "Stop"])
self.assertTrue(all("--project test" in h["command"] for h in manifest["hooks"]))
self.assertTrue((folder / "skills" / "harbor-test" / "SKILL.md").exists())
def test_refusals(self):
with self.assertRaises(install.InstallError):
install.install("codex", agent="Nobody")
(self.home / ".config" / "opencode" / "opencode.jsonc").write_text('{ // mine\n}', encoding="utf-8")
with self.assertRaises(install.InstallError):
install.install("opencode")
(self.home / ".codex" / "config.toml").write_text(CODEX_CONFIG + "\n[mcp_servers.harbor]\ncommand='x'\n",
encoding="utf-8")
with self.assertRaises(install.InstallError):
install.install("codex")
def test_dry_run_writes_nothing(self):
report = install.install("deepseek", dry_run=True, python="py -3.12")
self.assertTrue(any(line.startswith("update") for line in report))
self.assertEqual(self.read(".dsh", "profiles", "desktop", "cordis.patch.yml"), DSH_PATCH)
if __name__ == "__main__":
unittest.main()
+347
View File
@@ -0,0 +1,347 @@
"""Command line, host hooks, web server and MCP tools, each against a temporary Harbor home."""
import asyncio
import json
import os
import re
import shutil
import subprocess
import sys
import unittest
import urllib.request
from helpers import ROOT, env_for, http, make_home, run_module, temp_dir, wait_for_port
from harbor.core import Harbor
class Cli(unittest.TestCase):
def setUp(self):
self.dir = temp_dir()
self.project = make_home(self.dir)["test"]
def tearDown(self):
shutil.rmtree(self.dir, ignore_errors=True)
def test_round_trip_and_identity_rules(self):
r = run_module(self.dir, "--as", "Codex", "send", "Kimi", "please", "review", "--thread", "fn:0x445310", "--ack")
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("thread", r.stdout)
r = run_module(self.dir, "--json", "inbox", HARBOR_AGENT="Kimi")
self.assertEqual(json.loads(r.stdout)[0]["body"], "please review")
tree = self.dir / "worktree-deepseek"
tree.mkdir()
(self.dir / ".harbor.toml").write_text('project = "test"\n', encoding="utf-8")
r = run_module(self.dir, "doing", "drafting", "0x445310", cwd=tree) # identity from the folder
self.assertIn("DeepSeek: drafting 0x445310", r.stdout)
self.assertIn("doing: drafting 0x445310", run_module(self.dir, "--as", "Claude", "status").stdout)
r = run_module(self.dir, "--as", "User", "send", "All", "pretend")
self.assertNotEqual(r.returncode, 0)
self.assertIn("chat page", r.stderr)
r = run_module(self.dir, "send", "All", "who am I")
self.assertIn("say who you are", r.stderr)
r = run_module(self.dir, "--as", "Codex", "wait", "--timeout", "0.2")
self.assertIn("No new messages", r.stdout)
self.assertIn("From", run_module(self.dir, "digest", "--since", "1h").stdout)
def test_init_creates_a_project_and_marker(self):
home = self.dir / "home2"
folder = self.dir / "garden"
home.mkdir()
folder.mkdir()
r = run_module(home, "init", "garden", "--title", "Garden app", "--agent", "Lead:Builds it",
"--agent", "Helper", "--coordinator", "Lead", "--marker", str(folder))
self.assertEqual(r.returncode, 0, r.stderr)
r = run_module(home, "--as", "Helper", "whoami", cwd=folder)
self.assertIn("project garden", r.stdout)
r = run_module(home, "--as", "Helper", "send", "Lead", "hello", cwd=folder)
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("garden", run_module(home, "projects").stdout)
class Hooks(unittest.TestCase):
def setUp(self):
self.dir = temp_dir()
self.project = make_home(self.dir)["test"]
(self.dir / ".harbor.toml").write_text('project = "test"\n', encoding="utf-8")
self.h = Harbor(self.project)
def tearDown(self):
self.h.close()
shutil.rmtree(self.dir, ignore_errors=True)
def hook(self, event, payload=None, host="claude", agent="DeepSeek"):
return run_module(self.dir, "--as", agent, "hook", event, "--host", host,
stdin=json.dumps(payload or {"cwd": str(self.dir)}))
def test_session_start_and_prompt_submit(self):
self.h.send("Claude", ["DeepSeek"], "please look at 0x445310", importance="urgent")
r = self.hook("session-start")
context = json.loads(r.stdout)["hookSpecificOutput"]["additionalContext"]
self.assertIn("please look at 0x445310", context)
r = self.hook("prompt-submit", host="generic")
self.assertIn("1 unread", r.stdout)
def denied(self, r):
return r.returncode == 0 and r.stdout.strip() and \
json.loads(r.stdout)["hookSpecificOutput"]["permissionDecision"] == "deny"
def test_pre_tool_blocks_only_while_stopped(self):
call = {"cwd": str(self.dir), "tool_name": "Bash", "tool_input": {"command": "git commit -am x"}}
self.assertEqual((self.hook("pre-tool", call).returncode, self.hook("pre-tool", call).stdout), (0, ""))
self.h.set_flag("stop:all", "on", "Claude", "cli", reason="user asked")
for host in ("claude", "codex", "deepseek", "kimi"):
r = self.hook("pre-tool", call, host=host)
self.assertTrue(self.denied(r), (host, r.stdout, r.stderr))
self.assertIn("STOP in force", json.loads(r.stdout)["hookSpecificOutput"]["permissionDecisionReason"])
r = self.hook("pre-tool", call, host="opencode")
self.assertEqual(r.returncode, 2)
self.assertIn("STOP in force", r.stderr)
harbor_call = {"cwd": str(self.dir), "tool_name": "Bash", "tool_input": {"command": "harbor inbox"}}
self.assertEqual(self.hook("pre-tool", harbor_call).stdout, "") # may still read messages
sneaky = {"cwd": str(self.dir), "tool_name": "Bash", "tool_input": {"command": "harbor inbox && git push"}}
self.assertTrue(self.denied(self.hook("pre-tool", sneaky)))
mcp_call = {"cwd": str(self.dir), "tool_name": "mcp__harbor__inbox", "tool_input": {}}
self.assertEqual(self.hook("pre-tool", mcp_call).stdout, "")
def test_stop_hook_keeps_agent_for_urgent_messages_once(self):
self.assertEqual(self.hook("stop").stdout, "")
self.h.send("Claude", ["DeepSeek"], "answer this first", ack_required=True)
r = self.hook("stop", host="deepseek")
decision = json.loads(r.stdout)
self.assertEqual((r.returncode, decision["decision"]), (0, "block"))
self.assertIn("#", decision["reason"])
self.assertEqual(self.hook("stop", host="deepseek").stdout, "") # no loop on the same messages
self.h.send("Claude", ["DeepSeek"], "and this", importance="urgent")
self.assertEqual(self.hook("stop", {"cwd": str(self.dir), "stop_hook_active": True}).stdout, "")
r = self.hook("stop", host="opencode")
self.assertEqual(r.returncode, 2) # a new urgent message nudges once
self.h.send("Claude", ["DeepSeek"], "plain note")
self.assertEqual(self.hook("stop", host="codex").stdout, "") # plain notes do not interrupt
def test_outside_a_project_hooks_are_silent(self):
elsewhere = self.dir / "elsewhere"
elsewhere.mkdir()
(self.dir / ".harbor.toml").unlink()
self.h.set_flag("stop:all", "on", "Claude", "cli")
r = self.hook("pre-tool", {"cwd": str(elsewhere), "tool_name": "Bash"})
self.assertEqual((r.returncode, r.stdout, r.stderr), (0, "", ""))
r = run_module(self.dir, "--as", "DeepSeek", "--project", "test", "hook", "pre-tool", "--host", "kimi",
stdin=json.dumps({"cwd": str(elsewhere), "tool_name": "Shell"}))
self.assertTrue(self.denied(r)) # a bound plugin still gates
def test_kimi_gets_the_brief_on_prompts(self):
self.assertEqual(self.hook("prompt-submit", host="kimi").stdout, "")
self.h.send("Claude", ["DeepSeek"], "please check 0x445310")
self.assertIn("Harbor brief for DeepSeek", self.hook("prompt-submit", host="kimi").stdout)
def test_idle_and_unknown_agents(self):
self.h.send("Claude", ["DeepSeek"], "new work")
idle = json.loads(self.hook("idle", host="opencode").stdout)
self.assertEqual((idle["unread"], bool(idle["prompt"])), (1, True))
again = json.loads(self.hook("idle", host="opencode").stdout)
self.assertEqual((again["unread"], again["prompt"]), (1, None)) # one nudge per message
r = run_module(self.dir, "hook", "pre-tool", stdin=json.dumps({"cwd": str(self.dir)}))
self.assertEqual((r.returncode, r.stdout), (0, "")) # not an agent: stay silent
broken = {k: v for k, v in env_for(self.dir).items()}
r = subprocess.run([sys.executable, "-B", "-m", "harbor", "--as", "DeepSeek", "--project", "nope", "hook",
"pre-tool"], input="{}", capture_output=True, text=True, cwd=self.dir,
env={**broken, "PYTHONPATH": str(ROOT)})
self.assertEqual(r.returncode, 0) # fails open
class Server(unittest.TestCase):
PORT = 8798
@classmethod
def setUpClass(cls):
cls.dir = temp_dir()
cls.projects = make_home(cls.dir, names=("alpha", "beta"))
env = env_for(cls.dir, PYTHONPATH=str(ROOT))
cls.proc = subprocess.Popen([sys.executable, "-B", "-m", "harbor", "serve", "--port", str(cls.PORT)],
cwd=cls.dir, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
assert wait_for_port(cls.PORT), cls.proc.stdout.read() if cls.proc.poll() is not None else "no server"
page = http(cls.PORT, "/")[1]
cls.token = re.search(r'name="harbor-token" content="([^"]+)"', page).group(1)
@classmethod
def tearDownClass(cls):
cls.proc.terminate()
cls.proc.wait(timeout=10)
cls.proc.stdout.close()
shutil.rmtree(cls.dir, ignore_errors=True)
def post(self, path, body, **kw):
return http(self.PORT, path, body, token=self.token, **kw)
def test_human_messages_only_from_the_page(self):
self.assertEqual(http(self.PORT, "/send", {"to": "All", "text": "scripted"}, browser=False)[0], 403)
self.assertEqual(self.post("/send", {"to": "All", "text": "x"}, headers={"X-Harbor-Token": "wrong"})[0], 403)
self.assertEqual(self.post("/send", {"to": "All", "text": "x"}, headers={"Origin": "http://evil.example"})[0], 403)
self.assertEqual(self.post("/send", {"to": "All", "text": "x"}, headers={"Sec-Fetch-Site": "cross-site"})[0], 403)
self.assertEqual(http(self.PORT, "/messages", headers={"Host": "attacker.example:8798"})[0], 403)
status, reply = self.post("/send", {"to": "Codex, Kimi", "text": "Please both look"})
self.assertEqual(status, 200, reply)
last = http(self.PORT, "/messages")[1][-1]
self.assertEqual((last["from"], last["recipients"], last["origin"]), ("User", ["Codex", "Kimi"], "web"))
self.assertEqual(http(self.PORT, f"/messages?after={last['id']}")[1], [])
def test_mentions_receipts_and_projects(self):
status, reply = self.post("/p/beta/send", {"to": "All", "text": "@kimi @Codex: can you check this?",
"ack": True})
self.assertEqual((status, reply["recipients"]), (200, ["Kimi", "Codex"]))
beta = Harbor(self.projects["beta"])
beta.ack("Kimi", [reply["id"]])
beta.close()
receipts = http(self.PORT, f"/p/beta/api/receipts?ids={reply['id']}")[1][str(reply["id"])]
self.assertEqual((receipts["acked"], receipts["waiting"]), (["Kimi"], ["Codex"]))
self.assertEqual(http(self.PORT, "/messages")[1][-1]["text"] if http(self.PORT, "/messages")[1] else None,
http(self.PORT, "/p/alpha/messages")[1][-1]["text"] if http(self.PORT, "/p/alpha/messages")[1] else None)
self.assertNotIn("@kimi", json.dumps(http(self.PORT, "/p/alpha/messages")[1])) # projects stay apart
listing = http(self.PORT, "/api/projects")[1]
self.assertEqual([p["name"] for p in listing], ["alpha", "beta"])
self.assertEqual(http(self.PORT, "/p/nope/messages")[0], 404)
self.assertIn('<base href="/p/beta/">', http(self.PORT, "/p/beta/board")[1])
status, css = http(self.PORT, "/p/beta/static/harbor.css") # the shared look, per project too
self.assertEqual((status, ":root" in css), (200, True))
self.assertEqual(http(self.PORT, "/static/icons.svg")[0], 200)
for name in ("themes.css", "themes.js", "InterVariable.woff2"): # themes and the Inter font
with urllib.request.urlopen(f"http://127.0.0.1:{self.PORT}/static/{name}", timeout=10) as r:
self.assertEqual(r.status, 200, name)
self.assertGreater(len(r.read()), 1000, name)
self.assertEqual(http(self.PORT, "/static/server.py")[0], 404) # only the listed files
def test_board_flags_digest_and_files(self):
status, reply = self.post("/api/flag", {"target": "all", "value": "on", "reason": "going home"})
self.assertEqual(status, 200, reply)
overview = http(self.PORT, "/api/overview")[1]
self.assertEqual(next(a for a in overview["agents"] if a["name"] == "Codex")["stopped"], ["stop:all"])
self.assertEqual(self.post("/api/flag", {"target": "all", "value": "off"})[0], 200)
self.assertEqual(self.post("/api/flag", {"target": "Nobody", "value": "on"})[0], 400)
digest = http(self.PORT, "/api/digest?since=1h")[1]
self.assertEqual([f["value"] for f in digest["flags"]], ["on", "off"])
alpha = Harbor(self.projects["alpha"])
report = self.dir / "r.txt"
report.write_text("hello", encoding="utf-8")
sent = alpha.send("Kimi", ["User"], "report", attachments=[report])
alpha.close()
name = http(self.PORT, "/messages")[1][-1]["attachments"][0]
self.assertEqual(http(self.PORT, f"/files/{name}")[1], "hello")
self.assertEqual(http(self.PORT, "/files/..%2F..%2Fsecret")[0], 404)
self.assertEqual(self.post("/api/thread-state", {"thread": "control", "state": "closed"})[0], 200)
self.assertTrue(sent["id"])
class ServerWork(unittest.TestCase):
PORT = 8796
@classmethod
def setUpClass(cls):
cls.dir = temp_dir()
cls.project = make_home(cls.dir)["test"]
env = env_for(cls.dir, PYTHONPATH=str(ROOT))
cls.proc = subprocess.Popen([sys.executable, "-B", "-m", "harbor", "serve", "--port", str(cls.PORT)],
cwd=cls.dir, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
assert wait_for_port(cls.PORT)
cls.token = re.search(r'name="harbor-token" content="([^"]+)"', http(cls.PORT, "/")[1]).group(1)
@classmethod
def tearDownClass(cls):
cls.proc.terminate()
cls.proc.wait(timeout=10)
cls.proc.stdout.close()
shutil.rmtree(cls.dir, ignore_errors=True)
def post(self, path, body):
return http(self.PORT, path, body, token=self.token)
def test_tasks_presence_ping_prompt_doctor(self):
status, r = self.post("/api/task", {"action": "add", "title": "Name 0x445310", "scope": "fn:0x445310",
"priority": "high", "assignee": "Codex"})
self.assertEqual(status, 200, r)
task_id = r["task"]["id"]
overview = http(self.PORT, "/api/overview")[1]
self.assertEqual([t["id"] for t in overview["tasks"]], [task_id])
self.assertEqual(self.post("/api/task", {"action": "update", "id": task_id, "state": "cancelled"})[0], 200)
self.assertEqual(http(self.PORT, "/api/tasks")[1], [])
self.assertEqual(len(http(self.PORT, "/api/tasks?all=1")[1]), 1)
status, r = self.post("/api/presence", {"status": "away", "until": "2h", "note": "decide with Claude"})
self.assertEqual((status, r["presence"]["status"]), (200, "away"))
self.assertEqual(http(self.PORT, "/api/overview")[1]["presence"]["note"], "decide with Claude")
status, r = self.post("/api/ping", {"agent": "Kimi"})
self.assertEqual(status, 200, r)
h = Harbor(self.project)
ping = h.inbox("Kimi", mark_read=False)[-1]
self.assertTrue(ping["ack_required"] and ping["importance"] == "urgent" and ping["sender"] == "User")
self.assertIn("User is away", h.brief("Kimi"))
h.handoff("Kimi", "halfway through batch 49")
first = h.send("Codex", "All", "wrong claim")
h.send("Codex", None, "right claim", corrects=first["id"])
h.close()
self.assertIn("halfway through batch 49", http(self.PORT, "/api/prompt?agent=Kimi")[1]["prompt"])
marked = {m["id"]: m for m in http(self.PORT, "/messages")[1]}
self.assertEqual(marked[first["id"]]["corrected_by"], [first["id"] + 1])
self.assertEqual(self.post("/api/pin", {"id": first["id"]})[0], 200)
self.assertTrue({m["id"]: m for m in http(self.PORT, "/messages")[1]}[first["id"]]["pinned"])
self.assertEqual(len(http(self.PORT, f"/messages?before={first['id']}")[1]), first["id"] - 1)
checks = http(self.PORT, "/api/doctor")[1]
self.assertEqual(next(c for c in checks if c["area"] == "Server")["status"], "ok")
self.assertEqual(self.post("/api/presence", {"status": "gone"})[0], 400)
class Mcp(unittest.TestCase):
def test_tools_bind_identity_and_round_trip(self):
from fastmcp import Client
from fastmcp.exceptions import ToolError
from harbor import mcp_server
d = temp_dir()
project = make_home(d)["test"]
mcp_server._state.clear()
mcp_server._state["project"] = project
saved = os.environ.get("HARBOR_AGENT")
async def scenario():
async with Client(mcp_server.mcp) as client:
names = {t.name for t in await client.list_tools()}
self.assertTrue({"brief", "doing", "wait_for_messages", "send", "reply", "ack", "claim", "submit",
"review", "land", "digest", "close_thread"} <= names)
os.environ["HARBOR_AGENT"] = "DeepSeek"
sent = (await client.call_tool("send", {"to": "Kimi", "text": "Please review 0x50A720",
"thread": "fn:0x50A720", "ack": True})).data
await client.call_tool("doing", {"text": "waiting for Kimi"})
await client.call_tool("claim", {"scope": "fn:0x50A720"})
item = (await client.call_tool("submit", {"title": "LayoutTextMesh", "scope": "fn:0x50A720",
"commit": "abc1234"})).data
os.environ["HARBOR_AGENT"] = "Kimi"
self.assertIn("Please review 0x50A720", (await client.call_tool("brief", {})).data)
waited = (await client.call_tool("wait_for_messages", {"timeout_seconds": 2})).data
self.assertEqual([m["id"] for m in waited["messages"]], [sent["id"]])
await client.call_tool("ack", {"message_ids": [sent["id"]], "note": "on it"})
with self.assertRaises(ToolError):
await client.call_tool("claim", {"scope": "fn:0x50A720"})
with self.assertRaises(ToolError):
await client.call_tool("land", {"item_id": item["id"], "commit": "f00"})
status = (await client.call_tool("status", {})).data
self.assertEqual(next(a for a in status["agents"] if a["name"] == "DeepSeek")["doing"],
"waiting for Kimi")
os.environ["HARBOR_AGENT"] = "User"
with self.assertRaises(ToolError):
await client.call_tool("send", {"to": "All", "text": "pretending"})
return sent
try:
sent = asyncio.run(scenario())
finally:
if saved is None:
os.environ.pop("HARBOR_AGENT", None)
else:
os.environ["HARBOR_AGENT"] = saved
mcp_server._state.pop("h").close()
mcp_server._state.clear()
h = Harbor(project)
self.assertEqual(h.message(sent["id"])["origin"], "mcp")
self.assertEqual(h.pending_acks(sender="DeepSeek"), [])
h.close()
shutil.rmtree(d, ignore_errors=True)
if __name__ == "__main__":
unittest.main()
+168
View File
@@ -0,0 +1,168 @@
"""Trial runs, roll calls, launch status, the live activity feed, experts and thread summaries."""
import json
import os
import re
import shutil
import subprocess
import sys
import unittest
os.environ["HARBOR_NO_TOASTS"] = "1"
from helpers import ROOT, HarborCase, env_for, http, make_home, run_module, temp_dir, wait_for_port
from harbor.core import Harbor, HarborError
from harbor.launch import KEEP_ACTIVITY, describe_target
class TrialRuns(HarborCase):
def test_trial_run_ends_by_itself(self):
self.h.set_flag("stop:Codex", "on", "User", "web")
with self.assertRaises(HarborError):
self.h.set_flag("stop:Codex", "off", "Claude", "cli", until="30m") # only the human
self.h.set_flag("stop:Codex", "off", "User", "web", until="30m")
self.assertEqual(self.h.stopped("Codex"), [])
self.assertIsNotNone(self.h.trial("Codex"))
self.assertIn("Trial run: you may work until", self.h.brief("Codex"))
self.assertIn("trial run until", self.h.inbox("Codex")[-1]["body"])
self.clock.t += 31 * 60
stops = self.h.stopped("Codex")
self.assertEqual(len(stops), 1)
self.assertIn("trial run ended", stops[0]["reason"])
self.assertIsNone(self.h.trial("Codex"))
with self.assertRaises(HarborError):
self.h.claim("Codex", "fn:0x445310") # stopped again
self.h.set_flag("stop:Codex", "off", "User", "web") # a real resume
self.assertEqual(self.h.stopped("Codex"), [])
def test_trial_for_everyone_and_a_personal_stop(self):
self.h.set_flag("stop:all", "on", "User", "web")
self.h.set_flag("stop:all", "off", "User", "web", until="15m")
self.h.set_flag("stop:Kimi", "on", "User", "web")
self.assertEqual(self.h.stopped("Codex"), [])
self.assertTrue(self.h.stopped("Kimi")) # its own stop holds
self.clock.t += 16 * 60
self.assertTrue(self.h.stopped("Codex"))
class RollCall(HarborCase):
def test_rollcall_works_while_stopped(self):
self.h.set_flag("stop:all", "on", "User", "web")
with self.assertRaises(HarborError):
self.h.rollcall("Claude", "cli")
status = self.h.rollcall("User", "web", agents=["Codex", "Kimi"])
self.assertEqual([a["agent"] for a in status["agents"]], ["Codex", "Kimi"])
q = next(q for q in self.h.questions(to="Codex"))
self.h.inbox("Codex")
self.clock.t += 42
self.h.answer("Codex", q["id"], "Here") # stopped, still answers
rows = {a["agent"]: a for a in self.h.rollcall_status()["agents"]}
self.assertEqual((rows["Codex"]["answer"], rows["Codex"]["seconds"]), ("Here", 42))
self.assertIsNone(rows["Kimi"]["answer"])
self.assertTrue(any(m["kind"] == "answer" for m in self.h.inbox("User")))
launch = {a["agent"]: a for a in self.h.launch_status()}
self.assertEqual(launch["Codex"]["state"], "stopped")
self.assertEqual(launch["Codex"]["rollcall"]["answer"], "Here")
self.h.brief("Kimi")
self.assertIsNotNone({a["agent"]: a for a in self.h.launch_status()}["Kimi"]["brief_at"])
class Activity(HarborCase):
def test_feed_targets_and_pruning(self):
self.assertEqual(describe_target("Edit", {"file_path": "x.c"}, ["x.c"]), "x.c")
masked = describe_target("Bash", {"command": "curl -H 'Authorization: Bearer abc123' --token=s3cret x"}, [])
self.assertNotIn("abc123", masked)
self.assertNotIn("s3cret", masked)
self.assertEqual(describe_target("shell", {"command": ["bash", "-lc", "make all"]}, []), "make all")
for i in range(KEEP_ACTIVITY + 25):
self.h.activity("Codex", "shell", "codex", target=f"step {i}")
feed = self.h.activity_feed(limit=5, agent="Codex")
self.assertEqual(feed[0]["target"], f"step {KEEP_ACTIVITY + 24}")
self.assertEqual(self.h.conn.execute("SELECT COUNT(*) FROM activity_log WHERE agent='Codex'").fetchone()[0],
KEEP_ACTIVITY)
def test_hook_records_targets(self):
(self.dir / ".harbor.toml").write_text('project = "test"\n', encoding="utf-8")
payload = {"cwd": str(self.dir), "tool_name": "Edit", "tool_input": {"file_path": "annotations/names.csv"}}
run_module(self.dir, "--as", "DeepSeek", "hook", "pre-tool", "--host", "claude", stdin=json.dumps(payload))
self.assertEqual(self.h.activity_feed(limit=1)[0]["target"], "annotations/names.csv")
class Knowledge(HarborCase):
def test_experts_and_summaries(self):
self.h.note("Kimi", "fn:0x52BD10", "stdcall, ret 4")
self.h.claim("DeepSeek", "fn:0x52BD10")
item = self.h.submit("DeepSeek", "AudioManagerTick", scope="fn:0x52BD10", commit="abc")
self.h.transition("Kimi", item["id"], "approved")
self.h.send("Codex", "All", "0x52BD10 takes the manager in ECX")
ranked = [e["agent"] for e in self.h.experts("fn:0x52BD10")]
self.assertEqual(sorted(ranked[:2]), ["DeepSeek", "Kimi"])
self.assertIn("who knows this", self.h.context_text("fn:0x52BD10"))
for i in range(21):
self.h.send("Codex" if i % 2 else "Kimi", ["Claude"], f"point {i}", thread="fn:0x52BD10")
self.assertIn("Long threads without a recent summary", self.h.brief("Kimi"))
self.h.summarize("Kimi", "fn:0x52BD10", "Decided: stdcall ret 4; DeepSeek fixes the header.")
self.assertNotIn("Long threads without", self.h.brief("Kimi"))
self.assertIn("summary of fn:0x52BD10", self.h.context_text("fn:0x52BD10"))
with self.assertRaises(HarborError):
self.h.summarize("Kimi", "fn:0x52BD10", "x" * 4001)
class ServerLaunch(unittest.TestCase):
PORT = 8792
@classmethod
def setUpClass(cls):
cls.dir = temp_dir()
cls.project = make_home(cls.dir)["test"]
cls.proc = subprocess.Popen([sys.executable, "-B", "-m", "harbor", "serve", "--port", str(cls.PORT)],
cwd=cls.dir, env=env_for(cls.dir, PYTHONPATH=str(ROOT)),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
assert wait_for_port(cls.PORT)
cls.token = re.search(r'name="harbor-token" content="([^"]+)"', http(cls.PORT, "/")[1]).group(1)
@classmethod
def tearDownClass(cls):
cls.proc.terminate()
cls.proc.wait(timeout=10)
cls.proc.stdout.close()
shutil.rmtree(cls.dir, ignore_errors=True)
def post(self, path, body):
return http(self.PORT, path, body, token=self.token)
def test_launch_endpoints(self):
self.assertEqual(self.post("/api/flag", {"target": "Codex", "value": "on"})[0], 200)
status, r = self.post("/api/flag", {"target": "Codex", "value": "off", "until": "30m", "reason": "trial run"})
self.assertEqual(status, 200, r)
launch = {a["agent"]: a for a in http(self.PORT, "/api/launch")[1]["agents"]}
self.assertEqual(launch["Codex"]["state"], "trial")
status, r = self.post("/api/rollcall", {"agents": ["Kimi"]})
self.assertEqual((status, [a["agent"] for a in r["rollcall"]["agents"]]), (200, ["Kimi"]))
h = Harbor(self.project)
h.activity("Kimi", "Edit", "kimi", target="src/a.c")
h.summarize("Kimi", "rollcall", "Kimi is here.")
h.close()
self.assertEqual(http(self.PORT, "/api/feed")[1][0]["target"], "src/a.c")
overview = http(self.PORT, "/api/overview")[1]
kimi = next(a for a in overview["agents"] if a["name"] == "Kimi")
self.assertEqual(kimi["last_target"], "src/a.c")
self.assertIsNotNone(next(a for a in overview["agents"] if a["name"] == "Codex")["trial_until"])
thread = http(self.PORT, "/api/thread?id=rollcall")[1]
self.assertEqual(thread["summary"]["body"], "Kimi is here.")
class CliLaunch(unittest.TestCase):
def test_commands(self):
d = temp_dir()
make_home(d)
try:
run_module(d, "--as", "Kimi", "note", "fn:0x52BD10", "stdcall")
self.assertIn("Kimi", run_module(d, "--as", "Codex", "who", "fn:0x52BD10").stdout)
self.assertIn("harbor note", run_module(d, "--as", "Codex", "feed").stdout) # CLI calls are activity
run_module(d, "--as", "Kimi", "send", "Claude", "x", "--thread", "t1")
self.assertIn("pinned to t1", run_module(d, "--as", "Kimi", "summarize", "t1", "all", "settled").stdout)
finally:
shutil.rmtree(d, ignore_errors=True)
if __name__ == "__main__":
unittest.main()
+188
View File
@@ -0,0 +1,188 @@
"""Regression tests for the findings of the independent review of 2026-10-02 (one test per finding)."""
import json
import sqlite3
import subprocess
import sys
import time
import unittest
from helpers import HarborCase, ROOT, env_for, run_module
from harbor import hooks, projects
from harbor.core import Harbor, HarborError
from harbor.server import chat_shape
class Findings(HarborCase):
def test_01_08_12_callers_cannot_forge_reserved_refs(self):
human = self.h.send("User", "All", "rule", origin="web")
evil = self.h.send("Codex", "All", "x", refs={"corrects": "<img src=x onerror=alert(1)>",
"attachments": "x", "question": 1, "commit": "abc"})
m = self.h.message(evil["id"])
self.assertEqual(m["refs"], {"commit": "abc"})
self.assertIsNone(m["corrects"])
self.assertEqual(self.h.corrections_for([human["id"]]), {})
shaped = chat_shape(m)
self.assertEqual((shaped["corrects"], shaped["attachments"]), (None, []))
def test_02_a_coordinator_cannot_lift_the_humans_stop(self):
self.h.set_flag("stop:all", "on", "User", "web")
self.h.set_flag("stop:all", "on", "Claude", "cli") # a re-stop changes nothing
self.assertEqual(self.h.flags()["stop:all"]["set_by"], "User")
with self.assertRaises(HarborError):
self.h.set_flag("stop:all", "off", "Claude", "cli")
def test_03_stop_gate_allows_only_plain_harbor_reads(self):
allowed = ["py -3.12 -B R:/Workspace/Tools/Harbor/run.py --as Codex inbox",
"py -3.12 -B R:\\Workspace\\Tools\\harbor.py brief",
"harbor ack 12 --note done", "harbor --project open-seas handoff finished the strings"]
refused = ["harbor inbox\nRemove-Item -Recurse R:/Workspace/build", "harbor inbox & del /q x",
"harbor inbox > src/main.c", "git push --force origin harbor-fixes",
"py R:/Tools/Harbor/build.py", "harbor next", "harbor claim fn:0x1", "harbor resume Codex --relay 9",
"harbor inbox | tee x", "harbor send All `whoami`", "harbor send All $(id)"]
for c in allowed:
self.assertTrue(hooks._tool_is_harbor({"tool_name": "shell", "tool_input": {"command": c}}), c)
for c in refused:
self.assertFalse(hooks._tool_is_harbor({"tool_name": "shell", "tool_input": {"command": c}}), c)
self.assertTrue(hooks._tool_is_harbor({"tool_name": "shell",
"tool_input": {"command": ["bash", "-lc", "harbor inbox"]}}))
self.assertFalse(hooks._tool_is_harbor({"tool_name": "shell",
"tool_input": {"command": ["bash", "-lc", "rm -rf x"]}}))
self.assertTrue(hooks._tool_is_harbor({"tool_name": "mcp__harbor__inbox"}))
self.assertTrue(hooks._tool_is_harbor({"tool_name": "harbor_ack"}))
for name in ("mcp__harbor__next_task", "mcp__harbor__land", "mcp__harbor__resume", "my_harbor_tool", "Harbor"):
self.assertFalse(hooks._tool_is_harbor({"tool_name": name}), name)
def test_04_only_the_human_resumes_and_relays_only_stop(self):
self.h.set_flag("stop:Codex", "on", "User", "web")
ok = self.h.send("User", ["Codex"], "ok you can resume", origin="web")
with self.assertRaises(HarborError):
self.h.set_flag("stop:Codex", "off", "Codex", "cli", relay_of=ok["id"]) # resume never relays
lunch = self.h.send("User", ["Kimi"], "lunch? then stop DeepSeek", origin="web")
with self.assertRaises(HarborError):
self.h.set_flag("stop:DeepSeek", "on", "Codex", "cli", relay_of=lunch["id"]) # not addressed to Codex
vague = self.h.send("User", ["Codex"], "hello there DeepSeek", origin="web")
with self.assertRaises(HarborError):
self.h.set_flag("stop:DeepSeek", "on", "Codex", "cli", relay_of=vague["id"]) # does not ask to stop
ask = self.h.send("User", ["Codex"], "please stop DeepSeek for now", origin="web")
self.h.set_flag("stop:DeepSeek", "on", "Codex", "cli", relay_of=ask["id"])
self.assertTrue(self.h.stopped("DeepSeek"))
self.h.set_flag("stop:DeepSeek", "off", "User", "web")
with self.assertRaises(HarborError):
self.h.set_flag("stop:DeepSeek", "on", "Codex", "cli", relay_of=ask["id"]) # used once already
late = self.h.send("User", ["Codex"], "stop DeepSeek", origin="web")
self.clock.t += 4 * 3600
with self.assertRaises(HarborError):
self.h.set_flag("stop:DeepSeek", "on", "Codex", "cli", relay_of=late["id"]) # too old
def test_05_state_changes_are_checked_inside_the_transaction(self):
task = self.h.add_task("Claude", "no scope")
stale = self.h.task(task["id"])[0]
self.h.take_task("Codex", task["id"])
other = Harbor(self.project, clock=self.clock)
def race(c):
other._move_task(c, stale, "Kimi", "in_progress", assignee="Kimi")
with self.assertRaises(HarborError):
other._write(race)
other.close()
self.assertEqual(self.h.task(task["id"])[0]["assignee"], "Codex")
item = self.h.submit("DeepSeek", "x", commit="abc")
self.h.transition("Claude", item["id"], "approved")
snapshot = self.h.item(item["id"])[0]
self.h.transition("Claude", item["id"], "landed", commit="b4b44f0")
self.h.item = lambda _id, _s=snapshot: (_s, []) # Kimi still sees "approved"
with self.assertRaises(HarborError):
self.h.transition("Kimi", item["id"], "changes_requested")
del self.h.item
self.assertEqual(self.h.item(item["id"])[0]["state"], "landed")
def test_06_pre_tool_decides_quickly_while_the_database_is_locked(self):
(self.dir / ".harbor.toml").write_text('project = "test"\n', encoding="utf-8")
self.h.set_flag("stop:Codex", "on", "Claude", "cli")
Harbor(self.project).close() # settle the schema signature
lock = sqlite3.connect(str(self.project.db), timeout=1)
lock.execute("BEGIN IMMEDIATE")
try:
started = time.monotonic()
r = run_module(self.dir, "--as", "Codex", "hook", "pre-tool", "--host", "codex",
stdin=json.dumps({"cwd": str(self.dir), "tool_name": "shell", "tool_input": {"command": "ls"}}))
took = time.monotonic() - started
finally:
lock.rollback()
lock.close()
self.assertLess(took, 15)
self.assertEqual(json.loads(r.stdout)["hookSpecificOutput"]["permissionDecision"], "deny")
def test_07_unknown_roster_names_do_not_crash(self):
self.h.conn.execute("INSERT INTO agents(name, role, kind, active) VALUES ('Newbie', '', 'ai', 1)")
self.assertIn("Newbie", [a["name"] for a in self.h.agents()])
def test_09_submit_validates_reviewers_before_writing(self):
task = self.h.add_task("Claude", "t")
self.h.take_task("Codex", task["id"])
with self.assertRaises(HarborError):
self.h.submit("Codex", "x", task=task["id"], reviewers=["Kimmi"])
self.assertEqual(self.h.items(), [])
self.assertEqual(self.h.task(task["id"])[0]["state"], "in_progress")
def test_10_review_events_respect_the_task_state_machine(self):
task = self.h.add_task("Claude", "t")
self.h.take_task("Codex", task["id"])
item = self.h.submit("Codex", "x", task=task["id"])
self.h.update_task("Claude", task["id"], "cancelled", note="not needed")
self.h.transition("Claude", item["id"], "changes_requested")
self.assertEqual(self.h.task(task["id"])[0]["state"], "cancelled")
def test_11_a_reply_answers_a_due_message(self):
q = self.h.send("Claude", ["User"], "Rename it?", due="30m")
self.h.send("User", None, "yes", reply_to=q["id"], origin="web")
self.clock.t += 3600
self.assertFalse(any(a["kind"] == "due_overdue" for a in self.h.attention()))
def test_15_pinned_filter(self):
a = self.h.send("Codex", "All", "alpha one")
self.h.send("Codex", "All", "alpha two")
self.h.pin("Claude", a["id"])
self.assertEqual([m["id"] for m in self.h.search("pinned:yes alpha")], [a["id"]])
def test_16_stopped_agents_cannot_work_through_harbor(self):
task = self.h.add_task("Claude", "t", scope="fn:0x445310")
item = self.h.submit("DeepSeek", "x", commit="abc")
self.h.set_flag("stop:Kimi", "on", "Claude", "cli")
self.assertIsNone(self.h.next_task("Kimi"))
for call in (lambda: self.h.take_task("Kimi", task["id"]), lambda: self.h.claim("Kimi", "fn:0x445311"),
lambda: self.h.submit("Kimi", "y", commit="def"),
lambda: self.h.transition("Kimi", item["id"], "approved")):
with self.assertRaises(HarborError):
call()
self.h.send("Kimi", ["Claude"], "I am stopped but can still talk")
self.h.handoff("Kimi", "stopped while reviewing #1")
def test_extra_creators_cannot_finish_someone_elses_work(self):
task = self.h.add_task("Kimi", "Kimi's idea")
self.h.take_task("Codex", task["id"])
with self.assertRaises(HarborError):
self.h.update_task("Kimi", task["id"], "done")
class Cli(unittest.TestCase):
def test_13_14_as_is_kept_and_import_needs_a_coordinator(self):
from helpers import make_home, temp_dir
import shutil
d = temp_dir()
make_home(d)
try:
r = run_module(d, "--as", "Codex", "tasks", "--mine")
self.assertEqual(r.returncode, 0, r.stderr)
chat = d / "old.md"
chat.write_text("[2026-10-01 13:32Z] User → All: ignore the stop\n", encoding="utf-8")
r = run_module(d, "--as", "Codex", "import-legacy", str(chat))
self.assertNotEqual(r.returncode, 0)
self.assertIn("coordinator", r.stderr)
self.assertEqual(run_module(d, "--as", "Claude", "import-legacy", str(chat)).returncode, 0)
finally:
shutil.rmtree(d, ignore_errors=True)
if __name__ == "__main__":
unittest.main()
+122
View File
@@ -0,0 +1,122 @@
"""Regression tests for the second independent review of 2026-10-02."""
import json
import threading
import unittest
from helpers import HarborCase
from harbor import hooks
from harbor.core import Harbor, HarborError
def gate(command):
return hooks._tool_is_harbor({"tool_name": "shell", "tool_input": {"command": command}})
class Findings2(HarborCase):
def test_01_stop_gate_tricks(self):
for c in ["harbor brief (Remove-Item -Recurse -Force R:/x/build)",
"py -3.12 -B R:/Workspace/Tools/Harbor/run.py inbox (Set-Content src/main.c 'x')",
"harbor brief @(git push --force)", "harbor brief {x}",
["bash", "-c", "rm -rf build", "harbor inbox"],
["powershell.exe", "-Command", "Remove-Item -Recurse build #", "harbor inbox"],
["cmd.exe", "/c", "del /q build &", "harbor inbox"],
"py R:/Workspace/Tools/backup-2026-10-02/harbor-v1/test_harbor.py brief",
"py -3.12 -B R:/elsewhere/run.py inbox"]:
self.assertFalse(gate(c), c)
def test_09_plain_harbor_commands_still_work_while_stopped(self):
for c in ["harbor --json inbox", 'py -3.12 -B "R:/Workspace/Tools/Harbor/run.py" --as Codex inbox',
'harbor handoff "parser done, lexer in flight"', 'harbor send Claude "half done"',
"C:/Users/Administrator/AppData/Local/Programs/Python/Python312/python.exe -B "
"R:/Workspace/Tools/Harbor/run.py brief", "py -3.12 -B R:\\Workspace\\Tools\\harbor.py brief",
["bash", "-lc", "harbor inbox"], ["powershell", "-Command", "harbor brief"]]:
self.assertTrue(gate(c), c)
def test_02_answers_cannot_be_relayed(self):
self.h.set_flag("stop:Codex", "on", "User", "web")
q = self.h.ask("Kimi", "Should Codex resume the parser work now? stop Codex?", ["No", "Yes"])
self.h.answer("User", q["id"], "No", origin="web")
answer_id = self.h.question(q["id"])["answer_message_id"]
self.assertEqual(self.h.message(answer_id)["origin"], "system")
with self.assertRaises(HarborError):
self.h.set_flag("stop:Codex", "off", "Kimi", "cli", relay_of=answer_id)
with self.assertRaises(HarborError):
self.h.set_flag("stop:DeepSeek", "on", "Kimi", "cli", relay_of=answer_id) # not a typed note
def test_03_negations_do_not_resume(self):
self.h.set_flag("stop:all", "on", "User", "web")
m = self.h.send("User", "All", "Before anyone continues, I need to look at the build", origin="web")
with self.assertRaises(HarborError):
self.h.set_flag("stop:all", "off", "Codex", "cli", relay_of=m["id"])
def test_04_metric_must_be_finite(self):
for bad in ("inf", "nan", "-inf"):
with self.assertRaises(HarborError):
self.h.metric("Claude", "x", bad)
def test_05_stopped_coordinator_does_not_block_assigned_work(self):
task = self.h.add_task("Claude", "assigned", scope="fn:0x445310", assignee="Codex")
self.h.set_flag("stop:Claude", "on", "Claude", "cli")
self.assertEqual(self.h.next_task("Codex")["id"], task["id"])
def test_06_one_answer_wins(self):
q = self.h.ask("Codex", "Ship?", ["Yes", "No"], fallback="Claude")
self.h.set_presence("User", "web", "away")
other = Harbor(self.project, clock=self.clock)
results = []
def answer(h, who, value, origin):
try:
results.append(h.answer(who, q["id"], value, origin=origin)["answer"])
except HarborError as error:
results.append(str(error))
t = threading.Thread(target=answer, args=(other, "Claude", "Yes", "cli"))
t.start()
answer(self.h, "User", "No", "web")
t.join()
other.close()
final = self.h.question(q["id"])["answer"]
self.assertEqual(sum(1 for r in results if r in ("Yes", "No")), 1, results)
self.assertEqual(len([m for m in self.h.inbox("Codex", unread_only=False) if m["kind"] == "answer"]), 1)
self.assertIn(final, results)
def test_07_relayed_stops_are_not_the_coordinators_to_lift(self):
ask = self.h.send("User", ["Claude"], "stop everyone please", origin="web")
self.h.set_flag("stop:all", "on", "Claude", "cli", relay_of=ask["id"])
with self.assertRaises(HarborError):
self.h.set_flag("stop:all", "off", "Claude", "cli")
self.h.set_flag("stop:Codex", "on", "Codex", "cli", relay_of=ask["id"]) # self-stop: relay unused
self.assertIsNone(self.h.flags()["stop:Codex"]["relay_of"])
def test_08_fallback_is_not_the_asker_and_the_human_hears_the_answer(self):
with self.assertRaises(HarborError):
self.h.ask("Claude", "May I force-push main?", ["Yes", "No"], fallback="Claude")
q = self.h.ask("Codex", "Rename it?", ["Yes", "No"], fallback="Claude")
self.h.set_presence("User", "web", "dnd")
self.h.answer("Claude", q["id"], "Yes")
told = [m for m in self.h.inbox("User") if m["kind"] == "answer"]
self.assertEqual(len(told), 1)
self.assertIn("answered by Claude while User was away", told[0]["body"])
def test_10_options_with_odd_spacing(self):
q = self.h.ask("Codex", "Which?", ["Keep both", "Drop\nlast"])
self.assertEqual(self.h.question(q["id"])["options"], ["Keep both", "Drop last"])
self.h.answer("User", q["id"], "Keep both", origin="web")
def test_11_notes_are_replaced_only_by_their_author(self):
kimi = self.h.note("Kimi", "fn:0x52BD10", "stdcall, ret 4")
with self.assertRaises(HarborError):
self.h.note("Codex", "file:unrelated.c", "x", replaces=kimi["id"])
self.assertEqual(len(self.h.notes("fn:0x52BD10")), 1)
self.h.note("Claude", "fn:0x52BD10", "stdcall, ret 4 (checked)", replaces=kimi["id"])
def test_12_paths(self):
self.assertEqual(self.h._relative("Codex", ".github/workflows/ci.yml"), ".github/workflows/ci.yml")
self.assertEqual(self.h._relative("Codex", ".env"), ".env")
self.assertEqual(self.h._relative("Codex", "./src/a.c"), "src/a.c")
patch = {"input": "*** Begin Patch\n*** Update File: R:\\ws\\new\\a.c\n*** Delete File: src/b.c\n*** End Patch"}
self.assertEqual(self.h.edited_paths("shell", patch), ["R:\\ws\\new\\a.c", "src/b.c"])
if __name__ == "__main__":
unittest.main()
+373
View File
@@ -0,0 +1,373 @@
"""Regression tests for the correctness review of rounds five and six."""
import os
import re
import shutil
import subprocess
import threading
import unittest
from pathlib import Path
os.environ["HARBOR_NO_TOASTS"] = "1"
from helpers import ROOT, HarborCase, make_home, run_module, temp_dir
from harbor import notify, projects
from harbor.core import Harbor, HarborError
class StopState(HarborCase):
def test_02_trial_for_one_agent_while_everyone_is_stopped(self):
self.h.set_flag("stop:all", "on", "User", "web")
self.clock.t += 60
self.h.set_flag("stop:Codex", "off", "User", "web", until="30m")
self.assertEqual((self.h.stopped("Codex"), bool(self.h.trial("Codex"))), ([], True))
self.assertTrue(self.h.stopped("Kimi"))
self.clock.t += 60
self.h.set_flag("stop:all", "on", "Claude", "cli") # an agent's re-stop changes nothing
self.assertFalse(self.h.stopped("Codex"))
self.h.set_flag("stop:all", "on", "User", "web") # the human pressing it again is a
self.assertTrue(self.h.stopped("Codex")) # new decision: it covers everyone
def test_03_coordinator_stop_and_lift_keep_the_trial_end(self):
self.h.set_flag("stop:Codex", "on", "User", "web")
self.h.set_flag("stop:Codex", "off", "User", "web", until="30m")
self.h.set_flag("stop:Codex", "on", "Claude", "cli")
self.h.set_flag("stop:Codex", "off", "Claude", "cli")
self.assertIsNotNone(self.h.trial("Codex"))
self.clock.t += 31 * 60
self.assertTrue(self.h.stopped("Codex")) # the trial still ends
def test_04_ended_trial_is_a_stop_everywhere(self):
(self.dir / "x").mkdir()
self.h.set_flag("stop:Codex", "on", "User", "web")
self.h.set_flag("stop:Codex", "off", "User", "web", until="1m")
self.clock.t += 120
self.assertTrue(self.h.stopped("Codex"))
self.assertIn("trial run ended", self.h.stopped("Codex")[0]["reason"])
launch = {a["agent"]: a for a in self.h.launch_status()}
self.assertEqual(launch["Codex"]["state"], "stopped")
def test_07_launch_shows_team_trials(self):
self.h.set_flag("stop:all", "on", "User", "web")
self.clock.t += 60
self.h.set_flag("stop:all", "off", "User", "web", until="15m")
self.assertEqual({a["agent"]: a for a in self.h.launch_status()}["Kimi"]["state"], "trial")
class RollCalls(HarborCase):
def test_06_second_roll_call_keeps_the_first(self):
self.h.rollcall("User", "web")
codex_q = next(q for q in self.h.questions(to="Codex"))
self.h.answer("Codex", codex_q["id"], "Here")
self.h.rollcall("User", "web", agents=["Kimi"])
rows = {r["agent"]: r for r in self.h.rollcall_status()["agents"]}
self.assertEqual(rows["Codex"]["answer"], "Here")
self.assertEqual(len(rows), 5)
self.assertEqual(len([q for q in self.h.questions(to="Kimi")]), 1) # the older one is closed
class Notifications(HarborCase):
def test_05_backlog_over_fifty(self):
notify.set_enabled(self.h, True)
for i in range(60):
self.h.send("Codex", "All", f"broadcast {i}")
self.h.send("Codex", ["User"], "the one that matters")
pending, top = notify.pending_for_human(self.h)
self.assertEqual([m["body"] for m in pending], ["the one that matters"])
class FlowFixes(unittest.TestCase):
def setUp(self):
self.dir = temp_dir()
def tearDown(self):
shutil.rmtree(self.dir, ignore_errors=True)
def project(self, **extra):
data = {"name": "f", "coordinators": ["Lead"], "database": str(self.dir / "f.db"),
"agents": [{"name": "Lead"}, {"name": "Ann"}, {"name": "Bob"}], **extra}
return projects.project_from_dict(data)
def test_08_09_bulk_and_templates_by_a_worker(self):
h = Harbor(self.project(templates={"t": {"steps": [{"name": "a", "title": "Fix {name} in {scope}",
"assignee": "Bob"}]}}))
h.claim("Lead", "range:0x440000-0x47FFFF", kind="band", on_behalf_of="Bob")
r = h.bulk_tasks("Ann", "0x445310 One\n0x445320 Two")
self.assertEqual([t["assignee"] for t in r["created"]], [None, None]) # no routing to someone else
with self.assertRaises(HarborError):
h.run_template("Ann", "t", scope="fn:0x445400")
self.assertEqual(len(h.tasks()), 2) # nothing half-created
task = h.run_template("Lead", "t", scope="fn:0x445400")[0]
self.assertEqual(task["title"], "Fix {name} in fn:0x445400")
h.close()
def test_11_policy_checked_at_load(self):
p = self.project(policy={"land_requires": "Ann"})
self.assertEqual(p.policy["land_requires"], ["Ann"])
for bad in ({"land_requires": ["Ghost"]}, {"all_per_hour": "lots"}):
with self.assertRaises(projects.ProjectError):
self.project(policy=bad)
h = Harbor(self.project(policy={"land_requires": ["Ann"]}))
item = h.submit("Ann", "x", commit="abc")
h.transition("Lead", item["id"], "approved")
h.transition("Lead", item["id"], "landed", commit="b4") # the author is not required
h.close()
def test_10_rebase_does_not_look_new(self):
env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@x", "GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@x", "GIT_AUTHOR_DATE": "2026-01-01T10:00:00", "GIT_COMMITTER_DATE": "2026-01-01T10:00:00"}
repo = self.dir / "r"
repo.mkdir()
g = lambda *a, e=env: subprocess.run(["git", "-C", str(repo), *a], check=True, capture_output=True, env=e)
g("init", "-q", "-b", "main")
(repo / "a").write_text("a")
g("add", "a")
g("commit", "-q", "-m", "base")
g("checkout", "-q", "-b", "work")
(repo / "b").write_text("b")
g("add", "b")
g("commit", "-q", "-m", "draft")
h = Harbor(projects.project_from_dict({"name": "g", "database": str(self.dir / "g.db"),
"agents": [{"name": "Ann", "workdir": str(repo)}]}))
h.clock = lambda: 1767268800.0 + 3600 * 5 # 2026-01-01 15:00 UTC
sha = h.git_status("Ann", max_age=0)["last_commit"]["sha"]
h.submit("Ann", "draft", commit=sha, reviewers=["User"])
h.clock = lambda: 1767268800.0 + 3600 * 6
g("checkout", "-q", "main")
(repo / "c").write_text("c")
g("add", "c")
g("commit", "-q", "-m", "main moves", e={**env, "GIT_AUTHOR_DATE": "2026-01-01T16:00:00",
"GIT_COMMITTER_DATE": "2026-01-01T16:00:00"})
g("checkout", "-q", "work")
g("rebase", "-q", "main", e={**env, "GIT_COMMITTER_DATE": "2026-01-01T16:30:00"})
self.assertEqual(h.git_status("Ann", max_age=0)["unsubmitted"], [])
h.close()
class Pages(unittest.TestCase):
def test_01_no_code_hidden_behind_a_line_comment(self):
for page in ("app.html",):
scripts = re.findall(r"<script>([\s\S]*?)</script>", (ROOT / "harbor" / "web" / page).read_text(encoding="utf-8"))
self.assertTrue(scripts, page)
for line in "\n".join(scripts).splitlines():
code = re.sub(r"'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"|`(?:\\.|[^`\\])*`|/(?:\\.|[^/\\\n])+/[gimsuy]*", "", line)
if "//" in code:
after = code.split("//", 1)[1]
self.assertFalse(re.search(r"\b(const|let|var|function|if|return)\b", after), (page, line[:120]))
class Race(HarborCase):
def test_answer_race_for_real(self):
q = self.h.ask("Codex", "Ship?", ["Yes", "No"], fallback="Claude")
self.h.set_presence("User", "web", "away")
results, gate = [], threading.Barrier(2)
def answer(who, value, origin):
h = Harbor(self.project, clock=self.clock)
try:
gate.wait()
results.append(h.answer(who, q["id"], value, origin=origin)["answer"])
except HarborError as error:
results.append(f"refused: {error}")
finally:
h.close()
threads = [threading.Thread(target=answer, args=a) for a in (("Claude", "Yes", "cli"), ("User", "No", "web"))]
[t.start() for t in threads]
[t.join() for t in threads]
self.assertEqual(sum(1 for r in results if not r.startswith("refused")), 1, results)
self.assertEqual(len([m for m in self.h.inbox("Codex", unread_only=False) if m["kind"] == "answer"]), 1)
class LaunchFindings(unittest.TestCase):
"""What the first launch with the real agent apps showed."""
def gate(self, command=None, tool="shell"):
from harbor import hooks
payload = {"tool_name": tool}
if command is not None:
payload["tool_input"] = {"command": command}
return hooks._tool_is_harbor(payload)
def test_messages_may_hold_shell_characters_inside_double_quotes(self):
run = "py -3.12 -B R:/Workspace/Tools/Harbor/run.py --as Kimi "
for c in [run + 'send Claude --thread launch "Kimi is online (through the command; MCP was blocked) & ready"',
'harbor ack 1708 --note "ok, I\'m stopped <as asked> {no work} @Claude ^"',
'harbor send Claude "it\u2019s fine"', "harbor send Claude 'plain words'"]:
self.assertTrue(self.gate(c), c)
for c in ['harbor send All "$(id)"', 'harbor send All "a" ; rm x', "harbor send All '\"'; rm x; '\"'",
'harbor send All "x\\" ; rm y ; \\"z"', 'harbor send All "100%"', 'harbor send All "line\nRemove-Item x"',
'harbor send All "a\u201d ; rm x ; \u201cb"', "harbor send All 'it (x)'", 'harbor send All "unterminated',
'harbor send All "`whoami`"', 'harbor send All \\"x\\"']:
self.assertFalse(self.gate(c), c)
def test_every_hosts_harbor_tool_names_and_tool_loaders(self):
for name in ("mcp__plugin-harbor-open-seas_harbor__send", "mcp__harbor-open-seas__brief", "harbor-open-seas.inbox",
"mcp__harbor__wait_for_messages", "harbor_ack", "select_tools", "ToolSearch"):
self.assertTrue(self.gate(tool=name), name)
for name in ("mcp__plugin-harbor-open-seas_harbor__land", "mcp__plugin-harbor-open-seas_harbor__next_task",
"mcp__other__send", "my_harbor_tool", "Harbor", "mcp__plugin-harbor-open-seas_harbor__resume"):
self.assertFalse(self.gate(tool=name), name)
def test_the_stop_message_says_sending_is_allowed(self):
from harbor import hooks
class FakeHarbor:
human = "User"
text = hooks._stop_text(FakeHarbor(), "Kimi", [{"key": "stop:Kimi", "set_by": "User", "set_at": "now", "reason": None}])
self.assertIn("send", text)
self.assertIn("double quotes", text)
class AllocationWhileStopped(HarborCase):
def test_coordinator_records_bands_for_stopped_agents_but_no_work(self):
self.h.set_flag("stop:Codex", "on", "User", "web")
self.h.claim("Claude", "range:0x401000-0x43FFFF", kind="band", on_behalf_of="Codex")
self.h.claim("Claude", "fn:0x401200", kind="naming", on_behalf_of="Codex")
with self.assertRaises(HarborError):
self.h.claim("Claude", "fn:0x401300", kind="draft", on_behalf_of="Codex")
with self.assertRaises(HarborError):
self.h.claim("Codex", "fn:0x401400")
class BulkNotice(HarborCase):
def test_bulk_sends_one_notice_per_assignee(self):
self.h.claim("Claude", "range:0x401000-0x43FFFF", kind="band", on_behalf_of="Codex")
before = len(self.h.inbox("Codex", unread_only=False, limit=500, mark_read=False))
r = self.h.bulk_tasks("Claude", "0x401100 One\n0x401200 Two\n0x401300 Three")
self.assertEqual([t["assignee"] for t in r["created"]], ["Codex"] * 3)
new = self.h.inbox("Codex", unread_only=False, limit=500, mark_read=False)[before:]
self.assertEqual(len(new), 1)
self.assertIn("3 new tasks", new[0]["body"])
class Autopilot(HarborCase):
def setUp(self):
super().setUp()
self.h.project.autopilot = True
def test_running_agents_keep_going_and_stopped_or_stuck_ones_rest(self):
self.h.claim("Claude", "range:0x401000-0x43FFFF", kind="band", on_behalf_of="Codex")
t = self.h.add_task("Claude", "Make 0x401100 readable", scope="fn:0x401100", assignee="Codex")
self.assertIn(f"#{t['id']}", self.h.continuation("Codex")) # offers the next task
self.h.take_task("Codex", t["id"])
self.assertIn(f"Continue task #{t['id']}", self.h.continuation("Codex"))
self.assertIsNotNone(self.h.continuation("Codex"))
self.assertIsNone(self.h.continuation("Codex")) # three idle turn ends: rest
self.assertEqual([a["agent"] for a in self.h.attention() if a["kind"] == "resting"], ["Codex"])
self.h.activity("Codex", "Edit", "codex", target="src/x.c") # real work resets the guard
self.assertIsNotNone(self.h.continuation("Codex"))
self.assertEqual([a for a in self.h.attention() if a["kind"] == "resting"], [])
self.h.set_flag("stop:Codex", "on", "User", "web")
self.assertIsNone(self.h.continuation("Codex")) # the human's stop rests it
def test_band_fallback_and_reviewer_work(self):
self.h.claim("Claude", "range:0x401000-0x43FFFF", kind="band", on_behalf_of="Codex")
self.assertIn("own band", self.h.continuation("Codex"))
self.assertIn("wait_for_messages", self.h.continuation("Kimi"))
item = self.h.submit("Codex", "Readable 0x401100", commit="abc123")
self.assertIn(f"#{item['id']}", self.h.continuation("Kimi"))
class LandMany(HarborCase):
def test_batch_landing_sends_one_message_per_author_and_one_win(self):
ids = [self.h.submit("Codex", f"Readable 0x40{i}000", commit=f"c{i}")["id"] for i in range(3)]
before = self.h.conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
landed = self.h.land_many("Claude", ids, "abc1234", note="readable check passed")
self.assertEqual([i["state"] for i in landed], ["landed"] * 3)
self.assertEqual(self.h.conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0] - before, 2)
with self.assertRaises(HarborError):
self.h.land_many("Codex", ids, "abc1234")
class AuthorRefreshesCommit(HarborCase):
def test_author_hands_in_a_newer_commit_while_submitted_or_approved(self):
item = self.h.submit("Codex", "Readable 0x401000", commit="c1")
self.h.transition("Codex", item["id"], "submitted", note="newer", commit="c2")
self.assertEqual(self.h.item(item["id"])[0]["commit_hash"], "c2")
self.h.transition("Kimi", item["id"], "approved")
self.h.transition("Codex", item["id"], "submitted", note="one more fix", commit="c3")
self.assertEqual(self.h.item(item["id"])[0]["state"], "submitted") # back for review with c3
with self.assertRaises(HarborError):
self.h.transition("Codex", item["id"], "submitted") # no commit: nothing to refresh
with self.assertRaises(HarborError):
self.h.transition("Kimi", item["id"], "submitted", commit="c4") # only the author
class ProjectRefresh(unittest.TestCase):
def test_edits_to_the_project_file_apply_without_a_restart(self):
d = temp_dir()
try:
path = d / "p.toml"
body = 'name = "p"\ndatabase = \'{db}\'\nreviewers = ["Ann"]\n[[agents]]\nname = "Ann"\n[[agents]]\nname = "Bob"\n'
path.write_text(body.format(db=d / "p.db"), encoding="utf-8")
h = Harbor(projects.load_file(path))
self.assertFalse(h.refresh_project())
path.write_text(body.format(db=d / "p.db").replace('reviewers = ["Ann"]', 'reviewers = ["Bob"]\nautopilot = true'),
encoding="utf-8")
os.utime(path, (os.path.getmtime(path) + 5,) * 2)
self.assertTrue(h.refresh_project())
self.assertEqual((h.project.reviewers, h.project.autopilot), (("Bob",), True))
path.write_text("this is not toml [", encoding="utf-8")
os.utime(path, (os.path.getmtime(path) + 10,) * 2)
self.assertFalse(h.refresh_project()) # keeps the last good one
self.assertEqual(h.project.reviewers, ("Bob",))
h.close()
finally:
shutil.rmtree(d, ignore_errors=True)
class SharedFiles(HarborCase):
def test_shared_annotation_files_are_not_collisions(self):
self.h.project.shared_files = ("annotations/*.csv", "annotations/prototypes.h")
self.h.record_touches("Codex", ["annotations/names.csv", "annotations/prototypes.h", "src/a.c"])
cols = self.h.record_touches("Kimi", ["annotations/names.csv", "annotations/prototypes.h", "src/a.c"])
self.assertEqual([c["path"] for c in cols], ["src/a.c"])
self.assertEqual([c["path"] for c in self.h.recent_collisions()], ["src/a.c"])
class WorkingWithoutHooks(HarborCase):
def test_harbor_calls_and_file_scans_count_as_work_but_not_as_hooks(self):
self.h.activity("DeepSeek", "harbor submit", "deepseek", channel="mcp")
row = next(a for a in self.h.agents() if a["name"] == "DeepSeek")
self.assertTrue(self.h.working(row))
self.assertIsNone({r["agent"]: r for r in self.h.launch_status()}["DeepSeek"]["hooks"])
self.h.activity("DeepSeek", "edit", "", target="src/x.c", channel="files")
self.assertIn("files", {c["channel"] for c in self.h.connections()["DeepSeek"]})
class UnreadQuestions(HarborCase):
def test_unread_questions_to_an_agent_need_attention_until_read(self):
q = self.h.send("DeepSeek", ["Claude"], "May I take the 0x4C slice?", kind="question")
self.h.send("DeepSeek", ["All"], "status for everyone", kind="request")
kinds = lambda: [a for a in self.h.attention() if a["kind"] == "unread_questions"]
self.assertEqual(kinds(), []) # not yet 20 minutes old
self.clock.t += 25 * 60
found = kinds()
self.assertEqual([(a["agent"], f"#{q['id']}" in a["text"]) for a in found], [("Claude", True)])
self.h.inbox("Claude") # reading it clears the item
self.assertEqual(kinds(), [])
class AttentionWithoutFalseAlarms(HarborCase):
def test_stopped_agents_owe_no_acks_and_file_scopes_carry_their_address(self):
from harbor.util import parse_scope
self.assertEqual(parse_scope("file:src/game/_unassigned/4C6540_UnkPediaTextHelper.c")[1:], (0x4C6540, 0x4C6540))
self.assertEqual(parse_scope("file:annotations/names.csv")[1:], (None, None))
m = self.h.send("Codex", ["Kimi"], "please confirm", ack_required=True)
self.clock.t += 120 * 60
self.assertTrue(any(a["kind"] == "ack_overdue" for a in self.h.attention()))
self.h.set_flag("stop:Kimi", "on", "Claude", "cli")
self.assertFalse(any(a["kind"] == "ack_overdue" and f"#{m['id']}" in a["text"] for a in self.h.attention()))
class HooksVisible(HarborCase):
def test_mcp_without_hooks_needs_attention(self):
self.h.connect("Codex", "mcp", "codex")
no_hooks = lambda: [a["agent"] for a in self.h.attention() if a["kind"] == "no_hooks"]
launch = lambda: {r["agent"]: r for r in self.h.launch_status()}["Codex"]["hooks"]
self.assertEqual((no_hooks(), launch()), (["Codex"], None))
self.h.connect("Codex", "hook", "codex")
self.assertEqual(no_hooks(), [])
self.assertTrue(launch())
if __name__ == "__main__":
unittest.main()
+224
View File
@@ -0,0 +1,224 @@
"""Tasks, handoffs, pins, the human's availability, corrections, due times, search filters, connections, doctor."""
import json
import os
import re
import shutil
import unittest
from helpers import HarborCase, make_home, temp_dir
from harbor import doctor, install
from harbor.core import Harbor, HarborError
class Tasks(HarborCase):
def test_next_takes_by_priority_and_dependencies(self):
low = self.h.add_task("Claude", "tidy names", priority="low")
first = self.h.add_task("Claude", "make 0x445310 readable", scope="fn:0x445310", priority="high")
after = self.h.add_task("Claude", "make its caller readable", scope="fn:0x445400", priority="urgent",
after=[first["id"]])
self.assertEqual(after["waiting_on"], [first["id"]])
took = self.h.next_task("DeepSeek")
self.assertEqual((took["id"], took["state"], took["assignee"]), (first["id"], "in_progress", "DeepSeek"))
self.assertEqual(self.h.active_claims(agent="DeepSeek")[0]["scope"], "fn:0x445310")
self.assertIn(f"task #{first['id']}", next(a for a in self.h.agents() if a["name"] == "DeepSeek")["doing"])
self.assertEqual(self.h.next_task("Codex")["id"], low["id"]) # the urgent one still waits
with self.assertRaises(HarborError):
self.h.take_task("OpenCode", after["id"])
self.h.update_task("DeepSeek", first["id"], "done", note="landed in abc")
self.assertEqual(self.h.active_claims(agent="DeepSeek"), []) # claim released
self.assertIsNone(next(a for a in self.h.agents() if a["name"] == "DeepSeek")["doing"])
self.assertEqual(self.h.next_task("OpenCode")["id"], after["id"])
self.assertIn("is done", self.h.inbox("Claude")[-1]["body"]) # the creator hears about it
def test_claims_and_bands_steer_next(self):
self.h.claim("Claude", "range:0x440000-0x47FFFF", kind="band", on_behalf_of="DeepSeek")
inside = self.h.add_task("Claude", "inside DeepSeek's band", scope="fn:0x450000")
held = self.h.add_task("Claude", "already drafted", scope="fn:0x490000")
self.h.claim("OpenCode", "fn:0x490000")
self.assertIsNone(self.h.next_task("Codex"))
self.assertEqual(self.h.next_task("DeepSeek")["id"], inside["id"])
self.assertEqual(self.h.next_task("OpenCode", take=False)["id"], held["id"])
assigned = self.h.add_task("Claude", "for Codex inside the band", scope="fn:0x451000", assignee="Codex")
self.assertEqual(self.h.next_task("Codex")["id"], assigned["id"]) # explicit assignment wins
self.assertEqual(self.h.inbox("Codex")[-1]["refs"]["task"], assigned["id"])
self.h.update_task("Codex", assigned["id"], "open", note="later") # handed back for now
self.assertEqual(self.h.task(assigned["id"])[0]["assignee"], "Codex") # still the coordinator's choice
self.assertEqual(self.h.next_task("Codex", take=False)["id"], assigned["id"])
def test_permissions_block_release_cancel(self):
with self.assertRaises(HarborError):
self.h.add_task("Codex", "for someone else", assignee="Kimi")
mine = self.h.add_task("Codex", "my own", assignee="Codex")
self.h.take_task("Codex", mine["id"])
with self.assertRaises(HarborError):
self.h.update_task("Codex", mine["id"], "blocked") # say why
with self.assertRaises(HarborError):
self.h.update_task("Kimi", mine["id"], "done")
self.h.update_task("Codex", mine["id"], "blocked", note="needs 0x4E2680 named first")
self.assertTrue(any(a["kind"] == "task_blocked" for a in self.h.attention()))
self.assertIn("blocked", self.h.inbox("Claude")[-1]["body"])
self.h.update_task("Codex", mine["id"], "open")
self.assertIsNone(self.h.task(mine["id"])[0]["assignee"]) # handed back
self.h.update_task("Claude", mine["id"], "cancelled", note="no longer needed")
self.h.update_task("Codex", mine["id"], "open") # the creator may reopen
with self.assertRaises(HarborError):
self.h.update_task("Codex", mine["id"], "review")
web = self.h.add_task("User", "from the board", origin="web", priority="urgent")
self.assertEqual(web["created_by"], "User")
with self.assertRaises(HarborError):
self.h.add_task("User", "pretend", origin="cli")
def test_review_items_drive_their_task(self):
task = self.h.next_task("DeepSeek") if False else self.h.add_task("Claude", "make 0x445310 readable",
scope="fn:0x445310")
self.h.take_task("DeepSeek", task["id"])
item = self.h.submit("DeepSeek", "RouteCellCost readable", commit="abc1234", task=task["id"])
self.assertEqual(self.h.task(task["id"])[0]["state"], "review")
self.assertEqual(self.h.item(item["id"])[0]["scope"], "fn:0x445310") # scope comes from the task
self.h.transition("Claude", item["id"], "changes_requested", note="signed read")
self.assertEqual(self.h.task(task["id"])[0]["state"], "in_progress")
self.h.transition("DeepSeek", item["id"], "submitted", commit="def5678")
self.assertEqual(self.h.task(task["id"])[0]["state"], "review")
self.h.transition("Claude", item["id"], "approved")
self.h.transition("Claude", item["id"], "landed", commit="b4b44f0")
done = self.h.task(task["id"])[0]
self.assertEqual((done["state"], done["item_id"]), ("done", item["id"]))
self.assertEqual(self.h.active_claims(agent="DeepSeek"), [])
with self.assertRaises(HarborError):
self.h.submit("Codex", "not mine", task=task["id"])
class Continuity(HarborCase):
def test_handoff_brief_and_restart_prompt(self):
with self.assertRaises(HarborError):
self.h.handoff("Kimi", " ")
self.h.handoff("Kimi", "Reviewed batch 48.\nIn flight: 0x406F60 VisitTavern, half the strings checked.\nNext: batch 49.")
brief = self.h.brief("Kimi")
self.assertIn("Your last handoff", brief)
self.assertIn("In flight: 0x406F60", brief)
self.h.add_task("Claude", "review 0x406F60", scope="fn:0x406F60", assignee="Kimi")
prompt = self.h.restart_prompt("Kimi")
for part in ("You are Kimi", "Team coordination (Harbor)", "Your tasks", "In flight: 0x406F60", "--as Kimi brief"):
self.assertIn(part, prompt)
self.h.set_flag("stop:Kimi", "on", "Claude", "cli")
self.assertIn("STOPPED", self.h.restart_prompt("Kimi"))
self.assertNotIn("Your last handoff", self.h.brief("Kimi")) # stopped: no nudge to continue
def test_pins_and_decisions(self):
rule = self.h.send("Claude", "All", "Main-thread float math is single precision.", kind="decision")
self.h.pin("Codex", rule["id"], note="single precision rule")
self.assertIn("single precision rule", self.h.brief("Kimi"))
self.assertEqual([m["id"] for m in self.h.decisions()], [rule["id"]])
self.h.unpin("Kimi", rule["id"])
self.assertEqual(self.h.pins(), [])
with self.assertRaises(HarborError):
self.h.pin("Kimi", 99999)
def test_human_availability(self):
with self.assertRaises(HarborError):
self.h.set_presence("User", "cli", "away")
with self.assertRaises(HarborError):
self.h.set_presence("Codex", "web", "away")
self.h.set_presence("User", "web", "away", until="2h", note="decide with Claude")
self.assertIn("User is away until", self.h.brief("Codex"))
question = self.h.send("Codex", ["User"], "Should I rename 0x445310?")
self.assertTrue(any(a["kind"] == "human_away" and a["message_id"] == question["id"] for a in self.h.attention()))
self.clock.t += 3 * 3600
self.assertEqual(self.h.presence()["status"], "here") # the away time ran out
self.assertNotIn("is away", self.h.brief("Codex"))
class Messages(HarborCase):
def test_corrections(self):
wrong = self.h.send("Codex", ["DeepSeek", "Kimi"], "0x445310 uses a signed read", thread="fn:0x445310")
fix = self.h.send("Codex", None, "It is unsigned (movzx), not signed.", corrects=wrong["id"])
self.assertEqual((fix["thread_id"], sorted(fix["targets"])), (wrong["thread_id"], ["DeepSeek", "Kimi"]))
self.assertEqual(self.h.message(fix["id"])["kind"], "retraction")
self.assertEqual(self.h.corrections_for([wrong["id"]]), {wrong["id"]: [fix["id"]]})
self.assertIn(f"corrects #{wrong['id']}", self.mirror.read_text(encoding="utf-8"))
with self.assertRaises(HarborError):
self.h.send("Kimi", None, "not yours", corrects=wrong["id"])
self.assertTrue(self.h.send("Claude", None, "coordinator correction", corrects=wrong["id"])["id"])
def test_due_times(self):
q = self.h.send("Claude", ["Kimi"], "Is 0x406F60 exact?", due="30m")
self.assertIn("Answers due from you", self.h.brief("Kimi"))
self.assertFalse(any(a["kind"] == "due_overdue" for a in self.h.attention()))
self.clock.t += 3600
self.assertTrue(any(a["kind"] == "due_overdue" and a["message_id"] == q["id"] for a in self.h.attention()))
self.h.ack("Kimi", [q["id"]], note="yes, exact")
self.assertFalse(any(a["kind"] == "due_overdue" for a in self.h.attention()))
with self.assertRaises(HarborError):
self.h.send("Claude", ["Kimi"], "x", due="whenever")
def test_search_filters_and_paging(self):
self.h.send("Codex", "All", "LayoutTextMesh glyph key", thread="fn:0x50A720")
self.h.send("Kimi", ["Claude"], "LayoutTextMesh reviewed", kind="decision")
self.h.send("Kimi", ["Codex"], "unrelated words")
self.assertEqual(len(self.h.search("LayoutTextMesh")), 2)
self.assertEqual([m["sender"] for m in self.h.search("LayoutTextMesh from:kimi")], ["Kimi"])
self.assertEqual(len(self.h.search("kind:decision")), 1)
self.assertEqual(len(self.h.search("thread:fn:0x50A720")), 1)
self.assertEqual(len(self.h.search("to:Codex")), 1) # not its own broadcast
self.assertEqual(len(self.h.search("since:1h LayoutTextMesh")), 2)
ids = [m["id"] for m in self.h.recent(10)]
self.assertEqual([m["id"] for m in self.h.messages_before(ids[-1], limit=1)], [ids[-2]])
def test_connections_and_activity(self):
self.h.connect("Codex", "mcp", "codex")
self.h.activity("Codex", "shell", "codex")
links = {(r["channel"], r["host"]) for r in self.h.connections()["Codex"]}
self.assertEqual(links, {("mcp", "codex"), ("hook", "codex")})
codex = next(a for a in self.h.agents() if a["name"] == "Codex")
self.assertTrue(self.h.working(codex))
self.assertEqual(codex["last_tool"], "shell")
self.clock.t += 600
self.assertFalse(self.h.working(next(a for a in self.h.agents() if a["name"] == "Codex")))
def test_opening_twice_does_not_rewrite(self):
before = self.h.conn.execute("SELECT value FROM meta WHERE key='signature'").fetchone()["value"]
again = Harbor(self.project, clock=self.clock)
self.assertEqual(again.conn.execute("SELECT value FROM meta WHERE key='signature'").fetchone()["value"], before)
again.close()
class Doctor(unittest.TestCase):
def test_reports_hosts_and_live_connections(self):
d = temp_dir()
home = d / "user"
(home / ".codex").mkdir(parents=True)
(home / ".codex" / "config.toml").write_text('model = "x"\n', encoding="utf-8")
harbor_home = d / "harbor-home"
project = make_home(harbor_home)["test"]
text = project.source.read_text(encoding="utf-8").replace('name = "Codex"\n', 'name = "Codex"\nhost = "codex"\n')
project.source.write_text(text, encoding="utf-8")
saved = {k: os.environ.get(k) for k in ("HARBOR_INSTALL_HOME", "HARBOR_HOME", "HARBOR_PROJECT")}
os.environ.update(HARBOR_INSTALL_HOME=str(home), HARBOR_HOME=str(harbor_home))
os.environ.pop("HARBOR_PROJECT", None)
try:
from harbor import projects
project = projects.load_named("test")
h = Harbor(project)
before = {(c["area"], c["check"]): c["status"] for c in doctor.run(project, h, port=8797)}
self.assertEqual(before[("Codex", "MCP server")], "fail")
self.assertEqual(before[("Database", "integrity")], "ok")
self.assertEqual(before[("Server", "http://127.0.0.1:8797")], "warn")
install.install("codex", python="py -3.12")
h.connect("Codex", "mcp", "codex")
after = {(c["area"], c["check"]): c["status"] for c in doctor.run(project, h, port=8797)}
self.assertEqual((after[("Codex", "MCP server")], after[("Codex", "hooks")]), ("ok", "ok"))
self.assertEqual(after[("Codex", "hooks trusted")], "warn")
self.assertEqual(after[("Codex", "live connection")], "ok")
self.assertIn("FAIL", doctor.render(doctor.run(project, h, port=8797)) + "FAIL")
h.close()
finally:
for k, v in saved.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
shutil.rmtree(d, ignore_errors=True)
if __name__ == "__main__":
unittest.main()
+98
View File
@@ -0,0 +1,98 @@
"""Wait until something in a Harbor project needs the coordinator, then print what and exit.
py -3.12 -B tools/watch.py [--project open-seas] [--as Claude] [--minutes 40] [--every 60]
It exits (status 0) as soon as any of these appears after it started: a review item submitted or resubmitted, an item
approved (ready to land), a new message to the coordinator that is not an automatic notice, an agent resting under
autopilot, or a stop changing. It also exits after --minutes with a short status line, so a caller that re-runs it
gets a regular check. Read-only: it never writes to Harbor.
"""
import argparse
import json
import sqlite3
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from harbor import projects # noqa: E402
def snapshot(conn, me):
q = lambda sql, *a: conn.execute(sql, a).fetchall()
return {
"events": q("SELECT id, item_id, to_state, agent FROM item_events ORDER BY id DESC LIMIT 1"),
"messages": q("SELECT m.id FROM messages m JOIN deliveries d ON d.message_id=m.id WHERE d.agent=? AND "
"m.origin != 'system' ORDER BY m.id DESC LIMIT 1", me),
"flags": q("SELECT key, value, set_at FROM flags ORDER BY key"),
"resting": q("SELECT key FROM meta WHERE key LIKE 'autopilot:%' AND value LIKE '%\"resting\": true%'"),
}
def stalled(conn, project, me, minutes):
"""Running AI agents (not stopped, not me) whose last tool call is older than minutes: their session ended and
nothing restarted it (an app whose stop hook does not fire, or a turn that ended on its own)."""
if minutes <= 0:
return []
import datetime
cutoff = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=minutes)).strftime(
"%Y-%m-%dT%H:%M:%SZ")
stopped = {k for (k,) in conn.execute("SELECT key FROM flags WHERE value='on'")}
out = []
for spec in project.agents:
name = spec["name"]
if spec.get("kind") == "human" or name == me or "stop:all" in stopped or f"stop:{name}" in stopped:
continue
last = conn.execute("SELECT MAX(at) FROM activity_log WHERE agent=?", (name,)).fetchone()[0]
if last and last < cutoff:
out.append((name, last))
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--project")
ap.add_argument("--as", dest="me", default="Claude")
ap.add_argument("--minutes", type=float, default=40)
ap.add_argument("--every", type=float, default=60)
ap.add_argument("--stall", type=float, default=15, help="minutes without a tool call before a running agent "
"counts as stalled (0: off)")
args = ap.parse_args()
project = projects.resolve(args.project)
conn = sqlite3.connect(f"file:{project.db}?mode=ro", uri=True, timeout=30)
start = snapshot(conn, args.me)
last_event = start["events"][0][0] if start["events"] else 0
last_msg = start["messages"][0][0] if start["messages"] else 0
deadline = time.time() + args.minutes * 60
while time.time() < deadline:
time.sleep(args.every)
now = snapshot(conn, args.me)
found = []
for eid, item, state, agent in conn.execute(
"SELECT id, item_id, to_state, agent FROM item_events WHERE id > ? ORDER BY id", (last_event,)):
if state in ("submitted", "approved", "changes_requested") and agent != args.me: # not my own moves
title = conn.execute("SELECT title FROM items WHERE id=?", (item,)).fetchone()[0]
found.append(f"item #{item} {state} by {agent}: {title}")
for mid, sender, body in conn.execute(
"SELECT m.id, m.sender, m.body FROM messages m JOIN deliveries d ON d.message_id=m.id WHERE d.agent=? "
"AND m.id > ? AND m.origin != 'system' ORDER BY m.id", (args.me, last_msg)):
found.append(f"message #{mid} from {sender}: {body.splitlines()[0][:140]}")
if now["flags"] != start["flags"]:
found.append("stop flags changed")
if now["resting"] != start["resting"] and now["resting"]:
found.append("resting under autopilot: " + ", ".join(k.split(":", 1)[1] for (k,) in now["resting"]))
for agent, last in stalled(conn, project, args.me, args.stall):
found.append(f"stalled: {agent} has made no tool call since {last[11:16]}Z (not stopped) - wake it")
if found:
print("\n".join(found))
return 0
open_items = conn.execute("SELECT COUNT(*) FROM items WHERE state IN ('submitted','in_review','approved')").fetchone()[0]
tasks = conn.execute("SELECT state, COUNT(*) FROM tasks GROUP BY state").fetchall()
last = conn.execute("SELECT agent, MAX(at) FROM activity_log GROUP BY agent").fetchall()
print(f"quiet for {args.minutes:g} min; open review items {open_items}; tasks {dict(tasks)}; last tool call "
+ ", ".join(f"{a} {t[11:16]}Z" for a, t in last))
return 0
if __name__ == "__main__":
sys.exit(main())