Skip to content

khimaros/opencode-evolve

Repository files navigation

opencode-evolve

self-modifying hook plugin for opencode. gives LLM agents the ability to modify their own hooks and prompts (with test validation), discover and invoke custom tools, and evolve their behavior at runtime.

for a comprehensive production example see persona

installation

add to your opencode.jsonc:

{
  "plugin": ["opencode-evolve"]
}

set OPENCODE_EVOLVE_WORKSPACE to your workspace directory (default: ~/workspace).

workspace layout

~/workspace/
├── config/
│   └── evolve.jsonc        # evolve settings
├── state/
│   └── evolve.json         # runtime state (auto-managed)
├── hooks/
│   ├── persona.py          # hook scripts (autodiscovered)
│   └── logger.py
├── prompts/                # prompt templates
└── tests/
    ├── persona_test.py     # per-hook test scripts
    └── logger_test.py

configuration

config/evolve.jsonc — all fields optional, all overridable via EVOLVE_* env vars:

{
  "model": null,                   // "provider/model" or { providerID, modelID } (auto-detected if omitted)
  "heartbeat_ms": 1800000,        // heartbeat interval (30 min)
  "hook_timeout": 30000,          // subprocess timeout (30s)
  "heartbeat_title": "heartbeat", // heartbeat session title
  "heartbeat_agent": "evolve",    // agent ID for heartbeat prompts
  "heartbeat_cleanup": "none",     // "none" | "new" | "archive" | "compact"
  "heartbeat_cleanup_count": null, // cleanup after N heartbeats (null = disabled)
  "heartbeat_cleanup_tokens": null // cleanup after N total tokens (null = disabled)
}

env var overrides: EVOLVE_MODEL, EVOLVE_HEARTBEAT_AGENT. env vars always take precedence over config file values.

heartbeat_cleanup defines what happens when a threshold is reached:

  • none: thresholds are ignored; heartbeat accumulates indefinitely in one session.
  • new: starts a new heartbeat session; the old one remains active.
  • archive: starts a new heartbeat session and archives the old one (hides it in WebUI).
  • compact: triggers server-side compaction on the current session.

heartbeat_cleanup_count and heartbeat_cleanup_tokens are evaluated independently; the first threshold reached triggers the action.

heartbeat_agent must match a configured agent in your opencode.jsonc. for example:

{
  "default_agent": "evolve",
  "agent": {},
  "plugin": ["opencode-evolve"]
}

with a corresponding agent file at agents/evolve.md.

hook protocol

all executable files in hooks/ are autodiscovered and called as subprocesses (alphabetical order, files starting with . or __ are ignored):

$WORKSPACE/hooks/<hook> <hook_name>

input: JSON on stdin with at minimum {"hook": "<name>", ...context}.

output: JSONL on stdout. each line is a JSON object. lines with {"log": "..."} are printed to the debug log. all other lines are merged into the final result.

stderr: forwarded to the debug log.

exit code: 0 = success, non-zero = failure (triggers recover hook unless the failing hook is observational).

composability

multiple hooks run serially in alphabetical order. results are merged across all hooks:

  • arrays (system, tools, notifications, actions, modified) are concatenated
  • scalars (continue, prompt, user, message, result) are concatenated with newline
  • errors trigger recover per failed hook; other hooks continue regardless

hooks

discover

called once at plugin init. return registration metadata and tool definitions.

input:

{"hook": "discover"}

output:

{"name": "persona", "test": "persona_test.py", "tools": [{"name": "my_tool", "description": "...", "parameters": {"arg": "description"}}]}

registration fields (optional):

  • name — tool prefix and hook identifier (defaults to filename stem). tools are registered as <name>_<tool_name>.
  • test — test script filename in tests/ for hook validation (e.g. persona_test.py). if omitted, no validation is performed on hook writes.

parameters support two formats:

  • string (backwards compat): {"arg": "description"} — registers as a string param
  • typed: {"arg": {"type": "string", "description": "...", "optional": true}} — registers with the specified type

supported types: string, number, boolean, object, array, any

tool permissions

tools can declare a permission field in their discover definition to enable fine-grained permission control via opencode's agent config. the permission.arg field specifies which tool argument(s) to use as the permission pattern:

{"name": "trait_write", "description": "...", "parameters": {...}, "permission": {"arg": "trait"}}
{"name": "trait_move", "description": "...", "parameters": {...}, "permission": {"arg": ["old_trait", "new_trait"]}}

tools without a permission field default to patterns: ["*"] and can only be controlled with simple allow/deny.

agent config examples:

permission:
  # simple allow/deny all invocations
  persona_trait_delete: "deny"
  persona_note_list: "allow"

  # fine-grained by pattern (requires permission.arg in tool definition)
  persona_trait_write:
    "SOUL.md": "deny"
    "*": "allow"
  persona_data_update:
    ".people.json": "deny"
    "*": "allow"

  # restrict which hooks can be edited
  evolve_hook_write:
    "persona.py": "allow"
    "*": "deny"
  evolve_hook_edit:
    "persona.py": "allow"
    "*": "deny"

mutate_request

called on each new session to generate the system prompt. return {"system": [...]} to manage the session, or {} to skip. the result is cached per-session (system prompt is frozen after first call).

input:

{"hook": "mutate_request", "session": {"id": "..."}, "history": [...]}

output:

{"system": ["system prompt text..."]}

observe_message

called after each LLM response. observational — failure does not trigger recover.

input:

{"hook": "observe_message", "session": {"id": "...", "agent": "..."}, "thinking": "...", "calls": [...], "answer": "..."}

output:

{"modified": ["file.md"], "notify": [{"type": "some_change", "files": ["file.md"]}], "actions": [...]}

idle

called when the LLM gives a final response with no tool calls. return {"continue": "message"} to force the session to keep going.

input:

{"hook": "idle", "session": {"id": "...", "agent": "..."}, "answer": "..."}

output:

{}

or:

{"continue": "follow-up prompt text"}

heartbeat

called on the heartbeat timer interval. return a system prompt and user message to send to the heartbeat session.

input:

{"hook": "heartbeat", "sessions": [], "history": [...]}

output:

{"system": ["..."], "user": "heartbeat prompt text"}

compacting

called when opencode compacts a session. return a custom compaction prompt.

input:

{"hook": "compacting", "session": {"id": "..."}, "history": [...]}

output:

{"prompt": "compaction prompt text..."}

format_notification

called to format pending notifications before injecting them into a session. observational — failure does not trigger recover.

input:

{"hook": "format_notification", "session": {"id": "..."}, "notifications": [...]}

output:

{"message": "[update] modified: FOO.md. re-read if needed."}

recover

called when another hook fails (except observational hooks). return emergency system prompt and user message.

input:

{"hook": "recover", "error": "...", "failed_hook": "..."}

output:

{"system": ["recovery prompt"], "user": "recovery instructions"}

execute_tool

called when a discovered tool is invoked.

input:

{"hook": "execute_tool", "tool": "my_tool", "args": {"arg": "value"}}

output:

{"result": "tool output", "modified": ["file.md"], "notify": [...]}

tool_before / tool_after

called before/after any opencode tool execution. observational.

input (before):

{"hook": "tool_before", "session": {"id": "..."}, "tool": "tool_name", "callID": "...", "args": {}}

input (after):

{"hook": "tool_after", "session": {"id": "..."}, "tool": "tool_name", "callID": "...", "title": "...", "output": "..."}

self-modification

the plugin provides builtin tools that let agents modify their own behavior at runtime:

  • hook editinghook_list, hook_read, hook_write, hook_edit let the agent modify existing hook files. writes are validated against the hook's registered test script before installation. new hooks cannot be created or deleted.
  • prompt editingprompt_list, prompt_read, prompt_write, prompt_edit let the agent modify existing prompt templates. new prompts cannot be created or deleted.
  • tool discovery — custom tools defined by each hook's discover response are automatically registered with opencode.

tool discovery

tools are defined by each hook's discover response. each tool gets a prefixed name derived from the hook's registered name (or filename stem if not specified). for example, a hook returning {"name": "persona"} with a tool named trait_read registers as persona_trait_read.

builtin tools

the plugin provides these tools regardless of what the hook returns. they use the same prefix:

  • <prefix>_datetime — get the current date and time in UTC
  • <prefix>_heartbeat_time — get the last heartbeat runtime in UTC
  • <prefix>_prompt_list — list prompt files in prompts/ (bare filenames)
  • <prefix>_prompt_read — read an existing prompt (supports offset/limit)
  • <prefix>_prompt_write — overwrite an existing prompt (cannot create new files)
  • <prefix>_prompt_edit — find-and-replace in an existing prompt (supports replaceAll)
  • <prefix>_hook_list — list hook files in hooks/ (bare filenames)
  • <prefix>_hook_read — read an existing hook (supports offset/limit)
  • <prefix>_hook_write — overwrite an existing hook (validated if configured hook, cannot create new files)
  • <prefix>_hook_edit — find-and-replace in an existing hook (validated if configured hook, supports replaceAll)
  • <prefix>_hook_validate — validate hook content against the test suite without installing

git integration

the workspace is auto-initialized as a git repo. when a new repo is created, any pre-existing files are committed as an "initial" snapshot before any tool-triggered commits, so the diff history clearly shows what was present before vs what is new. after tool execution and heartbeats, changes are committed automatically.

actions

hooks can return an actions array to trigger side effects:

{"actions": [
  {"type": "send", "session_id": "...", "message": "...", "synthetic": true},
  {"type": "create_session", "title": "..."}
]}

writing a custom hook

see examples/hello/ for a complete working example. the hook script must:

  1. be executable
  2. accept the hook name as first argument (sys.argv[1])
  3. read JSON from stdin
  4. write JSONL to stdout
  5. exit 0 on success

minimal python hook:

#!/usr/bin/env python3
import json, sys

HOOKS = {}

def hook(fn):
    HOOKS[fn.__name__] = fn
    return fn

@hook
def discover(ctx):
    return {"name": "mybot", "test": "mybot_test.py", "tools": []}

@hook
def mutate_request(ctx):
    return {"system": ["you are a helpful assistant."]}

if __name__ == "__main__":
    h = HOOKS.get(sys.argv[1])
    if not h:
        print(json.dumps({"error": f"unknown hook: {sys.argv[1]}"}))
        sys.exit(1)
    ctx = json.loads(sys.stdin.read() or "{}")
    result = h(ctx)
    for key, value in result.items():
        print(json.dumps({key: value}), flush=True)

About

self-modifying hook plugin for opencode with heartbeat

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors