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
5.5 kB · 100 lines
Markdown

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). Plain UserPromptSubmit/SessionStart stdout 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.