Skip to content

Latest commit

 

History

History
816 lines (584 loc) · 22.8 KB

File metadata and controls

816 lines (584 loc) · 22.8 KB

Extension Lua API

Agent lifecycle seams

The extension host exposes explicit lifecycle/tool/context seams while keeping the default UI and assistant loop Go-owned. New APIs are designed as runtime-neutral host contracts first and then exposed through Lua.

Current lifecycle events include:

Event Purpose Mutation policy
session_start / session_load / session_shutdown Observe session lifecycle. Observational initially.
input / prompt_prepare Inspect or eventually transform user input before a turn. Explicit transforms only.
turn_start / turn_end / agent_end Observe one assistant turn. Observational initially.
context_build Add bounded, labeled context blocks and inspect context budgets. Bounded contributions only.
before_provider_request / after_provider_response / provider_error Observe provider traffic with redacted payloads. Conservative typed mutation only.
tool_call / tool_result / tool_error Mediate tool execution and results. Normalize arguments; rewrite/redact results; surface hook errors as tool errors.
message_append Observe durable message writes. Observational initially.

All lifecycle payloads must be bounded. Provider events must redact auth headers and secrets. Tool hook decisions should be visible in diagnostics.

librecode separates two event mechanisms:

  • the ro-backed event stream for observational async/fanout events such as lifecycle telemetry, headless JSON output, timers, retry notifications, and future watchers
  • the ordered middleware dispatcher for hooks that return mutations, such as tool normalization/redaction, context contributions, and provider request changes

Extensions may observe lifecycle events through Lua handlers, while core Go services can subscribe to the reactive stream for telemetry and integrations. Do not use the observational stream for state mutations that require deterministic ordering.

Status

This document describes the currently implemented Lua API surface.

It is intentionally practical and code-oriented. The API is still evolving as librecode moves toward a more programmable runtime.

See also:

  • docs/adr/0001-programmable-runtime.md
  • docs/runtime-architecture.md
  • docs/extension-runtime.md
  • docs/extension-roadmap.md
  • docs/rendering-boundary.md

Loading model

Extensions are trusted local Lua files loaded from configured extensions.use sources.

Default config:

extensions:
  enabled: true
  use:
    - path:.librecode/extensions

Supported source declaration forms:

extensions:
  use:
    # shorthand string form
    - official:vim-mode
    - github:example/librecode-extension
    - github:example/monorepo//extensions/fancy
    - path:.librecode/extensions/my-extension
    - path:/absolute/or/relative/extension

    # object form with version pinning
    - source: official:vim-mode
      version: v0.1.0
    - source: github:example/librecode-extension
      version: v1.2.3

Startup loads only entries declared in extensions.use; extra directories on disk are ignored. path: sources load from disk today. official: and github: sources are installed and pinned by the extension manager. Unknown schemes are configuration errors.

librecode does not auto-load a bundled extensions/ directory. The stock chat UI is implemented in Go; Lua extensions are optional customization. Use --no-extensions to skip configured extensions for one command.

Each Lua file or directory extension entry runs in its own Lua state. Directory extensions use a small manifest plus entry file:

.librecode/extensions/my-workflow/
  init.lua
  workflow.lua
  helpers.lua
-- init.lua
return {
  name = "my-workflow",
  version = "0.1.0",
  api_version = "v1alpha1",
  entry = "workflow.lua",
}

The extension root is added to package.path, so entry files can require sibling modules:

local helpers = require("helpers")

Helper modules are convenience wrappers over primitive APIs. They are not a separate Go host API family.

Importing the API

Extensions can either use the global librecode table or require the module explicitly:

local lc = require("librecode")

Top-level API

librecode.on(event_name, fn)

Registers a low-level event handler.

local lc = require("librecode")

lc.on("prompt_submit", function(ev)
  lc.event.consume()
  lc.buf.append("transcript", {
    kind = "message",
    role = "custom",
    text = "extension intercepted submit\n",
  })
end)

Variant with priority:

lc.on("key", { priority = 100 }, function(ev)
  return true
end)

