small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
albedo robot-docs hooks.md
7.1 kB · 141 lines
Markdown

hooks #

A hook is asked as each turn goes and may steer it. Any extension contributes one through its managed plugin, Managed(..extension.empty(), hook: Some(ask)), where ask: fn(hook.Event) -> hook.Verdict (hook.gleam). The installed hooks extension builds Claude Code compatible command hooks on it; an exporter of traces, a policy, or an audit log is an extension of the same shape. docs/extensions.md has the human-facing version with examples.

events #

event when a verdict can
Prompted(text) a turn's newest input is a user message, before the first model call block (the turn ends without a model call, the user is told why), add context
Calling(call) a tool call is about to run block (the call's result is the refusal), rewrite its JSON arguments, add context
Called(call, output, started_ms, finished_ms) once the call has a result, a refusal included add context; a block is context too, since the call already ran
Stopping(reply, held) the model ended its turn without a tool call block or add context, which continues the turn, at most 8 times

Any verdict can carry notes (shown to the user, never the model) and halt (the turn ends with that reason; after Called, once its result is committed). Context is committed as user input ahead of the next model call.

call is a model tool call (name is the tool, arguments its JSON), or a call made inside a cell, whose within is the model call that ran the cell:

name arguments
run command; host too for rem.run/rem.shell in ssh command mode
files.edit file_path, old_string, new_string
files.write file_path, content; host too for rem.write
mcp.call name, arguments (also mcp.<server>.<tool>(...))
mail.submit to, body
agents.spawn, .cancel, .close, .cancel_all, .delete what the daemon is asked
web_search query, limit
cua.<tool> the driver tool's arguments (clicks, typing, keys, launches, and its reads)
browser.goto, .click, .fill, .press, .evaluate the method's own (url, ref, text, key, expression)

The file and command argument names are Claude Code's, so command hooks read them unchanged. A remote kernel loads the same modules, so its run and files report through the relayed host connection like local ones. Wrapping a new plugin call is one line: @hook.reported(name, {parameter: argument}) on the method, or await hook.around(name, arguments, call).

The kernel reports these through the core hook.call and hook.called host routes (priv/python/albedo_plugins/hook.py, which the kernel loads only while some extension hooks the session). A block raises PermissionError in the cell, a rewrite changes only the arguments it names, context and notes print into the cell's output (a call's output is cut to 64 KiB before hooks see it), and run reports Called when its job ends, with {"exit_code", "timed_out"} as output and the hooks' lines appended to the job's output. A kernel that cannot reach the daemon within 30 s runs the call anyway.

combining #

runtime.hook asks every hook of the session's composition in registry order, each in the turn's process (inner calls: in the host call's process), synchronously. The first block, rewrite and halt win; context and notes concatenate. A later hook sees the arguments an earlier one rewrote. A hook that crashes is a note and the turn goes on. A session without hooks pays one list walk per step and nothing inside cells.

A hook that only watches answers hook.proceed at once, handing the event to a process of its own. Together with observe (CallSent carries each model call's request, usage and timing; TurnEnded closes the turn) that is a whole trace: Prompted opens a turn span, Called gives each tool span its start and end, and within nests inner calls under the cell's span.

extension.ManagedPlugin(fn(_, session, _) {
  let exporter = start_exporter(session)
  Ok(extension.Managed(
    ..extension.empty(),
    observe: fn(_, event) { process.send(exporter, Observed(event)) },
    hook: Some(fn(event) {
      process.send(exporter, Hooked(event))
      hook.proceed
    }),
    close: fn() { process.send(exporter, Close) },
  ))
})

built-in hooks #

agents hooks a child session: when it is about to stop while it still owes its parent an answer (mail.owes_reply), it keeps the turn going once with a reminder to mail.submit("parent", ...). If the child was spawned with a verifier command, agents also runs that command in the child's workspace when the child calls mail.submit to its parent or tries to stop while owing a reply, blocking with the command's failure output until it exits 0. The unreviewed forward of its last words stays the fallback once Stopping holds reach their cap.

command hooks #

hooks is installed and off; enable it with /extensions. It reads $ALBEDO_HOME/hooks.json at each event, in Claude Code's format. Only that file is read: a checkout cannot plant hooks.

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash|Write",
      "hooks": [{"type": "command", "command": "my-hook", "timeout": 10}]
    }]
  }
}

Events map to UserPromptSubmit, PreToolUse, PostToolUse and Stop. Matchers are Python regular expressions searched in the tool name (Bash, Edit and Write for run, files.edit and files.write, the call's own name otherwise); empty or * matches everything. When the file configures an event, the daemon starts priv/python/albedo_hook_command.py with python3 and the model tools' clean environment; it runs the matching commands concurrently (eight at a time) with sh -c in the session workspace, each in its own process group with the payload on stdin (session_id, cwd, hook_event_name, transcript_path (empty), permission_mode (default), and prompt, tool_name/tool_input/tool_use_id/tool_response, or stop_hook_active/last_assistant_message). timeout is in seconds, default 600; the group is killed at the timeout and when the command ends. Commands are not sandboxed.

Exit 2 blocks with stderr as the reason. Exit 0 may print JSON: continue: false/stopReason halts, decision: "block"/reason blocks, hookSpecificOutput.permissionDecision: "deny"/permissionDecisionReason blocks, hookSpecificOutput.updatedInput rewrites the arguments, hookSpecificOutput.additionalContext adds context (10,000 characters, labelled with the event), systemMessage is a note. A UserPromptSubmit command's plain stdout is context. Any other exit, a timeout, invalid JSON or a broken hooks.json fails open with a note.

Not yet: SessionStart/SessionEnd, compaction and subagent events, Python callbacks registered from a cell, and per-workspace hook files.

test/e2e/hooks_test.py covers the command hooks through the real loop and kernel.