ensure_server: start with a hidden console (CREATE_NO_WINDOW), never DETACHED_PROCESS: a console-less server made every git and compiler run it started open a visible window

This commit is contained in:
KeelrLeek committed 2026-10-07 08:48:58 -05:00
1 parent 3d9ac4ca9a
commit e0b9a82e7a
1 file changed
+381 -381
+381 -381
View File
@@ -1,381 +1,381 @@
"""Entry points for agent hosts' hook systems: """Entry points for agent hosts' hook systems:
harbor --as <Agent> [--project <name>] hook <event> --host claude|codex|deepseek|kimi|opencode|generic 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, 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 $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. 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 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, 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) 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 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 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 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) 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 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) (the OpenCode plugin injects it into the idle session)
turn-end record presence only turn-end record presence only
Output formats, from the hosts' own code (see integrations/README.md): Output formats, from the hosts' own code (see integrations/README.md):
claude, codex, deepseek context as {"hookSpecificOutput": {"hookEventName": ..., "additionalContext": ...}} claude, codex, deepseek context as {"hookSpecificOutput": {"hookEventName": ..., "additionalContext": ...}}
kimi, generic context as plain stdout kimi, generic context as plain stdout
claude, codex, deepseek, kimi claude, codex, deepseek, kimi
decisions before a tool as Claude-compatible JSON at exit 0 - {"hookSpecificOutput": decisions before a tool as Claude-compatible JSON at exit 0 - {"hookSpecificOutput":
{"permissionDecision": "deny", ...}} - because a host that runs the command through PowerShell {"permissionDecision": "deny", ...}} - because a host that runs the command through PowerShell
turns exit code 2 into 1 turns exit code 2 into 1
claude, codex, deepseek a stop's keep-going as {"decision": "block", "reason": ...} at exit 0 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 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 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 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) 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. Hooks fail open: if Harbor itself is broken they print a warning to stderr and let the agent continue.
""" """
import json import json
import os import os
import re import re
import sys import sys
from . import projects from . import projects
from .core import Harbor from .core import Harbor
BLOCK = 2 BLOCK = 2
JSON_CONTEXT_HOSTS = ("claude", "codex", "deepseek") JSON_CONTEXT_HOSTS = ("claude", "codex", "deepseek")
JSON_DECISION_HOSTS = ("claude", "codex", "deepseek", "kimi") JSON_DECISION_HOSTS = ("claude", "codex", "deepseek", "kimi")
EXIT_CODE_STOP_HOSTS = ("kimi",) # a stop's keep-going: exit 2 + stderr (see above) EXIT_CODE_STOP_HOSTS = ("kimi",) # a stop's keep-going: exit 2 + stderr (see above)
EVENT_NAMES = {"session-start": "SessionStart", "prompt-submit": "UserPromptSubmit"} EVENT_NAMES = {"session-start": "SessionStart", "prompt-submit": "UserPromptSubmit"}
def _payload(): def _payload():
try: try:
if sys.stdin is None or sys.stdin.isatty(): if sys.stdin is None or sys.stdin.isatty():
return {} return {}
text = sys.stdin.read() text = sys.stdin.read()
data = json.loads(text) if text.strip() else {} data = json.loads(text) if text.strip() else {}
return data if isinstance(data, dict) else {} return data if isinstance(data, dict) else {}
except (ValueError, OSError): except (ValueError, OSError):
return {} return {}
# While stopped, an agent may still talk and read through Harbor - and nothing else. # 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", ALLOWED_COMMANDS = {"brief", "inbox", "ack", "thread", "threads", "search", "tail", "status", "whoami", "queue", "claims",
"tasks", "pins", "decisions", "handoffs", "handoff", "item", "pending-acks", "wait", "doing", "send", "tasks", "pins", "decisions", "handoffs", "handoff", "item", "pending-acks", "wait", "doing", "send",
"reply", "answer", "questions", "context", "notes", "note", "digest", "attention", "stop", "who", "reply", "answer", "questions", "context", "notes", "note", "digest", "attention", "stop", "who",
"feed", "summarize", "git", "templates", "pulse", "metrics", "wins"} "feed", "summarize", "git", "templates", "pulse", "metrics", "wins"}
ALLOWED_TOOLS = {"brief", "i_am", "mark_read", "inbox", "ack", "thread", "threads", "search", "status", "queue", "claims", "tasks", "handoff", 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", "send", "reply", "wait_for_messages", "doing", "questions", "answer", "context", "notes", "note",
"digest", "pin", "close_thread", "stop", "summarize", "who_knows", "git_status"} "digest", "pin", "close_thread", "stop", "summarize", "who_knows", "git_status"}
TOOL_NAME = re.compile(r"^(?:mcp__harbor__|harbor[_.])([a-z_]+)$") 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, # 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. # and a stopped agent needs it to reach Harbor's MCP tools at all.
LOADER_TOOLS = {"select_tools", "toolsearch", "tool_search", "search_tools"} LOADER_TOOLS = {"select_tools", "toolsearch", "tool_search", "search_tools"}
SHELL_FLAGS = {"bash": {"-c", "-lc"}, "sh": {"-c"}, "zsh": {"-c"}, "cmd": {"/c", "/d"}, "powershell": {"-command", "-c"}, SHELL_FLAGS = {"bash": {"-c", "-lc"}, "sh": {"-c"}, "zsh": {"-c"}, "cmd": {"/c", "/d"}, "powershell": {"-command", "-c"},
"pwsh": {"-command", "-c"}} "pwsh": {"-command", "-c"}}
CURLY_QUOTES = "“”„‟" # PowerShell reads these as double quotes; bash and cmd do not CURLY_QUOTES = "“”„‟" # PowerShell reads these as double quotes; bash and cmd do not
FORBIDDEN = set("\n\r&|;<>`$%^(){}@" + CURLY_QUOTES) FORBIDDEN = set("\n\r&|;<>`$%^(){}@" + CURLY_QUOTES)
IN_QUOTES = set("\n\r`$%" + CURLY_QUOTES) # what bash, PowerShell or cmd still act on inside double quotes IN_QUOTES = set("\n\r`$%" + CURLY_QUOTES) # what bash, PowerShell or cmd still act on inside double quotes
TOKEN = re.compile(r'"[^"]*"|\'[^\']*\'|\S+') TOKEN = re.compile(r'"[^"]*"|\'[^\']*\'|\S+')
def _norm(path): def _norm(path):
return str(path).strip().strip('"').strip("'").replace("\\", "/").lower() return str(path).strip().strip('"').strip("'").replace("\\", "/").lower()
def _scripts(): def _scripts():
from .projects import PACKAGE_ROOT from .projects import PACKAGE_ROOT
return {_norm(PACKAGE_ROOT / "run.py"), _norm(PACKAGE_ROOT.parent / "harbor.py")} return {_norm(PACKAGE_ROOT / "run.py"), _norm(PACKAGE_ROOT.parent / "harbor.py")}
def _command_text(tool_input): def _command_text(tool_input):
"""The command a shell tool will run. Shell wrappers are unwrapped only in the exact form """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).""" [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 command = tool_input.get("command") if isinstance(tool_input, dict) else None
if isinstance(command, list): if isinstance(command, list):
parts = [str(x) for x in command] parts = [str(x) for x in command]
shell = parts[0].replace("\\", "/").split("/")[-1].lower().removesuffix(".exe") if parts else "" 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, ()): if len(parts) == 3 and parts[1].lower() in SHELL_FLAGS.get(shell, ()):
return parts[2] return parts[2]
return " ".join(parts) return " ".join(parts)
return command if isinstance(command, str) else None return command if isinstance(command, str) else None
def _harbor_tool(name): 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, """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.""" harbor_send, harbor.send, mcp__harbor-open-seas__send, ...); the part before the tool must name Harbor."""
n = name.lower() n = name.lower()
m = TOOL_NAME.match(n) m = TOOL_NAME.match(n)
if m: if m:
return m.group(1) return m.group(1)
for sep in ("__", ".", ":", "/"): for sep in ("__", ".", ":", "/"):
if sep in n: if sep in n:
head, tail = n.rsplit(sep, 1) head, tail = n.rsplit(sep, 1)
if "harbor" in head and re.fullmatch(r"[a-z_]+", tail): if "harbor" in head and re.fullmatch(r"[a-z_]+", tail):
return tail.removeprefix("harbor_") return tail.removeprefix("harbor_")
return None return None
return None return None
def _unsafe(command): def _unsafe(command):
"""True if a character any shell acts on appears where a shell would act on it. Inside double quotes bash, """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)"; $, `, % 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 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 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.""" 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) i, n = 0, len(command)
while i < n: while i < n:
ch = command[i] ch = command[i]
if ch in "\"'": if ch in "\"'":
j = command.find(ch, i + 1) j = command.find(ch, i + 1)
if j < 0: if j < 0:
return True return True
part = command[i + 1:j] part = command[i + 1:j]
if ch == '"' and (IN_QUOTES & set(part) or part.endswith("\\")): if ch == '"' and (IN_QUOTES & set(part) or part.endswith("\\")):
return True return True
if ch == "'" and ('"' in part or FORBIDDEN & set(part)): if ch == "'" and ('"' in part or FORBIDDEN & set(part)):
return True return True
i = j + 1 i = j + 1
continue continue
if ch in FORBIDDEN or (ch == "\\" and command[i + 1:i + 2] in ('"', "'")): if ch in FORBIDDEN or (ch == "\\" and command[i + 1:i + 2] in ('"', "'")):
return True return True
i += 1 i += 1
return False return False
def _tool_is_harbor(payload): 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, """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 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).""" 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() name = str(payload.get("tool_name") or payload.get("tool") or payload.get("name") or "").strip()
if name.lower() in LOADER_TOOLS: if name.lower() in LOADER_TOOLS:
return True return True
tool = _harbor_tool(name) tool = _harbor_tool(name)
if tool is not None: if tool is not None:
return tool in ALLOWED_TOOLS return tool in ALLOWED_TOOLS
tool_input = payload.get("tool_input") or payload.get("input") or payload.get("args") or {} tool_input = payload.get("tool_input") or payload.get("input") or payload.get("args") or {}
command = _command_text(tool_input) command = _command_text(tool_input)
if not command or _unsafe(command): if not command or _unsafe(command):
return False return False
tokens = TOKEN.findall(command.strip()) tokens = TOKEN.findall(command.strip())
if not tokens: if not tokens:
return False return False
first = _norm(tokens[0]).split("/")[-1] first = _norm(tokens[0]).split("/")[-1]
if first in ("harbor", "harbor.exe"): if first in ("harbor", "harbor.exe"):
rest = tokens[1:] rest = tokens[1:]
elif first in ("py", "py.exe", "python", "python.exe", "python3", "python3.exe"): elif first in ("py", "py.exe", "python", "python.exe", "python3", "python3.exe"):
i = 1 i = 1
while i < len(tokens) and re.fullmatch(r"-(?:3(?:\.\d+)?|B|u|E|s|I)", tokens[i]): while i < len(tokens) and re.fullmatch(r"-(?:3(?:\.\d+)?|B|u|E|s|I)", tokens[i]):
i += 1 i += 1
if i >= len(tokens) or _norm(tokens[i]) not in _scripts(): if i >= len(tokens) or _norm(tokens[i]) not in _scripts():
return False return False
rest = tokens[i + 1:] rest = tokens[i + 1:]
else: else:
return False return False
while rest and rest[0] in ("--as", "--project", "--json"): while rest and rest[0] in ("--as", "--project", "--json"):
rest = rest[2:] if rest[0] != "--json" else rest[1:] rest = rest[2:] if rest[0] != "--json" else rest[1:]
return bool(rest) and rest[0].lower() in ALLOWED_COMMANDS return bool(rest) and rest[0].lower() in ALLOWED_COMMANDS
def _deny_tool(host, text): def _deny_tool(host, text):
if host in JSON_DECISION_HOSTS: if host in JSON_DECISION_HOSTS:
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny",
"permissionDecisionReason": text}})) "permissionDecisionReason": text}}))
return 0 return 0
print(text, file=sys.stderr) print(text, file=sys.stderr)
return BLOCK return BLOCK
def _keep_going(host, text): def _keep_going(host, text):
if host in JSON_DECISION_HOSTS and host not in EXIT_CODE_STOP_HOSTS: if host in JSON_DECISION_HOSTS and host not in EXIT_CODE_STOP_HOSTS:
print(json.dumps({"decision": "block", "reason": text})) print(json.dumps({"decision": "block", "reason": text}))
return 0 return 0
print(text, file=sys.stderr) print(text, file=sys.stderr)
return BLOCK return BLOCK
def _context(host, event, text): def _context(host, event, text):
if host in JSON_CONTEXT_HOSTS: if host in JSON_CONTEXT_HOSTS:
print(json.dumps({"hookSpecificOutput": {"hookEventName": EVENT_NAMES[event], "additionalContext": text}})) print(json.dumps({"hookSpecificOutput": {"hookEventName": EVENT_NAMES[event], "additionalContext": text}}))
else: else:
print(text) print(text)
def ensure_server(project, every=60): 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 """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, 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.""" so the server outlives the hook (and the app) that started it."""
port, start = project.server.get("port"), project.server.get("start") port, start = project.server.get("port"), project.server.get("start")
if not port or not start: if not port or not start:
return return
stamp = project.db.parent / "server-check.stamp" stamp = project.db.parent / "server-check.stamp"
try: try:
import time import time
if stamp.exists() and time.time() - stamp.stat().st_mtime < every: if stamp.exists() and time.time() - stamp.stat().st_mtime < every:
return return
stamp.touch() stamp.touch()
import socket import socket
with socket.socket() as connection: with socket.socket() as connection:
connection.settimeout(0.3) connection.settimeout(0.3)
if connection.connect_ex(("127.0.0.1", int(port))) == 0: if connection.connect_ex(("127.0.0.1", int(port))) == 0:
return return
import subprocess import subprocess
flags = 0 flags = 0
if os.name == "nt": if os.name == "nt":
flags = (subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP flags = (subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP # hidden console, no windows
| subprocess.CREATE_BREAKAWAY_FROM_JOB | subprocess.CREATE_NO_WINDOW) | subprocess.CREATE_BREAKAWAY_FROM_JOB)
command = start if isinstance(start, list) else [start] command = start if isinstance(start, list) else [start]
try: try:
subprocess.Popen(command, creationflags=flags, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, subprocess.Popen(command, creationflags=flags, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, close_fds=True) stderr=subprocess.DEVNULL, close_fds=True)
except OSError: # the job may refuse breakaway: start it without except OSError: # the job may refuse breakaway: start it without
subprocess.Popen(command, creationflags=flags & ~getattr(subprocess, "CREATE_BREAKAWAY_FROM_JOB", 0), subprocess.Popen(command, creationflags=flags & ~getattr(subprocess, "CREATE_BREAKAWAY_FROM_JOB", 0),
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
close_fds=True) close_fds=True)
print(f"harbor: the web server on port {port} was down; restarting it", file=sys.stderr) 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 except Exception as error: # never break the host because of this
print(f"harbor: server check failed: {error}", file=sys.stderr) print(f"harbor: server check failed: {error}", file=sys.stderr)
def run(event, project_spec, agent, host="generic"): def run(event, project_spec, agent, host="generic"):
payload = _payload() payload = _payload()
try: try:
cwd = payload.get("cwd") or payload.get("directory") or os.getcwd() cwd = payload.get("cwd") or payload.get("directory") or os.getcwd()
if isinstance(project_spec, projects.Project): if isinstance(project_spec, projects.Project):
project = project_spec project = project_spec
else: else:
try: try:
project = projects.resolve(project_spec, cwd=cwd, use_default=False) project = projects.resolve(project_spec, cwd=cwd, use_default=False)
except projects.ProjectError: except projects.ProjectError:
if project_spec or os.environ.get("HARBOR_PROJECT"): if project_spec or os.environ.get("HARBOR_PROJECT"):
raise raise
return 0 # not a Harbor project: stay out of the way 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 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: if not agent or agent not in project.ai_names:
return 0 return 0
ensure_server(project) ensure_server(project)
harbor = Harbor(project, busy_ms=1500, write_attempts=2) harbor = Harbor(project, busy_ms=1500, write_attempts=2)
try: try:
return _run(event, harbor, agent, host, payload) return _run(event, harbor, agent, host, payload)
finally: finally:
harbor.close() harbor.close()
except Exception as error: # never break the host because of Harbor except Exception as error: # never break the host because of Harbor
print(f"harbor hook {event}: {error}", file=sys.stderr) print(f"harbor hook {event}: {error}", file=sys.stderr)
return 0 return 0
def _stop_text(harbor, agent, stops): def _stop_text(harbor, agent, stops):
f = stops[0] f = stops[0]
return (f"STOP in force for {agent} ({f['key']}, set by {f['set_by']} at {f['set_at']}" 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} " + (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, " "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. " "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 %.") "Put message text in double quotes, without $, backticks or %.")
HANDOFF_EVERY_MINUTES = 240 HANDOFF_EVERY_MINUTES = 240
def handoff_reminder(harbor, agent): def handoff_reminder(harbor, agent):
"""Once every 4 hours of real work without a handoff, ask for one at the end of a turn.""" """Once every 4 hours of real work without a handoff, ask for one at the end of a turn."""
last = harbor.last_handoff(agent) last = harbor.last_handoff(agent)
if last and harbor.minutes_since(last["created_at"]) < HANDOFF_EVERY_MINUTES: if last and harbor.minutes_since(last["created_at"]) < HANDOFF_EVERY_MINUTES:
return None return None
since = harbor.pulse(hours=HANDOFF_EVERY_MINUTES / 60)["agents"].get(agent, []) since = harbor.pulse(hours=HANDOFF_EVERY_MINUTES / 60)["agents"].get(agent, [])
if sum(b["tools"] for b in since) < 30: if sum(b["tools"] for b in since) < 30:
return None return None
key = f"handoff_nudge:{agent}" key = f"handoff_nudge:{agent}"
row = harbor.conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone() row = harbor.conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
if row and harbor.minutes_since(row["value"]) < HANDOFF_EVERY_MINUTES: if row and harbor.minutes_since(row["value"]) < HANDOFF_EVERY_MINUTES:
return None return None
harbor._write(lambda c: c.execute("INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET " harbor._write(lambda c: c.execute("INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET "
"value=excluded.value", (key, harbor.now()))) "value=excluded.value", (key, harbor.now())))
age = f"{harbor.minutes_since(last['created_at']) // 60} h old" if last else "missing" 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 " 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.") "finished, what is in flight with branch and files, what comes next), then finish.")
def _run(event, harbor, agent, host, payload): def _run(event, harbor, agent, host, payload):
stops = harbor.stopped(agent) stops = harbor.stopped(agent)
if event == "session-start": if event == "session-start":
harbor.connect(agent, "hook", host) harbor.connect(agent, "hook", host)
text = harbor.brief(agent) text = harbor.brief(agent)
text += ("\n\nThis folder is part of the Harbor project above. Use Harbor for all team communication " 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 " "(threads, ack, claims, review queue); set `doing` when you start something; call brief again "
"after long work.") "after long work.")
_context(host, event, text) _context(host, event, text)
return 0 return 0
if event == "pre-tool": if event == "pre-tool":
tool = payload.get("tool_name") or payload.get("tool") or payload.get("name") or "tool" tool = payload.get("tool_name") or payload.get("tool") or payload.get("name") or "tool"
result = 0 result = 0
if stops and not _tool_is_harbor(payload): if stops and not _tool_is_harbor(payload):
result = _deny_tool(host, _stop_text(harbor, agent, stops)) # decide from reads, before any write result = _deny_tool(host, _stop_text(harbor, agent, stops)) # decide from reads, before any write
sys.stdout.flush() sys.stdout.flush()
try: # bookkeeping is best effort try: # bookkeeping is best effort
from .launch import describe_target from .launch import describe_target
tool_input = payload.get("tool_input") or payload.get("input") or {} tool_input = payload.get("tool_input") or payload.get("input") or {}
harbor_tool = _harbor_tool(str(tool)) harbor_tool = _harbor_tool(str(tool))
if harbor_tool is not None: # tell the app's shared Harbor server which session is calling if harbor_tool is not None: # tell the app's shared Harbor server which session is calling
from . import sessions from . import sessions
sessions.leave(harbor, host, harbor_tool, tool_input if isinstance(tool_input, dict) else {}, agent) sessions.leave(harbor, host, harbor_tool, tool_input if isinstance(tool_input, dict) else {}, agent)
paths = harbor.edited_paths(tool, tool_input) paths = harbor.edited_paths(tool, tool_input)
harbor.activity(agent, tool, host, target=describe_target(tool, tool_input, paths)) harbor.activity(agent, tool, host, target=describe_target(tool, tool_input, paths))
if not stops: if not stops:
harbor.record_touches(agent, paths, tool, cwd=payload.get("cwd")) harbor.record_touches(agent, paths, tool, cwd=payload.get("cwd"))
except Exception as error: # noqa: BLE001 except Exception as error: # noqa: BLE001
print(f"harbor hook: {error}", file=sys.stderr) print(f"harbor hook: {error}", file=sys.stderr)
return result return result
if event == "prompt-submit": if event == "prompt-submit":
total, urgent = harbor.unread_count(agent) total, urgent = harbor.unread_count(agent)
owed = harbor.conn.execute("SELECT COUNT(*) FROM deliveries d JOIN messages m ON m.id=d.message_id " 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] "WHERE d.agent=? AND m.ack_required=1 AND d.acked_at IS NULL", (agent,)).fetchone()[0]
if host == "kimi": if host == "kimi":
if total or stops or owed: if total or stops or owed:
print(harbor.brief(agent)) print(harbor.brief(agent))
else: else:
harbor.connect(agent, "hook", host) harbor.connect(agent, "hook", host)
return 0 return 0
harbor.connect(agent, "hook", host) harbor.connect(agent, "hook", host)
notes = [] notes = []
if stops: if stops:
notes.append(_stop_text(harbor, agent, stops)) notes.append(_stop_text(harbor, agent, stops))
if total: if total:
notes.append(f"Harbor: {total} unread message(s) for you" + (f", {urgent} urgent" if urgent else "") notes.append(f"Harbor: {total} unread message(s) for you" + (f", {urgent} urgent" if urgent else "")
+ ". Read them (Harbor inbox) before continuing.") + ". Read them (Harbor inbox) before continuing.")
if notes: if notes:
_context(host, event, " ".join(notes)) _context(host, event, " ".join(notes))
return 0 return 0
if event == "stop": if event == "stop":
harbor.connect(agent, "hook", host) harbor.connect(agent, "hook", host)
if stops: if stops:
return 0 # the human's stop: always let it rest return 0 # the human's stop: always let it rest
if not payload.get("stop_hook_active"): if not payload.get("stop_hook_active"):
needing = harbor.nudge_candidates(agent, urgent_only=True) needing = harbor.nudge_candidates(agent, urgent_only=True)
if needing: if needing:
harbor.record_nudge(agent, [m["id"] for m in needing]) harbor.record_nudge(agent, [m["id"] for m in needing])
ids = ", ".join(f"#{m['id']} from {m['sender']}" for m in needing[:8]) 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}). " 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.") "Read them with the Harbor inbox, answer or acknowledge them, then go on.")
reminder = handoff_reminder(harbor, agent) reminder = handoff_reminder(harbor, agent)
if reminder: if reminder:
return _keep_going(host, reminder) return _keep_going(host, reminder)
text = harbor.continuation(agent) # autopilot; its own loop guard rests a stuck agent text = harbor.continuation(agent) # autopilot; its own loop guard rests a stuck agent
return _keep_going(host, text) if text else 0 return _keep_going(host, text) if text else 0
if event == "idle": if event == "idle":
total, urgent = harbor.unread_count(agent) total, urgent = harbor.unread_count(agent)
prompt = None prompt = None
if not stops: if not stops:
fresh = harbor.nudge_candidates(agent) fresh = harbor.nudge_candidates(agent)
if fresh: if fresh:
harbor.record_nudge(agent, [m["id"] for m in 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 "") 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.") + ". Read them with the Harbor inbox tool and act on anything addressed to you.")
else: else:
prompt = harbor.continuation(agent) # autopilot for hosts that wake idle sessions 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], print(json.dumps({"agent": agent, "unread": total, "urgent": urgent, "stopped": [f["key"] for f in stops],
"prompt": prompt})) "prompt": prompt}))
return 0 return 0
if event == "turn-end": if event == "turn-end":
harbor.connect(agent, "hook", host) harbor.connect(agent, "hook", host)
return 0 return 0
print(f"harbor hook: unknown event {event!r}", file=sys.stderr) print(f"harbor hook: unknown event {event!r}", file=sys.stderr)
return 0 return 0