Current commonly emitted events include:

  • startup
  • key
  • prompt_submit
  • prompt_user_entry
  • prompt_done
  • retry_start
  • retry_end
  • model_delta
  • thinking_delta
  • tool_start
  • tool_end
  • resize
  • render
  • tick
  • before_agent_start
  • agent_end
  • context_build
  • before_provider_request
  • after_provider_response
  • provider_error
  • tool_call
  • tool_result
  • tool_error

Tool lifecycle handlers may return small, explicit mutations. librecode stays YOLO by default; these hooks are for formatting, observability, redaction, or argument normalization rather than built-in permission gates.

lc.on("tool_call", function(ev)
  if ev.payload.name == "read" then
    return {
      tool_call = {
        arguments = {
          path = ev.payload.arguments.path,
          limit = 200,
        },
      },
    }
  end
end)

lc.on("tool_result", function(ev)
  if ev.payload.name == "bash" then
    return {
      tool_result = {
        result = ev.payload.result:gsub("SECRET=[^\\n]+", "SECRET=<redacted>"),
      },
    }
  end
end)

Diagnostics

librecode extension list reports registered commands, tools, keymaps, handlers, active timers, and cumulative Lua execution duration per loaded extension. Use it with --no-extensions comparisons to isolate extension-induced UX or performance issues.

librecode.log(message)

Writes a message through the Go logger.

lc.log("hello from extension")

librecode.register_command(name, description, fn)

Registers an extension command.

lc.register_command("hello", "print hello", function(args)
  return "hello " .. (args or "")
end)

The command appears in librecode extension list and can be executed with librecode extension run <name>.

librecode.register_tool(name, description, fn)

Registers an extension-backed tool callable by the runtime.

lc.register_tool("echo_json", "returns the provided args", function(args)
  return {
    content = "ok",
    details = args,
  }
end)

Handlers receive a Lua table converted from Go map[string]any arguments.

They may return either:

  • a scalar/string-like value, which becomes tool content; or
  • a table with:
    • content
    • details

librecode.api

Neovim-inspired low-level helpers.

librecode.api.create_namespace(name)

Returns a stable numeric namespace ID for the provided name.

local ns = lc.api.create_namespace("my-extension")

If called again with the same name, returns the same ID.

librecode.api.create_autocmd(events, opts_or_fn)

Registers event handlers for one or more event names.

Examples:

lc.api.create_autocmd("prompt_submit", function(ev)
  lc.log("submit seen")
end)

lc.api.create_autocmd({ "before_agent_start", "agent_end" }, {
  priority = 50,
  callback = function(ev)
    lc.log("lifecycle event")
  end,
})

Also available as:

  • librecode.api.nvim_create_autocmd
  • librecode.autocmd.create

librecode.api.create_user_command(name, opts_or_fn)

Registers a user command.

lc.api.create_user_command("Hello", {
  desc = "say hello",
  callback = function(args)
    return "hello"
  end,
})

Also available as:

  • librecode.api.nvim_create_user_command
  • librecode.command.create

librecode.keymap

librecode.keymap.set(target, lhs, fn, opts)

Registers a keymap against a generic target.

Examples:

lc.keymap.set({ focus = "composer" }, "ctrl+j", function(ev)
  lc.buf.set_text("status", "ctrl+j pressed")
  return true
end, { priority = 100, desc = "example composer keymap" })

lc.keymap.set({ buffer = "composer" }, "*", function(ev)
  lc.log("focused composer buffer key: " .. ev.key)
end)

lc.keymap.set("global", "ctrl+p", function()
  lc.action.run("transcript.tree")
  return true
end)

Supported target forms:

  • "global" for global keymaps
  • { focus = "name" } for focused target kind keymaps, such as composer, autocomplete, or panel
  • { buffer = "name" } for focused-buffer keymaps
  • { window = "name" } for focused-window keymaps
  • { role = "name" } for focused-role keymaps
  • { scope = "focus" | "buffer" | "window" | "role", name = "name" }
  • a list of any of the above

String targets other than "global" are shorthand for role-scoped keymaps. Prefer explicit target tables for clarity. Non-global keymaps match the currently focused target only, not every visible window. This keeps composer extensions from stealing keys while autocomplete or panels are focused.

Notes:

  • lhs is normalized internally (<c-j>, ctrl-j, and ctrl+j normalize together)
  • "*" or "any" matches any key
  • higher priority runs first
  • returning true marks the event consumed

