Files
HarborFork/harbor/hooks.py
T

345 lines
17 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 before a tool as Claude-compatible JSON at exit 0 - {"hookSpecificOutput":
{"permissionDecision": "deny", ...}} - because a host that runs the command through PowerShell
turns exit code 2 into 1
claude, codex, deepseek a stop's keep-going as {"decision": "block", "reason": ...} at exit 0
kimi a stop's keep-going as exit code 2 with the reason on stderr: Kimi's agent-core reads a
top-level {"decision": "block"} as allow, and it runs hooks through cmd.exe, which passes
exit code 2 through. Kimi honours one keep-going per turn
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")
EXIT_CODE_STOP_HOSTS = ("kimi",) # a stop's keep-going: exit 2 + stderr (see above)
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", "i_am", "mark_read", "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 and host not in EXIT_CODE_STOP_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 = project.session_identity(agent or os.environ.get("HARBOR_AGENT"), cwd) # the session's own folder
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 {}
harbor_tool = _harbor_tool(str(tool))
if harbor_tool is not None: # tell the app's shared Harbor server which session is calling
from . import sessions
sessions.leave(harbor, host, harbor_tool, tool_input if isinstance(tool_input, dict) else {}, agent)
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