Hooks #
hooks is installed but off by default. Enable it with /extensions hooks on. The Python kernel owns execution (priv/python/albedo_hooks.py); the
extension only contributes its Python module. The loop asks fire_json through
a hidden cell, not a host route, and commits context through existing bridges.
The kernel's run and files adapters call the same engine directly.
Configuration #
Two files are read at each firing, and all matching hooks run:
$ALBEDO_HOME/workspaces/<workspace>/hooks.json (the workspace slug is the one
memory.md lives under, in the same directory) and $ALBEDO_HOME/hooks.json.
The workspace file comes first, so on conflicts (the first block, rewrite or halt
wins) it beats the global one. Neither is in the repo, so a checkout cannot plant
hooks; .albedo/hooks.json is not read.
Anything that can write that file (an npm postinstall script, malware) would
otherwise get code execution, so, as in Codex, the workspace file runs only once
its exact content is approved. The user reviews and approves it on the /hooks
page (user-only: the model's commands object is refused): rows show each hook,
what triggers it and its command, and a approves, after a confirm, exactly the
content the page drew (an edit in between is refused). Approval is the file's
sha256 in hooks.sha256 beside it; editing the file revokes it, and the page
says so. While unapproved the kernel skips the file, notes it once, and the chat
sidebar shows approve in /hooks. The seal sits in the same directory, so this
stops writers that do not know the protocol, not targeted malware; a trust
store in the daemon's database would close that. The model is not treated as an
adversary. The global file is the user's own and always runs. Command hooks are
not sandboxed.
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash|Write",
"hooks": [{"type": "command", "command": "my-hook", "timeout": 10}]
}]
}
}
Supported events: SessionStart, SessionEnd, UserPromptSubmit, PreToolUse,
PostToolUse, Stop. SessionStart (source startup) fires with a kernel's first
prompt and its context goes first; it cannot block. SessionEnd fires as the
kernel shuts down (idle release included; 3 s cap), if a prompt was ever seen.
Matchers use Python re.search: tool name for tool events, empty otherwise;
empty or * matches all. Unsupported types/invalid matchers fail open with a
message. Compaction and subagent events are deferred.
Commands run concurrently (up to eight at once), merged in configuration order.
timeout is positive seconds, default 600. Each command starts its own process
group; timeout, oversized output, and completion kill remaining group members.
Stdout/stderr captures cap at 1 MiB each; overflow fails open. These commands do
not enter the kernel's heavy-job slot scheduler. Stdin is finite JSON with EOF:
session_id, cwd, hook_event_name, transcript_path (empty),
permission_mode (default), plus event-specific fields. Cwd is the session
workspace; relative commands/scripts resolve there.
Output and loop behavior #
Exit 2 blocks, with stderr as the reason. Other nonzero exits, crashes, timeouts or invalid output fail open with a visible message. Exit 0 accepts:
continue: false/stopReason: halt the turn.systemMessage: note shown to the user.decision: "block"/reason: block.hookSpecificOutput.permissionDecision: "deny"/permissionDecisionReason: deny a tool call.hookSpecificOutput.updatedInput: replacement argument object.hookSpecificOutput.additionalContext: context (10,000 characters, visibly truncated). PlainUserPromptSubmit/SessionStartstdout also becomes context.
The first block/halt/rewrite wins; contexts/messages concatenate. A prompt block
ends cleanly without a model request. PreToolUse denial prevents the effect and
becomes a tool result. PostToolUse cannot undo an effect. Prompt and outer-tool
context become durable User inputs. A Stop block/context continues the turn,
at most eight times; stop_hook_active is true after the first checkpoint.
Fields include prompt, tool_name/tool_input/tool_response, or
last_assistant_message/stop_hook_active, depending on event.
Inner tools use Bash for run, Edit, Write for files.edit/files.write;
outer calls use the model tool name (python). Inner denial raises
PermissionError. Bash rewrites are argv (shlex parsed), not shell execution.
Inner context/messages print into the cell. run() fires PreToolUse, and PostToolUse
(exit_code, timed_out) when the job ends, its lines appended to the job's
output. files.read fires nothing (follow-up).
Python callbacks #
hooks.register(event, callback, matcher=None) returns an idempotent removal
function. hooks.registered() lists (event, matcher); hooks.clear() removes
all. Callback input is the same envelope as command stdin, and its return is
None or the JSON output shape above. Sync/async callbacks run first, in
registration order, and exceptions fail open. Their first rewrite is passed to
command hooks; a callback block/halt skips commands. Registrations are local to
the kernel and are not restored after it closes; they are not trust-gated.
test/e2e/hooks_test.py covers real loop and inner-tool effects, provider-visible
context, continuation bounds, callback lifetime/order and fail-open failures.