382 lines
20 KiB
Python
382 lines
20 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 ensure_server(project, every=60):
|
|
"""Restart the project's web server when it is down: [server] port and start (a command). Any agent's hook
|
|
does it, at most once a minute, so the board and chat come back without the coordinator's session. Detached,
|
|
so the server outlives the hook (and the app) that started it."""
|
|
port, start = project.server.get("port"), project.server.get("start")
|
|
if not port or not start:
|
|
return
|
|
stamp = project.db.parent / "server-check.stamp"
|
|
try:
|
|
import time
|
|
if stamp.exists() and time.time() - stamp.stat().st_mtime < every:
|
|
return
|
|
stamp.touch()
|
|
import socket
|
|
with socket.socket() as connection:
|
|
connection.settimeout(0.3)
|
|
if connection.connect_ex(("127.0.0.1", int(port))) == 0:
|
|
return
|
|
import subprocess
|
|
flags = 0
|
|
if os.name == "nt":
|
|
flags = (subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP # hidden console, no windows
|
|
| subprocess.CREATE_BREAKAWAY_FROM_JOB)
|
|
command = start if isinstance(start, list) else [start]
|
|
try:
|
|
subprocess.Popen(command, creationflags=flags, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL, close_fds=True)
|
|
except OSError: # the job may refuse breakaway: start it without
|
|
subprocess.Popen(command, creationflags=flags & ~getattr(subprocess, "CREATE_BREAKAWAY_FROM_JOB", 0),
|
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
close_fds=True)
|
|
print(f"harbor: the web server on port {port} was down; restarting it", file=sys.stderr)
|
|
except Exception as error: # never break the host because of this
|
|
print(f"harbor: server check failed: {error}", file=sys.stderr)
|
|
|
|
|
|
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
|
|
ensure_server(project)
|
|
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
|