diff --git a/harbor/hooks.py b/harbor/hooks.py index b1f2c8d..a6b7c6e 100644 --- a/harbor/hooks.py +++ b/harbor/hooks.py @@ -21,9 +21,13 @@ 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 + 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. @@ -39,6 +43,7 @@ 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"} @@ -181,7 +186,7 @@ def _deny_tool(host, text): def _keep_going(host, text): - if host in JSON_DECISION_HOSTS: + 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) diff --git a/tests/test_review3.py b/tests/test_review3.py index 8c05a44..2702af5 100644 --- a/tests/test_review3.py +++ b/tests/test_review3.py @@ -1,4 +1,5 @@ """Regression tests for the correctness review of rounds five and six.""" +import json import os import re import shutil @@ -358,6 +359,21 @@ class AttentionWithoutFalseAlarms(HarborCase): self.assertFalse(any(a["kind"] == "ack_overdue" and f"#{m['id']}" in a["text"] for a in self.h.attention())) +class KimiStopKeepGoing(unittest.TestCase): + def test_kimi_gets_exit_2_and_stderr_others_get_json(self): + import contextlib, io + from harbor import hooks + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = hooks._keep_going("kimi", "keep going") + self.assertEqual((code, out.getvalue(), err.getvalue().strip()), (2, "", "keep going")) + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = hooks._keep_going("codex", "keep going") + self.assertEqual(code, 0) + self.assertEqual(json.loads(out.getvalue())["decision"], "block") + + class HooksVisible(HarborCase): def test_mcp_without_hooks_needs_attention(self): self.h.connect("Codex", "mcp", "codex")