librecode.keymap.del(target, lhs)

Removes matching keymaps previously registered by the same extension.

All mutable runtime APIs currently work only during active event handling. Calling them outside an event raises an error.

librecode.win

Window APIs expose and mutate the currently visible runtime windows for the active event.

librecode.win.list()

Returns visible window names.

librecode.win.get(name)

Returns the named window table or nil.

Fields currently include:

  • name
  • role
  • buffer
  • renderer
  • x
  • y
  • width
  • height
  • cursor_row
  • cursor_col
  • visible
  • metadata

renderer = "default" means the stock Go renderer may draw the window. renderer = "extension" means the extension owns the window until a later window/layout mutation changes it, so the stock renderer skips that window.

librecode.win.find(opts)

Finds the first matching window.

Supported filters today:

  • name
  • role
  • buffer

Example:

local win = librecode.win.find({ role = "composer" })
local buf = librecode.win.get_buf(win)

librecode.win.get_buf(name)

Returns the buffer name displayed by the given window.

This is the current path for extensions that want to discover the composer through the visible runtime model instead of assuming a hardcoded buffer name.

librecode.win.set_buf(name, buffer_name)

librecode.win.set_renderer(name, renderer)

librecode.win.get_var(name, key)

librecode.win.set_var(name, key, value)

librecode.win.create(name[, value])

librecode.win.set(name, value)

librecode.win.delete(name)

Mutate the active window set. Window mutations are applied back to the terminal runtime after the event.

Use librecode.win.set_renderer(name, "extension") or set window.renderer = "extension" before win.set/layout.set when an extension wants to fully own that window's drawing. Set it back to "default" to return drawing to the stock renderer.

librecode.layout

Layout APIs expose the current screen dimensions and window table.

librecode.layout.get()

Returns a table:

{
  width = 120,
  height = 40,
  windows = {
    composer = { role = "composer", buffer = "composer", x = 0, y = 32, width = 120, height = 6 },
  },
}

librecode.layout.set(layout)

Replaces the runtime layout with the provided table. This is intentionally low-level: callers are responsible for non-overlap, bounds, and visibility.

librecode.ui

Low-level drawing APIs enqueue window-relative draw operations for the current frame/event.

Current UI primitives are intentionally small. They are enough for optional overlays and focused custom windows, but not yet enough to faithfully reimplement complex hot renderers such as the transcript. See docs/rendering-boundary.md for the current rendering boundary.

librecode.ui.measure(text)

Returns terminal display width in cells using the Go rendering backend's grapheme-aware width logic.

librecode.ui.truncate(text, width)

Returns text truncated to fit width cells. Truncation is grapheme-aware and appends an ellipsis when possible.

librecode.ui.pad_right(text, width)

Returns text padded/truncated to exactly width cells.

librecode.ui.wrap(text, width)

Returns a list of wrapped lines using the same generic Go-backed wrapping logic used by stock renderers.

librecode.ui.viewport(lines, height[, offset])

Returns a bounded viewport table for a line list:

  • lines — visible line slice
  • start / end — zero-based half-open range in the original line list
  • offset — clamped bottom-relative scroll offset
  • max_offset — maximum valid offset
  • total — original line count

librecode.ui.virtual_list(items, height[, offset])

Returns a bounded viewport table for variable-height items. Each item may be either a number or a table with height.

Returned fields:

  • items — visible item metadata with index (zero-based), lua_index, row_offset, and height
  • start / end — zero-based half-open item range
  • offset — clamped bottom-relative scroll offset in rows
  • max_offset — maximum valid row offset
  • total — original item count

This is intended for large structured renderers so Lua can render only visible blocks while Go handles viewport math.

librecode.ui.theme_tokens()

Returns supported theme token names for fg/bg style fields.

librecode.ui.clear_window(name)

librecode.ui.clear_region(name, row, col, height, width[, style])

librecode.ui.draw_text(name, row, col, text[, style])

librecode.ui.draw_lines(name, row, col, lines[, style])

librecode.ui.draw_spans(name, row, col, spans)

librecode.ui.draw_box(name[, style])

librecode.ui.draw_batch(ops)

