subcortex 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- subcortex/__init__.py +3 -0
- subcortex/__main__.py +3 -0
- subcortex/adapters/__init__.py +48 -0
- subcortex/adapters/base.py +230 -0
- subcortex/adapters/claude_family.py +133 -0
- subcortex/adapters/codex.py +87 -0
- subcortex/adapters/copilot.py +60 -0
- subcortex/adapters/cursor.py +36 -0
- subcortex/adapters/docker_agent.py +115 -0
- subcortex/adapters/gemini_family.py +60 -0
- subcortex/adapters/grok.py +98 -0
- subcortex/adapters/kimi_code.py +138 -0
- subcortex/adapters/letta_vibe.py +96 -0
- subcortex/adapters/openhands.py +153 -0
- subcortex/auth.py +59 -0
- subcortex/backends/__init__.py +23 -0
- subcortex/backends/base.py +22 -0
- subcortex/backends/jev.py +460 -0
- subcortex/backends/laya.py +149 -0
- subcortex/cli.py +809 -0
- subcortex/client.py +77 -0
- subcortex/config.py +263 -0
- subcortex/daemon.py +502 -0
- subcortex/evalset.py +241 -0
- subcortex/hook.py +254 -0
- subcortex/installers/__init__.py +62 -0
- subcortex/installers/amp.py +39 -0
- subcortex/installers/base.py +874 -0
- subcortex/installers/claude_family.py +229 -0
- subcortex/installers/codex.py +110 -0
- subcortex/installers/copilot.py +65 -0
- subcortex/installers/crush.py +36 -0
- subcortex/installers/cursor.py +79 -0
- subcortex/installers/gemini_family.py +83 -0
- subcortex/installers/goose.py +186 -0
- subcortex/installers/kimi_code.py +71 -0
- subcortex/installers/mcp_only.py +111 -0
- subcortex/installers/more_hooks.py +184 -0
- subcortex/installers/opencode.py +66 -0
- subcortex/installers/openhands.py +84 -0
- subcortex/installers/pi_cline.py +53 -0
- subcortex/ledger.py +92 -0
- subcortex/localhttp.py +59 -0
- subcortex/mcp_server.py +187 -0
- subcortex/metrics.py +56 -0
- subcortex/plugins/amp/subcortex.ts +258 -0
- subcortex/plugins/cline/subcortex.ts +340 -0
- subcortex/plugins/opencode/subcortex.ts +265 -0
- subcortex/plugins/pi/subcortex.ts +292 -0
- subcortex/policy.py +341 -0
- subcortex/presets.py +163 -0
- subcortex/provision.py +188 -0
- subcortex/service.py +149 -0
- subcortex/state.py +137 -0
- subcortex/transcript.py +211 -0
- subcortex/tuis.py +51 -0
- subcortex/ui.py +319 -0
- subcortex/verdicts.py +233 -0
- subcortex/wizard.py +474 -0
- subcortex-0.3.0.dist-info/METADATA +287 -0
- subcortex-0.3.0.dist-info/RECORD +64 -0
- subcortex-0.3.0.dist-info/WHEEL +5 -0
- subcortex-0.3.0.dist-info/entry_points.txt +3 -0
- subcortex-0.3.0.dist-info/top_level.txt +1 -0
subcortex/__init__.py
ADDED
subcortex/__main__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Registry of command-hook adapters (lazy: a hook process imports only its own)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from typing import Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from ..tuis import canonical
|
|
9
|
+
from .base import HookAdapter
|
|
10
|
+
|
|
11
|
+
# canonical name -> "module:Class" (relative to this package)
|
|
12
|
+
_ADAPTERS: Dict[str, str] = {
|
|
13
|
+
"claude-code": "claude_family:ClaudeCodeAdapter",
|
|
14
|
+
"qoder": "claude_family:QoderAdapter",
|
|
15
|
+
"codebuddy": "claude_family:CodeBuddyAdapter",
|
|
16
|
+
"droid": "claude_family:DroidAdapter",
|
|
17
|
+
"junie": "claude_family:JunieAdapter",
|
|
18
|
+
"devin": "claude_family:DevinAdapter",
|
|
19
|
+
"codex": "codex:CodexAdapter",
|
|
20
|
+
"open-interpreter": "codex:OpenInterpreterAdapter",
|
|
21
|
+
"gemini-cli": "gemini_family:GeminiCliAdapter",
|
|
22
|
+
"qwen-code": "gemini_family:QwenCodeAdapter",
|
|
23
|
+
"cursor": "cursor:CursorAdapter",
|
|
24
|
+
"copilot": "copilot:CopilotAdapter",
|
|
25
|
+
"kimi-code": "kimi_code:KimiCodeAdapter",
|
|
26
|
+
"openhands": "openhands:OpenHandsAdapter",
|
|
27
|
+
"grok-build": "grok:GrokBuildAdapter",
|
|
28
|
+
"docker-agent": "docker_agent:DockerAgentAdapter",
|
|
29
|
+
"letta": "letta_vibe:LettaAdapter",
|
|
30
|
+
"vibe": "letta_vibe:VibeAdapter",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def canonical_name(name: str) -> Optional[str]:
|
|
35
|
+
return canonical(name, _ADAPTERS)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_adapter(name: str) -> Optional[HookAdapter]:
|
|
39
|
+
key = canonical_name(name)
|
|
40
|
+
if key is None:
|
|
41
|
+
return None
|
|
42
|
+
module_name, cls_name = _ADAPTERS[key].split(":")
|
|
43
|
+
module = importlib.import_module(f".{module_name}", __name__)
|
|
44
|
+
return getattr(module, cls_name)()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def names() -> List[str]:
|
|
48
|
+
return sorted(_ADAPTERS)
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Base class for command-hook adapters.
|
|
2
|
+
|
|
3
|
+
An adapter is pure translation: it maps a TUI's hook event names onto the four
|
|
4
|
+
canonical kinds, parses the TUI's stdin payload into a ``HookEvent``, and
|
|
5
|
+
renders policy results into the TUI's stdout response. All decisions live in
|
|
6
|
+
``subcortex.policy``; process safety lives in ``subcortex.hook``.
|
|
7
|
+
|
|
8
|
+
The default ``parse`` understands the Claude-Code-style payload that many TUIs
|
|
9
|
+
copied (``session_id``, ``prompt``, ``tool_name``, ``tool_input``,
|
|
10
|
+
``tool_response``, ``transcript_path``, ``source``, ``trigger``); adapters
|
|
11
|
+
override only what differs.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
18
|
+
|
|
19
|
+
PROMPT = "prompt"
|
|
20
|
+
TOOL_OUTPUT = "tool_output"
|
|
21
|
+
PRE_COMPACT = "pre_compact"
|
|
22
|
+
POST_COMPACT = "post_compact"
|
|
23
|
+
SESSION_START = "session_start"
|
|
24
|
+
KINDS = (PROMPT, TOOL_OUTPUT, PRE_COMPACT, POST_COMPACT, SESSION_START)
|
|
25
|
+
|
|
26
|
+
Response = Union[Dict[str, Any], str, None]
|
|
27
|
+
|
|
28
|
+
# Values that would block, deny, or stop an agent in at least one TUI. A
|
|
29
|
+
# response containing any of them is dropped by ``guard`` unless the adapter
|
|
30
|
+
# explicitly allows that exact (key, value) for that kind.
|
|
31
|
+
BLOCKING_VALUES: Tuple[Tuple[str, Any], ...] = (
|
|
32
|
+
("continue", False),
|
|
33
|
+
("decision", "block"),
|
|
34
|
+
("decision", "deny"),
|
|
35
|
+
("decision", "ask"),
|
|
36
|
+
("permission", "deny"),
|
|
37
|
+
("permission", "ask"),
|
|
38
|
+
("permissionDecision", "deny"),
|
|
39
|
+
("permissionDecision", "ask"),
|
|
40
|
+
("behavior", "deny"),
|
|
41
|
+
("block", True),
|
|
42
|
+
("abort", True),
|
|
43
|
+
("cancel", True),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class HookEvent:
|
|
48
|
+
"""One hook event, normalized. A plain class on purpose: importing
|
|
49
|
+
``dataclasses`` costs ~3 ms in every (cold-started) hook process."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, kind: str, name: str, payload: Dict[str, Any], session_id: str = "",
|
|
52
|
+
prompt: str = "", tool: str = "", tool_input: Any = None,
|
|
53
|
+
output: Optional[str] = None, failed: bool = False, transcript_path: str = "",
|
|
54
|
+
source: str = "", trigger: str = "",
|
|
55
|
+
messages: Optional[List[Dict[str, str]]] = None,
|
|
56
|
+
extra: Optional[Dict[str, Any]] = None) -> None:
|
|
57
|
+
self.kind = kind
|
|
58
|
+
self.name = name
|
|
59
|
+
self.payload = payload
|
|
60
|
+
self.session_id = session_id
|
|
61
|
+
self.prompt = prompt
|
|
62
|
+
self.tool = tool
|
|
63
|
+
self.tool_input = tool_input
|
|
64
|
+
self.output = output
|
|
65
|
+
self.failed = failed
|
|
66
|
+
self.transcript_path = transcript_path
|
|
67
|
+
self.source = source
|
|
68
|
+
self.trigger = trigger
|
|
69
|
+
self.messages = messages
|
|
70
|
+
self.extra = {} if extra is None else extra
|
|
71
|
+
# Set by the runner: commit callables to run once the response is delivered.
|
|
72
|
+
self.commits: Optional[List[Any]] = None
|
|
73
|
+
|
|
74
|
+
def __eq__(self, other: Any) -> bool:
|
|
75
|
+
return isinstance(other, HookEvent) and vars(self) == vars(other)
|
|
76
|
+
|
|
77
|
+
def __repr__(self) -> str:
|
|
78
|
+
return f"HookEvent({vars(self)!r})"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def text_of(value: Any) -> Optional[str]:
|
|
82
|
+
"""Flatten common tool-result shapes (str, content blocks, dicts) to text."""
|
|
83
|
+
if isinstance(value, str):
|
|
84
|
+
return value
|
|
85
|
+
if isinstance(value, list):
|
|
86
|
+
parts = [b.get("text") for b in value if isinstance(b, dict) and isinstance(b.get("text"), str)]
|
|
87
|
+
parts += [b for b in value if isinstance(b, str)]
|
|
88
|
+
return "\n".join(parts) if parts else None
|
|
89
|
+
if isinstance(value, dict):
|
|
90
|
+
for key in ("stdout", "output", "llmContent", "content", "text", "result", "returnDisplay"):
|
|
91
|
+
if key in value:
|
|
92
|
+
text = text_of(value[key])
|
|
93
|
+
if text:
|
|
94
|
+
return text
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def failed_of(value: Any) -> bool:
|
|
99
|
+
"""True when a tool-result dict signals failure (non-zero exit, error flag, interrupt)."""
|
|
100
|
+
if not isinstance(value, dict):
|
|
101
|
+
return False
|
|
102
|
+
if value.get("interrupted") or value.get("is_error") or value.get("isError"):
|
|
103
|
+
return True
|
|
104
|
+
if value.get("error") not in (None, "", False):
|
|
105
|
+
return True
|
|
106
|
+
for key in ("exit_code", "exitCode", "returncode", "exit_status"):
|
|
107
|
+
code = value.get(key)
|
|
108
|
+
if isinstance(code, int) and code != 0:
|
|
109
|
+
return True
|
|
110
|
+
stderr = value.get("stderr")
|
|
111
|
+
return isinstance(stderr, str) and "traceback" in stderr.lower()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class HookAdapter:
|
|
115
|
+
name: str = ""
|
|
116
|
+
display_name: str = ""
|
|
117
|
+
aliases: Tuple[str, ...] = ()
|
|
118
|
+
# TUI event name -> canonical kind. Lookup is case-insensitive.
|
|
119
|
+
events: Dict[str, str] = {}
|
|
120
|
+
# Payload key carrying the event name, used when argv omits it.
|
|
121
|
+
event_field: str = "hook_event_name"
|
|
122
|
+
# SessionStart ``source`` values meaning "right after compaction".
|
|
123
|
+
compact_sources: Tuple[str, ...] = ("compact",)
|
|
124
|
+
# (kind, key, value) triples this adapter may emit despite BLOCKING_VALUES.
|
|
125
|
+
allowed_blocking: Tuple[Tuple[str, str, Any], ...] = ()
|
|
126
|
+
# Deliver the compaction snapshot with the first prompt after compaction
|
|
127
|
+
# (for TUIs whose session-start/post-compact hooks can't inject context).
|
|
128
|
+
restore_on_prompt: bool = False
|
|
129
|
+
# False when the TUI discards prompt-hook output: the hint isn't computed.
|
|
130
|
+
delivers_hints: bool = True
|
|
131
|
+
|
|
132
|
+
# -- event resolution -----------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def resolve(self, name: Any) -> Optional[Tuple[str, str]]:
|
|
135
|
+
"""(TUI event name, kind) for ``name``, or None if we don't handle it."""
|
|
136
|
+
if not isinstance(name, str) or not name.strip():
|
|
137
|
+
return None
|
|
138
|
+
wanted = name.strip().lower().replace("_", "").replace("-", "")
|
|
139
|
+
for event, kind in self.events.items():
|
|
140
|
+
if event.lower().replace("_", "").replace("-", "") == wanted:
|
|
141
|
+
return event, kind
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
# -- parsing ----------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def parse(self, name: str, kind: str, payload: Dict[str, Any]) -> Optional[HookEvent]:
|
|
147
|
+
event = HookEvent(
|
|
148
|
+
kind=kind,
|
|
149
|
+
name=name,
|
|
150
|
+
payload=payload,
|
|
151
|
+
session_id=str(payload.get("session_id") or payload.get("sessionId") or ""),
|
|
152
|
+
transcript_path=str(payload.get("transcript_path") or payload.get("transcriptPath") or ""),
|
|
153
|
+
source=str(payload.get("source") or ""),
|
|
154
|
+
trigger=str(payload.get("trigger") or ""),
|
|
155
|
+
)
|
|
156
|
+
if kind == PROMPT:
|
|
157
|
+
prompt = payload.get("prompt")
|
|
158
|
+
event.prompt = prompt if isinstance(prompt, str) else ""
|
|
159
|
+
elif kind == TOOL_OUTPUT:
|
|
160
|
+
response = payload.get("tool_response", payload.get("tool_output"))
|
|
161
|
+
event.tool = str(payload.get("tool_name") or "")
|
|
162
|
+
event.tool_input = payload.get("tool_input")
|
|
163
|
+
event.output = text_of(response)
|
|
164
|
+
event.failed = failed_of(response)
|
|
165
|
+
return event
|
|
166
|
+
|
|
167
|
+
def is_after_compaction(self, event: HookEvent) -> bool:
|
|
168
|
+
return event.source.lower() in self.compact_sources
|
|
169
|
+
|
|
170
|
+
def prompt_context(self, event: HookEvent, cfg: Dict[str, Any]) -> Optional[str]:
|
|
171
|
+
"""Context to deliver alongside a prompt (before any hint). By default:
|
|
172
|
+
the compaction snapshot, for adapters that restore on the next prompt."""
|
|
173
|
+
if not self.restore_on_prompt:
|
|
174
|
+
return None
|
|
175
|
+
from .. import policy
|
|
176
|
+
|
|
177
|
+
return policy.restore_snapshot(event.session_id, cfg, require_ready=True, tui=self.name,
|
|
178
|
+
commits=event.commits)
|
|
179
|
+
|
|
180
|
+
def output_context(self, event: HookEvent, cfg: Dict[str, Any]) -> Optional[str]:
|
|
181
|
+
"""Context to deliver alongside a tool result (after the trim decision).
|
|
182
|
+
None by default; for TUIs whose only model-visible channel is a tool result."""
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
# -- rendering (None = no output = pass-through) ---------------------------------
|
|
186
|
+
|
|
187
|
+
def render_prompt(self, event: HookEvent, text: str) -> Response:
|
|
188
|
+
"""``text`` is the hint, possibly preceded by restored compaction context."""
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
def render_tool_output(self, event: HookEvent, replacement: str) -> Response:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
def render_pre_compact(self, event: HookEvent) -> Response:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
def render_post_compact(self, event: HookEvent) -> Response:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
def render_session_start(self, event: HookEvent, context: str) -> Response:
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
# -- safety -------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
def guard(self, kind: str, response: Response) -> Response:
|
|
206
|
+
"""Drop any response that could block, deny, or stop the agent."""
|
|
207
|
+
if response is None or isinstance(response, str):
|
|
208
|
+
return response
|
|
209
|
+
if not isinstance(response, dict):
|
|
210
|
+
return None
|
|
211
|
+
for key, value in _walk(response):
|
|
212
|
+
for bad_key, bad_value in BLOCKING_VALUES:
|
|
213
|
+
if key == bad_key and value == bad_value \
|
|
214
|
+
and (kind, key, value) not in self.allowed_blocking:
|
|
215
|
+
return None
|
|
216
|
+
try:
|
|
217
|
+
json.dumps(response)
|
|
218
|
+
except (TypeError, ValueError):
|
|
219
|
+
return None
|
|
220
|
+
return response
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _walk(obj: Any):
|
|
224
|
+
if isinstance(obj, dict):
|
|
225
|
+
for key, value in obj.items():
|
|
226
|
+
yield key, value
|
|
227
|
+
yield from _walk(value)
|
|
228
|
+
elif isinstance(obj, list):
|
|
229
|
+
for item in obj:
|
|
230
|
+
yield from _walk(item)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Adapters for TUIs that copied Claude Code's hook contract.
|
|
2
|
+
|
|
3
|
+
Same event names and stdin fields (``session_id``, ``transcript_path``,
|
|
4
|
+
``prompt``, ``tool_name``/``tool_input``/``tool_response``, ``source``,
|
|
5
|
+
``trigger``) and the same response envelope
|
|
6
|
+
(``{"hookSpecificOutput": {"hookEventName": ..., ...}}``). What differs is
|
|
7
|
+
which events exist and whether ``updatedToolOutput`` replaces a tool result —
|
|
8
|
+
so each TUI declares that explicitly. Blocking semantics differ too, which is
|
|
9
|
+
why nothing here ever emits ``decision``/``continue``/``permissionDecision``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from typing import Any, Dict, Optional
|
|
16
|
+
|
|
17
|
+
from .base import PRE_COMPACT, PROMPT, SESSION_START, TOOL_OUTPUT, HookAdapter, HookEvent, Response
|
|
18
|
+
|
|
19
|
+
ALL_EVENTS = {
|
|
20
|
+
"UserPromptSubmit": PROMPT,
|
|
21
|
+
"PostToolUse": TOOL_OUTPUT,
|
|
22
|
+
"PreCompact": PRE_COMPACT,
|
|
23
|
+
"SessionStart": SESSION_START,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ClaudeStyleAdapter(HookAdapter):
|
|
28
|
+
events = ALL_EVENTS
|
|
29
|
+
# Does hookSpecificOutput.updatedToolOutput (a string) replace the result the model sees?
|
|
30
|
+
replaces_output = False
|
|
31
|
+
|
|
32
|
+
def _specific(self, event: HookEvent, **fields: Any) -> Dict[str, Any]:
|
|
33
|
+
# hookEventName is mandatory: some clones reject the whole object without it.
|
|
34
|
+
return {"hookSpecificOutput": {"hookEventName": event.name, **fields}}
|
|
35
|
+
|
|
36
|
+
def render_prompt(self, event: HookEvent, text: str) -> Response:
|
|
37
|
+
return self._specific(event, additionalContext=text)
|
|
38
|
+
|
|
39
|
+
def render_tool_output(self, event: HookEvent, replacement: str) -> Response:
|
|
40
|
+
if not self.replaces_output:
|
|
41
|
+
return None
|
|
42
|
+
return self._specific(event, updatedToolOutput=replacement)
|
|
43
|
+
|
|
44
|
+
def render_session_start(self, event: HookEvent, context: str) -> Response:
|
|
45
|
+
return self._specific(event, additionalContext=context)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ClaudeCodeAdapter(ClaudeStyleAdapter):
|
|
49
|
+
name = "claude-code"
|
|
50
|
+
display_name = "Claude Code"
|
|
51
|
+
# Since 2.1.121 updatedToolOutput replaces the output of any tool — but for
|
|
52
|
+
# built-in tools only in the tool's own shape (a string is silently ignored).
|
|
53
|
+
replaces_output = True
|
|
54
|
+
|
|
55
|
+
def render_tool_output(self, event: HookEvent, replacement: str) -> Response:
|
|
56
|
+
response = event.payload.get("tool_response")
|
|
57
|
+
if not isinstance(response, dict) or not isinstance(response.get("stdout"), str):
|
|
58
|
+
return None
|
|
59
|
+
# Already spilled to a file / background / image / interrupted: leave it be.
|
|
60
|
+
if any(response.get(k) for k in ("persistedOutputPath", "backgroundTaskId", "isImage", "interrupted")):
|
|
61
|
+
return None
|
|
62
|
+
return self._specific(event, updatedToolOutput={**response, "stdout": replacement})
|
|
63
|
+
|
|
64
|
+
def parse(self, name: str, kind: str, payload: Dict[str, Any]) -> Optional[HookEvent]:
|
|
65
|
+
# Grok Build, Devin CLI and Cortex Code also execute hooks from
|
|
66
|
+
# ~/.claude/settings.json, with their own payload shapes. Only answer
|
|
67
|
+
# payloads that look like Claude Code's own.
|
|
68
|
+
if not is_claude_code_payload(payload):
|
|
69
|
+
return None
|
|
70
|
+
return super().parse(name, kind, payload)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Set in hook processes by other TUIs that also execute ~/.claude/settings.json hooks.
|
|
74
|
+
# Copilot CLI also runs a trusted repo's .claude/settings.json hooks.
|
|
75
|
+
FOREIGN_HOST_ENV = ("CURSOR_VERSION", "DROID_PROJECT_DIR", "FACTORY_PROJECT_DIR", "GROK_HOOK_EVENT",
|
|
76
|
+
"COPILOT_CLI")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_claude_code_payload(payload: Dict[str, Any]) -> bool:
|
|
80
|
+
if any(os.environ.get(var) for var in FOREIGN_HOST_ENV) or "cursor_version" in payload:
|
|
81
|
+
return False # Cursor CLI / Factory Droid running Claude-format hooks
|
|
82
|
+
if any(k in payload for k in ("hookEventName", "sessionId", "workspaceRoot", "toolName")):
|
|
83
|
+
return False # camelCase: Grok Build
|
|
84
|
+
# NB: not `prompt_id` — Claude Code itself sends it on every event since 2.1.196.
|
|
85
|
+
if not isinstance(payload.get("transcript_path"), str) or not payload["transcript_path"]:
|
|
86
|
+
return False # Devin CLI (absent), Continue cn (empty)
|
|
87
|
+
if not isinstance(payload.get("session_id"), str) or not payload["session_id"]:
|
|
88
|
+
return False
|
|
89
|
+
tool = payload.get("tool_name")
|
|
90
|
+
return not (isinstance(tool, str) and tool == "bash") # Cortex Code's lowercase tools
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class QoderAdapter(ClaudeStyleAdapter):
|
|
94
|
+
name = "qoder"
|
|
95
|
+
display_name = "Qoder CLI"
|
|
96
|
+
replaces_output = True # "Replaces the tool response (works for any tool)"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CodeBuddyAdapter(ClaudeStyleAdapter):
|
|
100
|
+
name = "codebuddy"
|
|
101
|
+
display_name = "CodeBuddy Code"
|
|
102
|
+
# Its PostToolUse tool_response for Bash carries exit status and byte
|
|
103
|
+
# counts but no output (verified against 2.156.0): nothing to trim, so the
|
|
104
|
+
# event isn't registered (a Python start on every Bash call for nothing).
|
|
105
|
+
events = {k: v for k, v in ALL_EVENTS.items() if v != TOOL_OUTPUT}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class DroidAdapter(ClaudeStyleAdapter):
|
|
109
|
+
name = "droid"
|
|
110
|
+
display_name = "Factory Droid"
|
|
111
|
+
# PostToolUse can only block or append in Droid: not registered.
|
|
112
|
+
events = {k: v for k, v in ALL_EVENTS.items() if v != TOOL_OUTPUT}
|
|
113
|
+
|
|
114
|
+
def parse(self, name: str, kind: str, payload: Dict[str, Any]) -> Optional[HookEvent]:
|
|
115
|
+
event = super().parse(name, kind, payload)
|
|
116
|
+
previous = payload.get("previous_session_id")
|
|
117
|
+
if kind == SESSION_START and isinstance(previous, str) and previous:
|
|
118
|
+
event.session_id = previous # compaction starts a new session id
|
|
119
|
+
return event
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class JunieAdapter(ClaudeStyleAdapter):
|
|
123
|
+
name = "junie"
|
|
124
|
+
display_name = "Junie CLI"
|
|
125
|
+
# No PostToolUse and no PreCompact (so nothing to restore after compaction).
|
|
126
|
+
events = {"UserPromptSubmit": PROMPT}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class DevinAdapter(ClaudeStyleAdapter):
|
|
130
|
+
name = "devin"
|
|
131
|
+
display_name = "Devin CLI"
|
|
132
|
+
# PostToolUse only appends; there is no pre-compaction event.
|
|
133
|
+
events = {"UserPromptSubmit": PROMPT}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""OpenAI Codex CLI (>= 0.133) and Open Interpreter (a Rust fork on the same hook engine).
|
|
2
|
+
|
|
3
|
+
Claude-like event names and ``hookSpecificOutput`` envelope, with Codex's own
|
|
4
|
+
semantics:
|
|
5
|
+
|
|
6
|
+
- Every output struct is ``deny_unknown_fields``: any extra key makes the run
|
|
7
|
+
"Failed" and the output is ignored — so only schema keys are ever emitted.
|
|
8
|
+
- ``PostToolUse``: ``tool_response`` is the model-facing text (a string, no
|
|
9
|
+
exit code). ``{"decision": "block"}`` would hand the model a *failed* tool
|
|
10
|
+
call; ``{"continue": false, "stopReason": S, "reason": R}`` replaces the
|
|
11
|
+
result with ``R`` and the turn continues — the one deliberate ``continue:
|
|
12
|
+
false`` subcortex ever emits, allowed for this event only. Output Codex
|
|
13
|
+
already truncated (``Warning: truncated output``) is left alone.
|
|
14
|
+
- ``SessionStart`` with ``source: "compact"`` runs before the next model
|
|
15
|
+
request after compaction; same ``session_id`` as before.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Dict, Optional
|
|
23
|
+
|
|
24
|
+
from .base import TOOL_OUTPUT, HookEvent, Response
|
|
25
|
+
from .claude_family import ALL_EVENTS, ClaudeStyleAdapter
|
|
26
|
+
|
|
27
|
+
CODEX_TRUNCATED = "Warning: truncated output"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _exit_status(payload: Dict[str, Any]) -> Optional[int]:
|
|
31
|
+
"""The command's exit code. The payload has none, but by the time the hook
|
|
32
|
+
runs the session log (``transcript_path``) records the finished command as
|
|
33
|
+
an ``item_completed`` CommandExecution with ``exit_code``. None if unknown."""
|
|
34
|
+
call_id, path = payload.get("tool_use_id"), payload.get("transcript_path")
|
|
35
|
+
if not isinstance(call_id, str) or not call_id or not isinstance(path, str) or not path:
|
|
36
|
+
return None
|
|
37
|
+
try:
|
|
38
|
+
from ..transcript import read_tail
|
|
39
|
+
|
|
40
|
+
for line in reversed(read_tail(Path(path)).splitlines()):
|
|
41
|
+
if call_id not in line or "CommandExecution" not in line:
|
|
42
|
+
continue
|
|
43
|
+
item = ((json.loads(line).get("payload") or {}).get("item") or {})
|
|
44
|
+
if item.get("type") == "CommandExecution" and item.get("id") == call_id:
|
|
45
|
+
code = item.get("exit_code")
|
|
46
|
+
if item.get("status") == "failed" and not (isinstance(code, int) and code != 0):
|
|
47
|
+
return 1
|
|
48
|
+
return code if isinstance(code, int) and not isinstance(code, bool) else None
|
|
49
|
+
except Exception:
|
|
50
|
+
return None
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CodexAdapter(ClaudeStyleAdapter):
|
|
55
|
+
name = "codex"
|
|
56
|
+
display_name = "Codex CLI"
|
|
57
|
+
events = ALL_EVENTS
|
|
58
|
+
replaces_output = True
|
|
59
|
+
allowed_blocking = ((TOOL_OUTPUT, "continue", False),)
|
|
60
|
+
|
|
61
|
+
def parse(self, name: str, kind: str, payload: Dict[str, Any]) -> Optional[HookEvent]:
|
|
62
|
+
event = super().parse(name, kind, payload)
|
|
63
|
+
if kind == TOOL_OUTPUT and isinstance(event.output, str) \
|
|
64
|
+
and event.output.lstrip().startswith(CODEX_TRUNCATED):
|
|
65
|
+
event.output = None # Codex already head/tail-truncated it
|
|
66
|
+
if kind == TOOL_OUTPUT and event.output is not None:
|
|
67
|
+
# A failed command must never be trimmed: the model has to see why.
|
|
68
|
+
code = _exit_status(payload)
|
|
69
|
+
event.extra["exit_code"] = code
|
|
70
|
+
event.failed = event.failed or (code is not None and code != 0)
|
|
71
|
+
return event
|
|
72
|
+
|
|
73
|
+
def render_tool_output(self, event: HookEvent, replacement: str) -> Response:
|
|
74
|
+
removed = len(event.output or "") - len(replacement)
|
|
75
|
+
# `reason` replaces the whole result, Codex's exit-status header included.
|
|
76
|
+
if event.extra.get("exit_code") == 0:
|
|
77
|
+
replacement = "Process exited with code 0\n" + replacement
|
|
78
|
+
return {
|
|
79
|
+
"continue": False,
|
|
80
|
+
"stopReason": f"subcortex: trimmed {max(removed, 0)} chars of low-value output",
|
|
81
|
+
"reason": replacement,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class OpenInterpreterAdapter(CodexAdapter):
|
|
86
|
+
name = "open-interpreter"
|
|
87
|
+
display_name = "Open Interpreter"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""GitHub Copilot CLI (>= 1.0.67) with camelCase hook events.
|
|
2
|
+
|
|
3
|
+
- ``userPromptSubmitted`` → ``{"additionalContext": ...}`` (model-facing since 1.0.65).
|
|
4
|
+
- ``postToolUse`` (successful calls only) → ``{"modifiedResult": {...}}`` replaces
|
|
5
|
+
what the model sees; ``resultType`` must stay ``"success"`` (anything else
|
|
6
|
+
routes the call to the failure path).
|
|
7
|
+
- ``preCompact`` is a notification (can't block); there is no post-compaction
|
|
8
|
+
``sessionStart`` source, so the snapshot rides along with the next prompt.
|
|
9
|
+
- Payloads carry no event name: it always comes from argv.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from typing import Any, Dict, Optional
|
|
16
|
+
|
|
17
|
+
from .base import PRE_COMPACT, PROMPT, TOOL_OUTPUT, HookAdapter, HookEvent, Response
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CopilotAdapter(HookAdapter):
|
|
21
|
+
name = "copilot"
|
|
22
|
+
display_name = "GitHub Copilot CLI"
|
|
23
|
+
events = {"userPromptSubmitted": PROMPT, "postToolUse": TOOL_OUTPUT, "preCompact": PRE_COMPACT}
|
|
24
|
+
event_field = "hookEventName"
|
|
25
|
+
restore_on_prompt = True
|
|
26
|
+
|
|
27
|
+
def parse(self, name: str, kind: str, payload: Dict[str, Any]) -> Optional[HookEvent]:
|
|
28
|
+
event = HookEvent(kind=kind, name=name, payload=payload,
|
|
29
|
+
session_id=str(payload.get("sessionId") or ""),
|
|
30
|
+
transcript_path=str(payload.get("transcriptPath") or ""),
|
|
31
|
+
trigger=str(payload.get("trigger") or ""))
|
|
32
|
+
if kind == PROMPT:
|
|
33
|
+
prompt = payload.get("prompt")
|
|
34
|
+
event.prompt = prompt if isinstance(prompt, str) else ""
|
|
35
|
+
elif kind == TOOL_OUTPUT:
|
|
36
|
+
result = payload.get("toolResult")
|
|
37
|
+
if not isinstance(result, dict) or result.get("resultType") != "success":
|
|
38
|
+
return event
|
|
39
|
+
args = payload.get("toolArgs")
|
|
40
|
+
if isinstance(args, str):
|
|
41
|
+
try:
|
|
42
|
+
args = json.loads(args)
|
|
43
|
+
except ValueError:
|
|
44
|
+
pass
|
|
45
|
+
event.tool = str(payload.get("toolName") or "")
|
|
46
|
+
event.tool_input = args
|
|
47
|
+
text = result.get("textResultForLlm")
|
|
48
|
+
event.output = text if isinstance(text, str) else None
|
|
49
|
+
elif kind == PRE_COMPACT:
|
|
50
|
+
event.extra["compacted"] = True # notification of a compaction in progress
|
|
51
|
+
return event
|
|
52
|
+
|
|
53
|
+
def render_prompt(self, event: HookEvent, text: str) -> Response:
|
|
54
|
+
return {"additionalContext": text}
|
|
55
|
+
|
|
56
|
+
def render_tool_output(self, event: HookEvent, replacement: str) -> Response:
|
|
57
|
+
result = event.payload.get("toolResult")
|
|
58
|
+
if not isinstance(result, dict):
|
|
59
|
+
return None
|
|
60
|
+
return {"modifiedResult": {**result, "resultType": "success", "textResultForLlm": replacement}}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Cursor CLI (``agent`` / ``cursor-agent``, builds >= 2026.05.20).
|
|
2
|
+
|
|
3
|
+
- ``beforeSubmitPrompt`` → ``{"additional_context": ...}`` (> 10,000 chars
|
|
4
|
+
and Cursor drops it entirely, so we stay under). Only fires in interactive
|
|
5
|
+
sessions, not ``-p``.
|
|
6
|
+
- ``postToolUse`` can't replace Shell output (only MCP results): not registered.
|
|
7
|
+
- ``preCompact`` is observe-only and fires when compaction happens; there is
|
|
8
|
+
no post-compaction event (``sessionStart`` is new chats only), so the
|
|
9
|
+
snapshot is marked ready right away and delivered with the next prompt.
|
|
10
|
+
- Never ``continue``/``permission``/``decision`` keys; exit 2 would reject the prompt.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Dict, Optional
|
|
16
|
+
|
|
17
|
+
from .base import PRE_COMPACT, PROMPT, HookAdapter, HookEvent, Response
|
|
18
|
+
|
|
19
|
+
MAX_CONTEXT_CHARS = 9000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CursorAdapter(HookAdapter):
|
|
23
|
+
name = "cursor"
|
|
24
|
+
display_name = "Cursor CLI"
|
|
25
|
+
events = {"beforeSubmitPrompt": PROMPT, "preCompact": PRE_COMPACT}
|
|
26
|
+
restore_on_prompt = True
|
|
27
|
+
|
|
28
|
+
def parse(self, name: str, kind: str, payload: Dict[str, Any]) -> Optional[HookEvent]:
|
|
29
|
+
event = super().parse(name, kind, payload)
|
|
30
|
+
event.session_id = str(payload.get("conversation_id") or payload.get("session_id") or "")
|
|
31
|
+
if kind == PRE_COMPACT:
|
|
32
|
+
event.extra["compacted"] = True # fires as compaction runs; can't veto
|
|
33
|
+
return event
|
|
34
|
+
|
|
35
|
+
def render_prompt(self, event: HookEvent, text: str) -> Response:
|
|
36
|
+
return {"additional_context": text[:MAX_CONTEXT_CHARS]}
|