Skip to main content

peer_hooks

The hooks that make a Claude Code or Codex session a peer: they register it, give it its messages, and guard other sessions’ folders.

Claude Code and Codex have no control socket, as Ramabana has. They run hooks: commands that the agent starts at fixed events. Every peer hook is one command, PYTHON -m ramabana.peer_hooks KIND EVENT. PYTHON is the full path of the Python that ran ramabana setup peers, so the hook does not depend on the ramabana that is first on PATH; an older ramabana there would read the words as its own options and fail. It reads the hook input as JSON on stdin and writes what the agent expects.

Hooks must be fast and must not break the agent. They make no model call and use no network. An error goes to hooks.log in the peers folder, and the hook then does nothing. A hook never exits with code 2, which blocks the agent, except the waiter when it has messages. The module does not load the agent code.

Adapters

An Adapter holds what is different for each agent kind: its config file, the file that gets the peer-work skill, the variable that holds its session id, and whether a hook can wake it. ramabana setup peers uses them to install the hooks.


source

Adapter

def Adapter(
    kind:str, config:str, skill_path:str, session_env:str | None, wakes:bool
)->None:

What the peer hooks need to know about one agent kind.

hook_block gives the hook entries for one agent’s config, by event. Claude Code gets a second Stop hook, the waiter: async runs it in the background, and asyncRewake lets its exit code 2 wake an idle session. Claude Code stops an asyncRewake hook at its timeout, so the waiter gets a long one. Codex has no way to wake an idle session, and it names its file edit tool apply_patch.


source

hook_block

def hook_block(
    kind
):

The hook groups, by event, that agent kind needs in its config.


source

hook_cmd

def hook_cmd(
    kind, event
):

The command that runs hook event of agent kind with this Python, whatever ramabana is first on PATH.

Each event has one group. Claude Code has two Stop groups; Codex has one.

b = hook_block('claude')
test_eq(b['PreToolUse'], [{'matcher': 'Edit|Write|NotebookEdit', 'hooks': [{'type': 'command', 'command': f'{sys.executable} -m ramabana.peer_hooks claude pre-tool'}]}])
test_eq([g['hooks'][0].get('asyncRewake') for g in b['Stop']], [None, True])
test_eq(len(hook_block('codex')['Stop']), 1)

What the agent reads

_messages writes unread messages for the agent with peer_text. An ask also says who decides. Outside work together mode, the agent must ask the user first. In work together mode with that sender, it can act without the user. The ask also shows the command that answers it, as PEER_RUN: the Python the hook runs under, with -m ramabana.peers. An older ramabana first on PATH may have no tell, so the agent does not depend on it. These lines come before the <peer-message> block, as peer_text puts them. In work together mode an ask also counts against the budget: one over it gets the reply refused: budget: ... at once, and the agent does not see it. Codex does not give its session id to commands, so its commands carry --session.

Session start

_start registers the session with the git root of its folder and the agent’s process id. Only a git root is guarded: a session started outside git, for example in your home folder, shows its folder but locks nothing. Then it tells the agent who it is, which peers are live, and how to send. The brief gives PEER_RUN once as the command to use for ramabana.

The user is back

When the user types, _ended ends work together mode. It returns the start time of a mode that ended since the user last saw one, also a mode that ended while the user was away. The end of a mode waits in the small file <key>.ended, and _ended removes it, so the user sees each end once. The hook reads only that file on each prompt, not the whole log; a report_shown row in the log keeps the record. _report_note gives the saved report, or tells the agent to make it.

Claude Code also sends prompts that no human typed through UserPromptSubmit: a background task’s notification, a subagent’s hand-back, and a message from another session. The hook input has no field that tells them apart from a typed prompt; it has only the prompt text. Claude Code opens each such prompt with a frame: a hand-back or a message from another session starts with Another Claude session sent a message: and an <agent-message> or <cross-session-message> block, and a notification starts with <task-notification>. _typed looks for a line that opens with one of these frames, so a typed prompt that only mentions a tag in its text still counts as typed. _prompt ends work together mode only for a typed prompt.

Stop and wait

_stop blocks the stop while messages are unread, so the agent goes on and reads them. The reason is the messages. It blocks only for new messages, so it cannot loop: each message is taken once, the hop limit counts, and Claude Code stops after 8 blocks.

_wait is the background waiter. Only one runs for each session: it holds a lock on the .wait file and writes its process id there. A second waiter exits at once. A lock dies with its process, so a crashed waiter does not block the next one. On messages, the waiter exits with code 2 and writes them to stderr, which wakes Claude. If it cannot write the messages it took, it puts them back in the inbox. It exits with code 0 when the session ends. When work together mode expires while it waits, it saves the report first.

Session end and the folder guard

_end removes the registration, so the waiter stops. It leaves the .wait file, because the waiter can hold a lock on it. Claude Code gives SessionEnd only 1.5 s, so it does nothing more.

_pre_tool finds the paths a tool call writes. Claude Code’s file tools give file_path or notebook_path. Codex’s apply_patch gives the patch, which names each file. folder_denial checks each path. A refusal names the owner, and the agent can send it a message instead.

A patch names files to add, change, delete and move to.

test_eq(_paths({'tool_name': 'apply_patch', 'tool_input': {'command': '*** Begin Patch\n*** Update File: a.py\n*** Move to: b.py\n*** Add File: /r/c.py\n*** End Patch'}}), ['a.py', 'b.py', '/r/c.py'])
test_eq(_paths({'tool_name': 'Bash', 'tool_input': {'command': '*** Add File: x'}}), [])
test_eq(_paths({'tool_input': {'notebook_path': '/r/n.ipynb'}}), ['/r/n.ipynb'])

The entry point

peer_hook runs one event and returns the exit code, stdout and stderr. An error is logged to hooks.log and gives exit code 0 with no output, so a broken hook never stops the agent. main is python -m ramabana.peer_hooks, the command in the hooks; ramabana peer-hook, the command of older setups, runs it too. Wrong words or input that is not JSON are logged and give exit code 0, never 2. The event version prints the ramabana version, so --doctor can see which code the hooks run.


source

main

def main(
    argv
):

Run python -m ramabana.peer_hooks KIND EVENT on the hook input in stdin; the exit code, which is 2 only from a waiter with messages.


source

peer_hook

def peer_hook(
    kind, event, data, d:NoneType=None, cfg:NoneType=None
):

Run hook event of agent kind on the hook input data: (exit code, stdout, stderr); an error is logged and gives (0, ’‘,’’).

A stop with nothing unread lets the agent stop. A note makes it block, once. A write into another session’s folder is refused, and a write into the session’s own folder is not. A broken input is logged.

from ramabana.peers import Envelope, post, register
with tempfile.TemporaryDirectory() as t, patch.dict(os.environ, RAMABANA_PEERS=t):
    s1 = {'session_id': 's1', 'cwd': t}
    test_eq(peer_hook('claude', 'stop', s1, cfg=t), (0, '', ''))
    post(t, 'claude-s1', Envelope('note', 'v2 is out', {'key': 'ramabana-r1', 'name': 'leela'}))
    assert 'v2 is out' in json.loads(peer_hook('claude', 'stop', s1, cfg=t)[1])['reason']
    test_eq(peer_hook('claude', 'stop', s1, cfg=t), (0, '', ''))
    register(t, 'codex', 'c9', ['/r/abhyasa'], os.getpid())
    peer_hook('claude', 'session-start', s1, cfg=t)
    deny = json.loads(peer_hook('claude', 'pre-tool', s1 | {'tool_name': 'Edit', 'tool_input': {'file_path': '/r/abhyasa/a.py'}}, cfg=t)[1])
    test_eq(deny['hookSpecificOutput']['permissionDecision'], 'deny')
    test_eq(peer_hook('claude', 'pre-tool', s1 | {'tool_name': 'Edit', 'tool_input': {'file_path': f'{t}/a.py'}}, cfg=t), (0, '', ''))
    test_eq(peer_hook('claude', 'stop', {}, cfg=t), (0, '', ''))
    assert 'KeyError' in (Path(t)/'hooks.log').read_text()