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.
Files changed (64) hide show
  1. subcortex/__init__.py +3 -0
  2. subcortex/__main__.py +3 -0
  3. subcortex/adapters/__init__.py +48 -0
  4. subcortex/adapters/base.py +230 -0
  5. subcortex/adapters/claude_family.py +133 -0
  6. subcortex/adapters/codex.py +87 -0
  7. subcortex/adapters/copilot.py +60 -0
  8. subcortex/adapters/cursor.py +36 -0
  9. subcortex/adapters/docker_agent.py +115 -0
  10. subcortex/adapters/gemini_family.py +60 -0
  11. subcortex/adapters/grok.py +98 -0
  12. subcortex/adapters/kimi_code.py +138 -0
  13. subcortex/adapters/letta_vibe.py +96 -0
  14. subcortex/adapters/openhands.py +153 -0
  15. subcortex/auth.py +59 -0
  16. subcortex/backends/__init__.py +23 -0
  17. subcortex/backends/base.py +22 -0
  18. subcortex/backends/jev.py +460 -0
  19. subcortex/backends/laya.py +149 -0
  20. subcortex/cli.py +809 -0
  21. subcortex/client.py +77 -0
  22. subcortex/config.py +263 -0
  23. subcortex/daemon.py +502 -0
  24. subcortex/evalset.py +241 -0
  25. subcortex/hook.py +254 -0
  26. subcortex/installers/__init__.py +62 -0
  27. subcortex/installers/amp.py +39 -0
  28. subcortex/installers/base.py +874 -0
  29. subcortex/installers/claude_family.py +229 -0
  30. subcortex/installers/codex.py +110 -0
  31. subcortex/installers/copilot.py +65 -0
  32. subcortex/installers/crush.py +36 -0
  33. subcortex/installers/cursor.py +79 -0
  34. subcortex/installers/gemini_family.py +83 -0
  35. subcortex/installers/goose.py +186 -0
  36. subcortex/installers/kimi_code.py +71 -0
  37. subcortex/installers/mcp_only.py +111 -0
  38. subcortex/installers/more_hooks.py +184 -0
  39. subcortex/installers/opencode.py +66 -0
  40. subcortex/installers/openhands.py +84 -0
  41. subcortex/installers/pi_cline.py +53 -0
  42. subcortex/ledger.py +92 -0
  43. subcortex/localhttp.py +59 -0
  44. subcortex/mcp_server.py +187 -0
  45. subcortex/metrics.py +56 -0
  46. subcortex/plugins/amp/subcortex.ts +258 -0
  47. subcortex/plugins/cline/subcortex.ts +340 -0
  48. subcortex/plugins/opencode/subcortex.ts +265 -0
  49. subcortex/plugins/pi/subcortex.ts +292 -0
  50. subcortex/policy.py +341 -0
  51. subcortex/presets.py +163 -0
  52. subcortex/provision.py +188 -0
  53. subcortex/service.py +149 -0
  54. subcortex/state.py +137 -0
  55. subcortex/transcript.py +211 -0
  56. subcortex/tuis.py +51 -0
  57. subcortex/ui.py +319 -0
  58. subcortex/verdicts.py +233 -0
  59. subcortex/wizard.py +474 -0
  60. subcortex-0.3.0.dist-info/METADATA +287 -0
  61. subcortex-0.3.0.dist-info/RECORD +64 -0
  62. subcortex-0.3.0.dist-info/WHEEL +5 -0
  63. subcortex-0.3.0.dist-info/entry_points.txt +3 -0
  64. subcortex-0.3.0.dist-info/top_level.txt +1 -0
subcortex/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """subcortex — a local decision layer for coding-agent TUIs."""
2
+
3
+ __version__ = "0.3.0"
subcortex/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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]}