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.
336 lines
16 KiB
Python
336 lines
16 KiB
Python
"""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
|