librecode.ui.set_cursor(name, row, col)

Styles currently accept fg, bg, bold, and italic. Color names are theme tokens resolved by Go, including text, accent, border, borderAccent, borderMuted, muted, dim, warning, error, success, selectedBg, userMessageBg, customMessageBg, and tool/diff/code tokens.

draw_spans accepts inline spans such as:

{
  { text = "hot", fg = "accent", bold = true },
  { text = " cold", fg = "dim" },
}

Example:

lc.on("render", function()
  local win = lc.win.find({ role = "composer" })
  if not win then return end
  lc.win.set_renderer(win, "extension")
  lc.ui.clear_window(win)
  lc.ui.draw_box(win, { fg = "border" })
  lc.ui.draw_spans(win, 1, 2, {
    { text = "custom ", fg = "text" },
    { text = "composer", fg = "accent", bold = true },
  })
  lc.ui.set_cursor(win, 1, 2)
end)

Still-planned generic UI primitives include namespace-scoped highlights/extmarks, richer window scroll state, and renderer registration helpers.

librecode.buf

librecode.buf.list()

Returns visible buffer names for the current event.

librecode.buf.create(name[, value])

Creates or replaces a buffer in the current event result.

lc.buf.create("notes", { text = "hello", cursor = 5 })

librecode.buf.delete(name)

Marks a buffer for deletion.

librecode.buf.get(name)

Returns a table containing the current buffer state.

Fields include:

  • name
  • text
  • chars
  • cursor
  • label
  • metadata

librecode.buf.get_text(name)

librecode.buf.set_text(name, text)

Get or replace buffer text.

librecode.buf.get_cursor(name)

librecode.buf.set_cursor(name, cursor)

Get or replace cursor position.

librecode.buf.insert(name, position, text)

librecode.buf.delete_range(name, start, end)

librecode.buf.replace(name, start, end, replacement)

Rune-oriented editing helpers for low-level buffer mutation.

librecode.buf.get_lines(name, start, end)

librecode.buf.set_lines(name, start, end, lines)

Line-oriented helpers for replacing a range.

librecode.buf.set(name, value)

Replace the full buffer state.

librecode.buf.append(name, value)

Append text or one structured block to a buffer.

Examples:

lc.buf.append("status", "working")

lc.buf.append("transcript", {
  kind = "message",
  text = "tool finished\n",
  role = "tool_result",
})

String values append to buffer text. Tables with block fields append to buffer.blocks.

librecode.buf.clear(name)

Clear buffer text, blocks, and cursor.

librecode.buf.get_blocks(name[, start[, end]])

librecode.buf.set_blocks(name, start, end, blocks)

librecode.buf.delete_blocks(name, start, end)

Structured block helpers. Blocks are generic and may be used for transcript items, tool output, annotations, or any extension-defined data.

Recognized block fields include:

  • id
  • kind
  • role
  • text
  • index
  • created_at
  • streaming
  • metadata

librecode.buf.get_var(name, key)

librecode.buf.set_var(name, key, value)

Read or write buffer metadata values.

librecode.action

Request host-side runtime actions from an extension.

librecode.action.run(name)

Current built-ins include:

  • submit
  • history.prev
  • history.next
  • autocomplete.accept
  • followup.queue
  • followup.dequeue
  • interrupt
  • prompt.cancel
  • transcript.tree

librecode.timer

Timer callbacks run at the start of the next terminal runtime event whose clock is past the scheduled time. They execute inside the same transaction model as event handlers, so they may use buf, win, layout, ui, and action APIs.

librecode.timer.defer(ms, fn)

Schedules a one-shot callback and returns a timer ID.

librecode.timer.interval(ms, fn)

Schedules a repeating callback and returns a timer ID.

librecode.timer.stop(id)

Cancels a pending timer.

librecode.event

These helpers only make sense during active event execution.

librecode.event.consume()

Marks the event as consumed.

librecode.event.stop()

Marks the event as consumed and stops later handlers.

Event payload shape

Handlers for terminal events receive a table like:

