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:
commit
dbfd1720c5
43 files changed
+12440
No files matched your search
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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`.
|
||||
@@ -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__"]
|
||||
@@ -0,0 +1,3 @@
|
||||
from .cli import run
|
||||
|
||||
run()
|
||||
+721
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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),)))
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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
@@ -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] + "…"
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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; }
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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 {}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
home = 'R:\Workspace\Data\harbor'
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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("<x> & ''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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user