diff --git a/harbor/hooks.py b/harbor/hooks.py index e2d06e7..0848084 100644 --- a/harbor/hooks.py +++ b/harbor/hooks.py @@ -1,381 +1,381 @@ -"""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 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.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP - | subprocess.CREATE_BREAKAWAY_FROM_JOB | subprocess.CREATE_NO_WINDOW) - 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 +"""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 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