"""Entry points for agent hosts' hook systems: harbor --as [--project ] hook --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