{
  name = "key",
  key = "ctrl+j",
  text = "",
  ctrl = true,
  alt = false,
  shift = false,
  working = false,
  auth_working = false,
  context = {
    mode = "chat",
    working = false,
    auth_working = false,
    cwd = "/path/to/project",
    session_id = "abc",
  },
  data = {
    text = "incremental event text",
  },
  focus = { kind = "composer", window = "composer", buffer = "composer", role = "composer", panel_kind = "", exclusive = false },
  composer = { text = "hello", cursor = 5, chars = { "h", "e", "l", "l", "o" }, metadata = {} },
  buffers = {
    composer = { text = "hello", cursor = 5, chars = { "h", "e", "l", "l", "o" }, blocks = {}, metadata = {} },
    status = { text = "", cursor = 0, chars = {}, blocks = {}, metadata = {} },
    transcript = {
      text = "",
      cursor = 0,
      chars = {},
      metadata = { count = 12, snapshot_count = 12, snapshot_start = 0, snapshot_limit = 32 },
      blocks = {
        { id = "message:0", kind = "message", role = "user", text = "hello", index = 0, streaming = false },
        { id = "streaming:11", kind = "streaming", role = "assistant", text = "partial", index = 11, streaming = true },
      },
    },
    thinking = { text = "", cursor = 0, chars = {}, blocks = {}, metadata = { count = 1 } },
    tools = { text = "", cursor = 0, chars = {}, blocks = {}, metadata = { count = 1 } },
  },
}

Example extension shape

A project extension can build substantial behavior using the current API:

local lc = require("librecode")

lc.keymap.set({ focus = "composer" }, "<c-j>", function()
  lc.action.run("submit")
  lc.event.consume()
end)

Useful patterns include:

  • event-driven state
  • keymaps
  • composer editing
  • label updates
  • low-level buffer mutation

Assistant lifecycle events

The extension host also emits bounded lifecycle events around the assistant runtime. These are runtime-neutral host events exposed through Lua as regular lc.on(...) handlers. Handlers may observe payloads and return { stop = true } to prevent later handlers from running, but session/input/turn events are observational in the current release.

Current assistant lifecycle events include:

  • input
  • prompt_prepare
  • session_start
  • session_load
  • before_agent_start
  • agent_start
  • turn_start
  • context_build
  • before_provider_request
  • after_provider_response
  • provider_error
  • tool_call
  • tool_result
  • tool_error
  • message_append
  • turn_end
  • agent_end

Example:

local lc = require("librecode")

lc.on("turn_start", function(event)
  lc.log("turn started for session " .. event.payload.session_id)
end)

lc.on("message_append", function(event)
  lc.log("appended " .. event.payload.role .. " entry " .. event.payload.entry_id)
end)

lc.on("context_build", function(event)
  event.payload.contributions = {
    {
      name = "project-constraints",
      source = "my-extension",
      role = "system",
      content = "Prefer small focused commits and run the project CI before committing.",
    },
  }
  return { payload = event.payload }
end)

Lifecycle payloads are intentionally bounded. message_append includes the appended entry text and metadata for the current message, but lifecycle events do not include full transcript history.

context_build has a bounded mutation contract. Handlers may return contributions entries. Each contribution must include non-empty content and is capped by the host. The context payload exposes breakdown.system, breakdown.skills, breakdown.history, and breakdown.extensions token estimates so extensions can reason about context budget without seeing the full transcript.

before_provider_request has a conservative mutation contract. The payload exposes request metadata, a redacted header map, and a provider payload object. Handlers may return a modified payload and provider_request.headers for non-sensitive headers:

lc.on("before_provider_request", function(event)
  event.payload.payload.metadata = "debug-marker"
  return {
    payload = event.payload,
    provider_request = {
      headers = {
        ["X-Debug-Run"] = "local",
      },
    },
  }
end)

Sensitive headers such as Authorization, x-api-key, cookie, and proxy-authorization are redacted in payloads and cannot be modified by provider hooks.

Current limitations

The API is still incomplete compared with the long-term target.

Notably missing today:

  • job/process spawning primitives
  • optional Lua helper modules built on primitive APIs
  • highlights/extmarks/namespaced annotations
  • deeper assistant/model/tool runtime replacement hooks

Future APIs should preserve the primitive-first boundary described in docs/runtime-architecture.md: add kernel primitives to Go, and build product convenience in Lua.