lug

# The Lua API

Every lug.* function and event. Lug's own plugins in basic.lug use exactly this API, so they double as examples.

## In your editor

lug exists only inside Lug, so lua-language-server reports it as undefined. lug docs lua writes the lug table's annotations, this page and the modules built into the binary (lug.bsp, lug.bootstrap) to ~/.local/share/lug/lua-docs and prints that directory. Point the language server at it, and at the stock runtime's lua/ for its modules, for example with ~/.config/lug/.luarc.json:

{
  "runtime.version": "Lua 5.4",
  "workspace.library": [
    "/home/you/.local/share/lug/lua-docs",
    "/home/you/.local/share/lug/plugins/basic/lua"
  ]
}

Run it again after upgrading Lug, since the directory follows the installed version.

## Options and events

Name Arguments Returns
lug.opt.NAME — The value; a table is a copy
lug.opt.NAME = value A value of the option's type Raises when refused
pairs(lug.opt) — Each name and value, by name
lug.opt.declare(name, spec) A new option's name; { type, default, min? }, type one of boolean, integer, string, list, table Nothing: the option works as Lug's do, for :set, option_changed and checking. Raises when the name is taken
lug.on(event, fn) An event name below, or a plugin's own, which has a dot in its name (workflow.changed); fn(event) runs as a coroutine A function that removes the hook; calling it again does nothing
lug.emit(event, data?) A plugin's event, with a dot in its name; plain values Nothing: runs each hook on it before returning, each as its own coroutine with its own copy of data, as Lug's events run. Raises on a name with no dot, so no plugin fires one of Lug's, or on data holding a function

Events arrive as tables with these fields. The runtime's events, from run_started on, also carry type, the event's name.

Event Fields
run_started run
turn_started, turn_finished run, turn; turn_finished adds usage
text_delta, reasoning_delta run, text
message_added run, message (role, parts)
tool_call_requested run, call (id, name, args); hooks get it before the call is settled
tool_started, tool_finished run, id; tool_finished adds output, is_error
tool_progress run, id, progress: a running Lua tool's handler called ctx.progress
run_finished run, outcome, usage. outcome.status is completed, cancelled, stopped (a call was denied with stop) or failed, which adds reason (max_turns, misfit: the answer did not fit its schema twice, refused, or failed) and error, what happened. A job a cancel dropped before it started ends with one too, and no run_started
compacted run, kept
retrying run, attempt, delay_secs, reason
sent text: sent to the session, by lug.session.send or the prompt
option_changed name, value
layout_failed None: composing the screen failed three times, and it starts again with no windows, for a plugin to open its own again
prompt_changed name: typing or a paste changed that line editor's text
agent_started agent, name, parent: a run of the session or of a lug.agent.call started. agent is its conversation's id, and parent the agent whose tool call started it, if one did
agent_finished As agent_started, with outcome and usage as run_finished has them: that run ended
window_closed name, tab: lug.ui.close, lug.ui.tab_close or an open with replace took the window away (a window given back on a close does not fire it). Closing a tab fires it for each of its windows, in order, before tab_closed
tab_closed id: lug.ui.tab_close closed the tab

A plugin's own events are named with a dot, hunk.done say, so they never clash with Lug's, and carry whatever lug.emit gives them. Nothing registers them. A plugin that shows progress can emit it as an event for a statusline to draw, rather than expose a module other plugins require.

-- a plugin says what changed; anyone may listen
lug.emit("workflow.changed", { run = id, status = "waiting" })
local off = lug.on("workflow.changed", function(e) lug.ui.redraw() end)
off() -- no longer listening

-- a window that closes ends what waits on it
lug.on("window_closed", function(e)
  if e.name == "review" then finish(nil, "the review window was closed") end
end)

started is Lug's own: it is fired once at the end of the load order (its hooks see the options as --cmd and the flags leave them), and again when :reload builds a fresh state. Its pick_session is true once, at startup, when lug --session was given no id.

## Keys and commands

Name Arguments Returns
lug.keymap.set(mode, lhs, rhs, opts) mode: normal, insert, visual, transcript, command, popup, approval, input, confirm, picker, or a feed's, text component's or line editor's; lhs in key notation, or <Char> for what is typed; rhs: function(count) (function(text) for <Char>) or ":…"; opts: { desc?, nowait? } Nothing
lug.keymap.del(mode, lhs) As for set Nothing
lug.keymap.list(mode) A mode { { lhs, rhs, desc }, … }, by lhs, as written
lug.cmd.set(name, fn, opts) fn(cmd) with cmd = { args, raw, bang }, run as a coroutine; opts: { desc?, complete?, history? }; history = false keeps its lines out of the : history Nothing
lug.cmd.open(text?) Text to start with Nothing: opens the : line, a line editor named cmdline in the command mode
lug.cmd.run(line, opts?) A command line, without the :; { history? } Nothing: runs it; history = true keeps it in the : history, unless its command says not to
lug.cmd.history() — The lines the : line ran, oldest first
lug.cmd.list() — { { name, desc, complete? }, … }, by name
lug.feedkeys(keys) Keys in key notation Nothing: they go where typed keys go, through the mappings, the focus and the dialogs, and a sequence left unfinished runs what it maps to at once. Raises on bad notation

The : line is Lua built into Lug, require("lug.cmdline"), whose run(), close(), backspace(), complete() and history(step) are what the command mode's keys do. Its draw(width, height) returns the components it is drawn with while open, a : and the editor over the last row; replace it to change only the look, keeping a prompt component with id cmdline and mode command.

lug.feedkeys is lug.prompt.keys' opposite: where that types into one editor past every mapping, this presses keys as the terminal would. A test drives a plugin with it.

## The prompt

Name Arguments Returns
lug.prompt.get(name?) A line editor's id, the prompt unless given text, cursor
lug.prompt.set(text, cursor?, name?) Text; a byte offset, default the end; a line editor's id Nothing
lug.prompt.insert(text, name?) Text, inserted at the cursor; a line editor's id Nothing
lug.prompt.keys(keys, name?) Keys in key notation; a line editor's id Nothing: the editor takes them as if typed, past every mapping
lug.prompt.mode() — "normal", "insert" or "visual"
lug.prompt.set_mode(mode) One of those three Nothing
lug.prompt.selection() — from, to, linewise; nil outside visual mode
lug.prompt.send() — Nothing: the prompt's text goes to the first lug.prompt.on_send function that takes it, else to the session
lug.prompt.on_send(fn) fn(text), returning true to take the text; it must answer at once A function that removes it. Each runs in the order they were added
lug.prompt.history(step) -1 older, 1 newer Nothing
lug.prompt.height(width) A width The rows the text needs there, at least 1
lug.prompt.complete(from, items, name?) A byte offset where the word starts; a list of strings or { word, hint? }; a line editor's id Nothing: opens the completion popup over that editor, whose keys are the popup mode's first
lug.prompt.popup(how?) A number of candidates to move the highlight, round the ends; "accept" puts the highlighted one in place of the word; "close"; nil asks The id of the editor whose popup is open, or nil

## The transcript and the screen

Name Arguments Returns
lug.ui.open(view, opts?) A view; { split?, win?, size?, sep?, tab? } (see windows) The window's name
lug.ui.close(name) A window's name Nothing
lug.ui.resize(name, cells?) A window's name; rows or columns, as its split runs, or nil to share the split's room evenly again Nothing
lug.ui.tab_open() — A new, empty tab's id (see tabs)
lug.ui.tab_focus(id?) A tab's id; nil asks The shown tab's id
lug.ui.tab_close(id) A tab's id Nothing
lug.ui.tabs() — The tabs' ids, in order
lug.ui.windows() — The windows in the last frame, in order: { { name, view, x, y, w, h, split }, … }, split being which way the split holding it runs, h side by side or v stacked, and nil for a window alone
lug.ui.redraw() — Nothing: a frame follows
lug.ui.markdown(text, width) Markdown; a width text lines, drawn as the transcript draws an answer. A text component's content draws them without the lines.
lug.ui.code(text, lang, width) Code; a language name; a width text lines
lug.ui.diff(old, new, path, width, line?) Two texts; a path for the language; a width; the file line the texts start at, which numbers the rows text lines, as a diff entry draws them
lug.ui.diffstat(old, new) Two texts adds, dels: how many lines the change adds and deletes
lug.ui.filter(labels, text, sort?) Strings; the text typed; false keeps their order The indices of the labels that hold the text's characters in order, ignoring case, closest first
lug.ui.width(text) A string The terminal cells it takes as Lug draws it: 2 for a wide CJK character or an emoji, 0 for a combining mark
lug.transcript.items() — The conversation, item by item: { { id, version, kind, … }, … }
lug.transcript.transform(fn) fn(entry), or nil to remove every transform A function that removes this one
lug.feed.upsert(name, entry, opts?) A feed's name; an entry of a kind Lug draws, with an id (a whole number or a string) to replace the one that has it; { before = id } or { after = id } to put it beside another entry The entry's id. Raises on a conversation item, which lug.feed.item shows, and on an entry to go beside that the feed does not have
lug.feed.item(name, id, item, opts?) A feed's name; an id, a whole number or a string; a conversation item, as lug.transcript.items() gives them, or nil to remove it; { before = id } or { after = id }, as upsert takes Nothing: shows the item as the transcript would, under id. Raises on an entry to go beside that the feed does not have
lug.feed.clear(name) A feed's name Nothing
lug.feed.scroll(n, feed?) Rows; a fraction of the view; ±math.huge; a feed's name Nothing
lug.feed.jump(n, kind?, feed?) Entries to move; the kind entries came from (a conversation item's, or the kind lug.feed.upsert was given); a feed's name Nothing
lug.feed.fold(open?, feed?) true, false, or nil to toggle; a feed's name Nothing
lug.feed.fold_all(open, feed?) Boolean; a feed's name Nothing
lug.feed.move(keys, feed?) A prompt motion along the cursor's row: h l w b e W B E 0 ^ $, with an optional count (3w); a feed's name Nothing
lug.feed.select(charwise?, feed?) true for characters; nil or false for whole rows; a feed's name Nothing: starts a selection at the cursor, ends one of the same kind, or switches to the other kind
lug.feed.selection(feed?) A feed's name text, from, to, linewise: the selected text (a charwise selection's exact span), or the cursor row's; then, with a selection, where the text starts and ends in the selected rows' text (byte offsets, half-open, as lug.prompt.selection's) and whether it takes whole rows. Nil while the feed has no cursor
lug.feed.entry(feed?) A feed's name The id of the cursor's entry: its item's in the transcript, the id, number or string, lug.feed.upsert or lug.feed.item gave it in another feed. Nil while the feed has no cursor
lug.feed.entries(name) A feed's name { { id, kind, text, old?, title? }, … }, in order: each entry's id, the kind it came from, and what it says (a text entry's lines joined with \n, a diff's new text with its old, a box's title with what it holds). A conversation item may make several. {} for a feed that has none
lug.feed.yank(feed?) A feed's name Nothing
lug.hl.set(name, spec) A group; { fg?, bg?, bold?, italic?, underline?, reverse?, link?, default? }; default = true sets it only if nothing has Raises on a bad colour
lug.hl.get(name) A group Its definition as set, or nil

### Highlight groups

Chunks name highlight groups, and an unset group draws plain. Lug names a few groups for what they mean, not where they are used, and a theme fills them in:

Group For
LugAccent Text that stands out
LugOk Success
LugWarn What needs attention: a warning, a question waiting on the user
LugError Failure
LugMuted Text that recedes
LugTitle A window's title, with no background of its own
LugSelection A selection, or the cursor's row

The other Lug* groups are the stock look's, which a theme may derive as it likes, so a plugin names groups of its own and links each to a role, with default so a user's or theme's choice stands whichever loads first:

lug.hl.set("MyPluginTitle", { link = "LugTitle", default = true })
lug.hl.set("MyPluginWaiting", { link = "LugWarn", default = true })

A user then restyles the plugin through its groups, and a link is followed as it is drawn, so a new theme reaches the plugin too.

### Feeds and entries

A feed is a list of entries that Rust lays out, caches and scrolls. Each feed in the plan keeps its own place, folds, cursor and selection. The transcript is the feed the conversation fills. { kind = "transcript", … } shows it, and { kind = "feed", id = name, … } shows any other. A feed takes the focus under its name, and with it a cursor; its keys are its mode's (transcript unless given). lug.feed.scroll, jump, fold, fold_all, move, select, selection, entry and yank act on the feed they name, or without one on the feed with the cursor, the one focused last (a dialog or the : line over it leaves it its cursor and selection), or else on the transcript. The mouse wheel arrives as the keys <ScrollUp> and <ScrollDown>, in whatever mode has the keys.

The focus goes to a name on the screen as drawn: prompt, a feed's name, or a text component's or line editor's id. A text component without an id takes no focus; with one, its keys are its mode's (normal unless given; any name lug.keymap.set takes). { kind = "prompt", id = name, mode = mode } is a line editor of its own, as the : line and the dialogs' inputs are: its keys are its mode's, the keys no mapping takes edit it as the prompt's insert mode does, and the lug.prompt functions that take a name act on it. The plan is refused when an id is empty or is prompt or transcript, when two components share a name, when a line editor has no mode, or when a feed is named prompt. When the focused component leaves the plan, because its pane went or its leaf now shows another tab, the first component in the plan that can take the focus has it.

focusable = false on a feed or text component keeps the focus off it: a status bar in a window, say. lug.ui.focus cannot give it the keys, the focus leaves it at the next frame when it has them, and the stock <C-w> moves pass over its window.

A conversation item is a table of plain values. Its kind is one of these:

kind Fields
user text
assistant text, streaming
reasoning text, streaming
tool call_id, name, args, status (pending, awaiting_approval, running or done), progress (while running, what its handler last reported), output, is_error, summary
notice text, error

Lug draws none of them itself. A transform draws each as entries of the kinds Lug renders, and the stock runtime's look, added once loading ends, draws the user bar, thinking, notices and tool cards; an item no transform drew shows as plain text. An entry Lug draws is a table of plain values too, of one of these kinds:

kind Fields Drawn as
markdown text, streaming Markdown; while streaming, only the text after its last settled paragraph is parsed again
code text, lang Highlighted code
diff old, new, path, line A diff; with line, the file line the texts start at, each row numbered in LugLineNr: a removed line where it was, any other where it is
ansi text A program's output, its colours kept
canvas h, w, marker, shapes Shapes drawn in pixels, several to a cell; see below
text lines, as a text component's; prefix and indent, chunks The lines, wrapped at words, the first row after prefix and the others after indent (prefix unless given)

A canvas is h cells high and w wide (as wide as it is laid out, and no wider, unless given). Its marker says how many pixels a cell holds: braille (the default) 2 across and 4 down, half 1 across and 2 down, block one. Pixels count from 0 at the top left, so moving a sprite one pixel is adding 1 to its x. shapes are drawn in order, each in its group:

kind Fields Draws
points xy, a flat list: x, y, x, y, … Those pixels
line x1, y1, x2, y2 A line between two pixels
text x, y, text, in cells The text over the pixels; its spaces leave what is under them

A cell has one colour: that of the last pixel set in it. Rust keeps a canvas's rows like any other entry's, so give it an id and bump its version when a shape changes.

local slug = { kind = "canvas", id = 1, version = 1, h = 4, shapes = {
  { kind = "line", x1 = 0, y1 = 15, x2 = 39, y2 = 15, group = "LugDim" },
  { kind = "points", xy = { 10, 13, 11, 12, 12, 12, 13, 13 }, group = "LugSlug" },
  { kind = "text", x = 1, y = 0, text = "a slug" },
} }

Any entry may also have these:

A chunk { text, group, true } is decoration: a selection or yank leaves it out, as it leaves out fold arrows, boxes and code blocks' frames, and a row of nothing else.

lug.transcript.items() gives each conversation item as an entry with its id and its version, which goes up whenever the item changes. Rust lays out markdown, code and diffs and keeps the rows, so an entry that has not changed costs nothing on the next frame; it is laid out again only when it is written or the width changes. A transform that returns a markdown, code, diff or ansi entry hands Rust the text, never rows.

An item keeps its id for its lifetime: a streaming answer and a tool call going from pending to done change their version, never their id, and leave every other item alone. Redraws, focus changes and resizes change no version. A new width lays the entries in view out again, but nothing is projected again. items() builds fresh tables on every call, so a plugin that follows the conversation does it in lug.transcript.transform, which gets only what is new or changed.

lug.transcript.transform(fn) adds a step to how the conversation becomes the transcript's entries. fn gets each item as an entry when it is new and again whenever it changes: once for each change to a streaming answer, and once for each step of a tool call. It returns the entry, changed or not; nil to leave the item out; or a list of entries to show in its place. Transforms run in the order they were added, each on every entry the one before gave, so plugins' transforms compose; the stock runtime's sets tool cards' summaries while loading, and draws the look once loading ends, so a transform a plugin adds while loading sees the items. Where one fails, the entries it was given go on as they were. After three errors it is detached. The function transform returns removes it.

lug.transcript.transform(function(entry)
  if entry.kind == "tool" and entry.name == "read" then
    entry.summary = entry.args.path
  elseif entry.kind == "user" then
    return { { kind = "text", lines = { { { os.date("%H:%M"), "LugDim" } } } }, entry }
  end
  return entry
end)

Any other feed is filled with lug.feed.upsert, which adds an entry at the end or replaces the one with the same id:

lug.feed.upsert("log", { kind = "text", lines = { "build started" } })
lug.ui.open({ kind = "feed", id = "log" }, { split = "below", win = "transcript", size = 8 })

An id may be a string, which the feed keeps for the entry; lug.feed.entry gives it back. { before = id } or { after = id } puts a new entry beside one the feed has; an entry already there is replaced where it is. A view scrolled up holds its place, the cursor staying on its entry:

lug.feed.upsert("steps", { id = "build", kind = "text", lines = { "build" } })
lug.feed.upsert("steps", { id = "lint", kind = "text", lines = { "lint" } }, { before = "build" })

Keep to numbers or to strings in one feed: a string's entry is given a number no entry had.

A conversation that is not this session's, a sub-agent's from lug.agent.call's on_item or a saved one from lug.session.items, goes in a feed with lug.feed.item(name, id, item). The item is drawn exactly as the transcript draws it: through lug.transcript.transform, the look included, and drawn again when a transform is added or removed. Calling it again with the same id replaces what that item showed, wherever it is in the feed, and nil removes it. Items and lug.feed.upsert's entries share a feed's ids, in the order they were first added, and a new item takes before or after as an entry does. An item a transform leaves out has no entry to put another beside.

for i, item in ipairs(lug.session.items(id)) do lug.feed.item("agent", i, item) end

### Text components

A text component draws lines, each a string or chunks { text, group, decoration? }, then the rows of its content, from line scroll on, and no further than its last screenful. group fills its rectangle first, and id and mode let it take the focus. content is a list of markdown, code, diff, ansi and canvas entries, with the fields of the feed entries above. Rust lays them out at the component's width and keeps the rows, so no rows pass through Lua. An entry with both an id and a version is kept by them and the width (with a code block's lang or a diff's path), and its text is not compared again, so change the version whenever the text changes. Otherwise it is kept by its text. lug.ui.markdown, code and diff return the same rows as lines, for a plugin that changes them.

local notes = { kind = "markdown", text = "# Notes\n\n- one", id = 1, version = 1 }
lug.ui.open({ kind = "text", id = "notes", lines = { { { "pinned", "LugDim" } } }, content = { notes } },
  { split = "above", win = "transcript", size = 8 })

## Windows

The screen is windows side by side and stacked, with the statusline under them. Each window shows a view: a component without x, y, w, h, which is a table Lug reads again every frame, so changing its fields changes what is drawn. A view is shown at most once in a tab, and its window is named by it: a feed's or a text component's id, or transcript or prompt. Focus is by that name too, so lug.ui.focus(name) focuses a window.

A plugin opens a view and leaves where it goes to the screen: lug.ui.open(view) splits the focused window in the direction of the split option (right by default), half and half. opts asks for something else:

A view already shown in the tab is not opened again, and open returns its window's name. Opening does not move the focus, but for replace. Closing a window that replaced another puts that one back in its place, with the focus if the closed one had it, unless its view is shown elsewhere in the tab by now; replaces nested in one place unwind in order. Closing any other window gives its place to its neighbour, and if it had the focus, the first window that can take it does. A window that should give its place away instead is closed first, then the new one opened.

### Decorating windows

lug.ui.decorate, when a plugin sets it, is asked every frame how to draw each window around its view. It gets the window, { name, view, focused, tab, x, y, w, h }, and returns nil to draw it bare, or:

The view is drawn in what is left. A rule between windows that all have a border edge beside it is left out, so boxes stand apart with a gap. An error in decorate is shown in the window's top row, and the window is drawn bare. The stock runtime sets it: its lug.windows module is the place to see one.

lug.ui.decorate = function(win)
  if win.name == "transcript" then return { padding = { 1, 2, 0, 2 } } end
  return {
    border = { "╭", "─", "╮", "│", "╯", "─", "╰", "│" },
    title = " " .. (win.view.title or win.name) .. " ",
    dim = not win.focused and 0.4 or nil,
  }
end

Fading is a component of its own, which a plan may use anywhere: { kind = "dim", amount, x, y, w, h } mixes the colours the components before it drew in its rectangle amount of the way to the background, the cell's own or else LugNormal's, where they are RGB, and gives the rest the terminal's faint attribute.

The stock windows, the transcript and the prompt below it, are opened by basic.lug's lua/lug/layout.lua. Shadow it with ~/.config/lug/lua/lug/layout.lua to start with other windows:

-- ~/.config/lug/lua/lug/layout.lua: the transcript over a 3-row prompt, no statusline.
lug.statusline.left, lug.statusline.right = {}, {}
lug.ui.open({ kind = "transcript" })
lug.ui.open({ kind = "prompt" }, { split = "below", size = 3 })

## The statusline

The bottom row is the statusline, drawn from the segments in lug.statusline.left and lug.statusline.right: functions of no arguments that return nil, a string or chunks, called once per frame while the screen is composed. left runs from the left edge and right ends at the right edge, and when they do not fit, the segment nearest the middle on the wider side goes. A segment that fails shows its error in its place. With no segments there is no statusline row. The stock runtime adds its segments to both lists, so a plugin adds its own beside them:

table.insert(lug.statusline.right, function()
  return os.date("%H:%M")
end)

## Tabs

Each tab is a screen of windows of its own, and one is shown at a time. Lug starts with one. lug.ui.tab_open() adds an empty tab and returns its id; lug.ui.open(view, { tab = id }) puts windows in it without showing it, and lug.ui.tab_focus(id) shows it, giving the focus back to the window that last had it there. A view is shown at most once in a tab, but may be in several tabs, as the prompt is when a plugin gives its tab one too; the view is the same, so its text, scroll and folds are too. lug.ui.close(name) and lug.ui.resize act on the shown tab's window when it has one, else on the first tab that does, and lug.ui.windows() lists the shown tab's. lug.ui.tab_close(id) closes a tab and its windows; the only tab can't be closed.

-- A run of something, on a tab of its own, until it's done.
local tab = lug.ui.tab_open()
lug.ui.open({ kind = "text", id = "run", lines = { "running" } }, { tab = tab })
local back = lug.ui.tab_focus()
lug.ui.tab_focus(tab)
-- later
lug.ui.tab_focus(back)
lug.ui.tab_close(tab)

A window opened without tab goes in the shown tab, so what opens while a user watches one tab shows up there.

## Layouts: lug.bsp

Windows are the leaves of a tree from require("lug.bsp"), which divides a rectangle between named leaves. It is plain Lua that a plugin can use for anything it lays out.

Name Arguments Returns
bsp.leaf(name, pane?) A name, once in the tree; a component, or fn(rect) returning one A leaf
bsp.hsplit(size, first, second) A size; two children, each a leaf or another split A split, first on the left. Its sep = true rules its children off with a line
bsp.vsplit(size, first, second) As hsplit A split, first on top
bsp.ratio(r) The first child's share, from 0 to 1 A size
bsp.fixed(n) / bsp.fixed_second(n) Cells for the first or the second child, or n(rect) returning them A size; the other child takes the rest
bsp.replace(tree, name, fn) A tree; a leaf's name; fn(leaf) returning a tree A new tree with that leaf replaced, sharing the rest
bsp.remove(tree, name) A tree; a leaf's name A new tree whose leaf's sibling takes its split's place
bsp.plan(tree, width, height) A tree; a size Each leaf's component in its rectangle, then { { name, x, y, w, h }, … }

A tree holds no sizes of its own. It is resolved from the rectangle down, and a size given as a function sees its split's rectangle then ({ x, y, w, h }), which is how the prompt grows with its text. Each leaf's name is in the tree once and is not empty, or bsp.plan raises.

## Tasks: lug.task

A coroutine that waits can be cancelled, a timer is a sleep, and independent waits can run together. lug.sleep and lug.cancel are the mechanism; require("lug.task"), built into Lug, runs functions as coroutines of their own on top of them. A plugin may also start a coroutine itself: a waiting function yields whichever coroutine calls it.

Name Arguments Returns
lug.sleep(ms) Milliseconds, 0 or more true once they have passed. Waits. A headless run waits for it.
lug.cancel(co) A coroutine true when it was waiting: the work it waits on stops (a process group is killed, an agent's run ends, a dialog closes, a lug.tool.call never runs), and on the loop's next turn the waiting function returns nil, "cancelled", so its cleanup runs. false when it was not waiting, or was cancelled already.
lug.worker.run(fn, ...) A Lua function that uses no local from outside it, and plain values What fn returned, run in a fresh Lua state on a thread of its own: the standard library and package.path, but no lug. Arguments and results cross as JSON; an error, or a result that is not a plain value, gives nil and the message. Waits; lua_timeout does not stop it, and cancelling the caller does.
task.run(fn, ...) A function and its arguments A task, at once: fn(...) runs as a coroutine of its own. An error in it ends the task with nil and the message.
t:wait() — What fn returned, once it has. Waits, unless it has ended.
t:done() — Whether it has ended
t:status() — What the lug.agent.call the task waits on now is doing, as lug.agent.status says; nil when it waits on none
t:cancel() — true: the task ends with nil, "cancelled" and lug.cancel stops what its coroutine waits on; false when it had ended
task.all(tasks) A list of tasks Each task's first value, in order, once all have ended; or, when one ends with nil and a message, that, after cancelling the others still running. Waits.
task.first(tasks) A list of tasks The index of the task that ends first, then its values; the others are cancelled. Waits.
local task = require("lug.task")

-- every file's diff at once
local jobs = {}
for i, file in ipairs(files) do
  jobs[i] = task.run(lug.proc.run, { "git", "diff", "--no-index", "/dev/null", file })
end
local diffs = task.all(jobs)

-- a step that can be stopped: its agent's run ends, and wait() gives nil, "cancelled"
local step = task.run(lug.agent.call, spec)
step:cancel()

-- a timeout
local which, result = task.first({ task.run(lug.proc.run, argv), task.run(lug.sleep, 5000) })

Cancelling a task does not cancel tasks it started. A cancelled coroutine is resumed, not closed, so it may go on to wait again; a loop that should stop checks what its waits return. A long loop in Lua can yield with lug.sleep(0) to stay within lua_timeout, or run in a worker when it needs no lug and its input and output copy cheaply:

local hunks = lug.worker.run(function(patch)
  return require("hunk.diff").parse(patch)   -- plugins' modules are on package.path
end, patch)

## Dialogs and the terminal

The dialogs are Lua built into Lug, require("lug.dialog"): boxes over the screen, shown one at a time in the order asked. The one shown has the focus, so its keys are the input, confirm or picker mode's, and accept(), cancel() and move(by) are what they do. A picker's keys stand over the picker mode's while it shows.

Every dialog's spec may say where it belongs and who asks:

A dialog with a place shows, and takes the focus, only while its tab is shown; others wait behind it in their own tabs, and what is typed in one is kept while its tab is hidden. One with no place shows over any tab, as dialogs always have.

Name Arguments Returns
lug.ui.select(spec) { prompt, items = { { label, hint?, value? } }, sort?, preview?, on_change?, keys?, win?, tab?, kind? } The chosen value or item, or nil. Waits.
lug.ui.input(spec) { prompt, default?, message?, win?, tab?, kind? }; message is shown over what is typed, as a confirm's is The text, or nil. Waits.
lug.ui.confirm(spec) { prompt, message?, win?, tab?, kind? } Boolean. Waits.
lug.tool.asking() — The call the approval dialog asks about, { name, args, json }, its arguments as edited and as JSON text; or nil
lug.tool.answer(decision) "allow", "always", "deny" or "stop" Nothing: closes the approval dialog so
lug.tool.edit(args) A table, or JSON text of an object true: the call runs with them, and the preview follows; or nil and why when the text is not an object
lug.ui.notify(text, level?) Text; "info" (default) or "error" Nothing
lug.ui.focus(target?) "prompt", "transcript", a feed's name, a text component's or line editor's id, or nil to ask The focused one's name
lug.ui.suspend(argv) A program and its arguments Its exit code, or nil, message when it could not start. Waits.
lug.wait(fn) fn(done), called at once; a function it returns is what cancelling the coroutine calls, to close what it opened What done(...) is first given, from a keymap, say. Waits. A headless run takes it as waiting for keys, unless work is running.

lug.ui.select, lug.ui.input and lug.ui.confirm may be replaced by assigning your own, as vim.ui.select may in Neovim, and every plugin that asks then gets yours. A replacement keeps the contract above: it waits (with lug.wait), answers what the stock one would (nil, or false for a confirm, when dismissed), respects win and tab or at least never takes the keys in a tab not shown, and calls a picker's on_change and preview with the highlighted item each time it changes, as the stock picker does. Keep the stock one to fall back on: local select = lug.ui.select.

To change only how the dialogs look, replace one of require("lug.dialog").draw's select, input and confirm. Each is fn(dialog, width, height) and returns the components drawn, placed within the width by height cells the dialog is over (the screen, or its window). dialog is plain values: { kind, prompt, message?, items?, at?, preview? }, where a picker's items are the { label, hint } matching what is typed, at the highlighted one's place among them, and preview its lines. What it returns must hold the dialog's editor, a prompt component with id and mode picker or input, or for a confirm a component with id and mode confirm, since those take the keys.

local dialog = require("lug.dialog")
dialog.draw.select = function(d, width, height)
  local line = { { " " .. d.prompt .. " ", "LugModeNormal" } }
  for i, item in ipairs(d.items) do
    line[#line + 1] = { " " .. item.label .. " ", i == d.at and "LugPickerSel" or "LugDim" }
  end
  return {
    { kind = "text", lines = { line }, x = 0, y = 0, w = width, h = 1 },
    { kind = "prompt", id = "picker", mode = "picker", x = 0, y = 1, w = width, h = 1 },
  }
end

## The session and the model

Name Arguments Returns
lug.session.send(text) Text true, and sent fires; or nil and why it could not be sent
lug.session.steer(text) Text true; or nil and why. The text joins the run in progress, as a user message after the tool results the model waits on. When the model is not waiting on a tool, or nothing is running, it is sent as the next message, ahead of what is queued
lug.session.cancel() — Nothing
lug.session.compact(instructions?) Text true, and compacted follows; or nil and why
lug.session.rename(title) Text Nothing
lug.session.workspace() — The workspace's absolute path: the directory lug started in, which sessions, streams and trust belong to
lug.session.cd(path?) A directory, relative to the workspace; nil asks Where the session works, as an absolute path; or nil and why when path is not a directory. Given a path, the session works there from its next run: its built-in tools, :!, and lug.proc.* without a cwd. The model is told. The session records it and comes back there when continued, or to the workspace if the directory is gone. See working directories.
lug.session.status() — { id, busy, started?, queued, usage?, retry? }: whether a run is in progress or waiting, when the run in progress started (seconds since the epoch), how many sends and compactions wait for it, the last model call's usage that counted input (how full the context is; a compaction clears it), and the retry it waits to make, { attempt, delay_secs, reason }
lug.session.add_context(name, text?) A heading; text, or nil Nothing: from the next run, the system prompt has text under the heading, in place of what it had there; nil removes the section
lug.session.tools(which?) A list of tool names; true for every tool; nil asks The names of the tools the model is offered from the next run. A list offers only those, tools registered with model = false among them, so a plan mode can take write, edit and bash away; true offers every tool registered for the model again. An unknown name raises. :reload offers every tool again.
lug.session.system() — The system prompt the session's next run sends: the system_prompt option, then each context section. An agent's default.
lug.session.approve(fn) approve(call), or nil to remove it Nothing: asked about each of the session's calls that would open the approval dialog, in its place, as lug.agent.call's approve is (see typed agent calls)
lug.session.list(opts?) { all? } { { id, title, workspace, updated, entries, open, first, last }, … }, newest first
lug.session.open(id) A session id true; or nil and why during a run or for another workspace's session
lug.session.delete(id) A session id true; or nil and why for this lug's session or an open one
lug.session.items(id) A session id, or a conversation lug.agent.call saved Its items, as lug.transcript.items() gives this session's, or nil and why. It only reads, so the session may be open.
lug.auth.set(provider, key) A provider name; a non-empty key true: saves it in ~/.lug/auth.json; or nil and why the write failed. Raises on an unknown provider.
lug.model.current() — { id, context_window?, wire? }; context_window from the provider's last listing; wire, the API the model speaks (anthropic, openai, openai-chat, openrouter, ollama, or mock under --mock), nil for a provider Lug does not know
lug.model.list() — The current provider's { { id, name?, context_window? }, … }, or nil, message when listing fails. Waits on the provider's listing.
lug.model.listed() — The current provider's last listing, as list gave it, without waiting; nil before one has been fetched
lug.agent.call(spec) { prompt, name?, input?, output?, system?, model?, thinking?, max_turns?, max_tokens?, mode?, tools?, dir?, on_event?, on_item?, approve?, save?, from? } The answer and { model, usage, session?, messages? }, or nil, a message, and the same table with reason. Waits. See typed agent calls.
lug.agent.status(co) A coroutine What the lug.agent.call it waits on is doing: { phase, tool?, attempt?, usage }, phase one of thinking, writing, running (tool runs), approval (a call to tool waits for a rule, approve or the dialog) or retrying (attempt), and usage the tokens its turns have taken so far; nil when it waits on none. A task's t:status() reads it for the task (see tasks).
lug.agent.list() — Every live agent: the session first, then each a plugin started and has not closed, as { id, name, session?, parent?, dir, busy, queued, started?, phase, tool?, attempt?, usage }. id is its conversation's, session is true for the session, parent is the agent whose tool call started it, dir is where it works, busy says a run is in progress or queued, queued how many wait, started is when its run in progress started (seconds since the epoch), and the rest is as lug.agent.status says, usage over its life.
lug.agent.start(spec) As lug.agent.call's, without prompt and input, with meta? A handle, at once; or nil and why: from cannot be opened, or dir is not a directory. See agent handles.
lug.agent.get(id) An agent's id Its handle, the session's included; nil when it is not live
lug.reload() — Nothing; an error notice during a run. The old state's coroutines end: the dialogs they opened close, and a Lua tool's call in progress fails with reloaded.

## Tools and processes

Name Arguments Returns
lug.tool.register(name, spec) { description, handler, parameters?, output?, ask?, model?, recovery?, recover? }; handler(args, ctx) runs as a coroutine Raises on a bad spec, a schema it cannot check, or a taken name
lug.tool.call(name, input?, ctx?) A tool's name, built in or Lua's; its arguments; a table the handler gets as ctx, whose dir (relative to the workspace, the session's by default) is where a built-in tool works The result (a table when the tool has an output schema, else text), or nil, message when the input or output does not fit, the tool fails, or the call is denied, or ctx.dir is not a directory. Asks in the approval dialog as the model's calls do, or ctx.approve in its place (see typed agent calls). Waits.
lug.tool.list() — Every tool: { { name, description, parameters, output?, recovery, model, ask }, … }
lug.tool.recover(name, input, ctx?) As for call What the tool's recover returns: "done", output, "not_done" or "unknown"; "unknown" when it has none. Waits when recover does.
lug.tool.rule(name, rule) A tool, or "*" for every tool; "allow", "ask", "deny", rule(args, call), or nil to remove the tool's rules; rule may wait, and the call waits for it. A second value it returns is the reason: shown in the dialog with "ask", given to the model with "deny" A function that removes this rule. call is { id, tool, agent? }, agent the { id, name } of the agent that made the call, absent for lug.tool.call. Rules run in the order they were made: any deny denies, even in auto mode; else any ask asks (see tools)
lug.tool.grants(agent?) An agent's name; nil for the session The tools allowed always for that agent, sorted. lug.tool.call counts on the session's.
lug.tool.revoke() — Nothing: revokes every grant
lug.proc.run(argv, opts?) A list of strings; { timeout?, cwd?, env? }: seconds (120); a directory, relative to the workspace; variables added to Lug's environment { code, stdout, stderr }, code nil when killed; or nil and why when cwd is not a directory. Runs in cwd, else where the session works, unsandboxed for now, stdin empty, in a process group of its own that the timeout or lug.cancel kills, as bash does. Waits.
lug.proc.spawn(argv, opts?) A list of strings; { cwd?, env?, on_stdout?, on_stderr?, on_exit? }, cwd and env as run's A handle with :write(text), :close() (ends stdin) and :kill(), at once. Runs in cwd, else where the session works, unsandboxed for now, with no timeout, in a process group of its own that :kill() kills. nil and why when the program cannot start or cwd is not a directory.

lug.proc.spawn starts a process in the background. on_stdout(line) and on_stderr(line) get each line without its newline, and on_exit(code) gets the exit code, nil when killed, after the last line. Each call runs as a coroutine, so it may wait. stdin stays open until :close(). :write returns nil and a message once stdin is closed or the process has exited; :close and :kill then do nothing. After :kill(), lines not yet delivered are dropped. A callback that fails 3 times kills the process, with a notice. lug.reload and quitting kill every spawned process. A first message given on the command line (lug "...") waits for spawned processes to exit, so plugins haul is still cloning load first, and so does a headless run.

A tool's parameters and output are JSON Schema, checked on every call, from the model or from Lua. Lug checks this subset: type, properties, required, items, enum, const, additionalProperties, minimum and maximum, plus description, title, default and examples, which only describe. A schema using anything else is refused when the tool is registered. An empty Lua table fits both object and array.

With output, the handler returns a table, the model sees it as JSON, and lug.tool.call returns it as a table. model = false keeps the tool from the model, so only Lua calls it. recovery says what running the tool again does, for a caller that cannot tell whether a call finished: "pure" (nothing), "idempotent" (the same as once), or "unknown", the default. Lug stores it and acts on none of it. recover(input, ctx) checks whether an interrupted call took effect. The built-in read, grep and ls are pure, write is idempotent, and edit and bash are unknown.

When no one wants a call's result any more, because the run that made it was cancelled or stopped, or the coroutine that called lug.tool.call was cancelled, the handler is cancelled as lug.cancel cancels it: what it waits on stops (a sub-agent's run ends, a process group is killed, a dialog closes), the waiting function returns nil, "cancelled" so its cleanup runs, and what it returns goes nowhere.

Handlers run on the UI thread with every agent's callbacks and the screen, so a handler with long stretches of pure Lua (parsing a large output, say) does them in lug.worker.run, which a cancelled call stops too.

ctx says who called: { caller = "model", id, agent, dir, progress }, id being the model's id for the call, agent the handle of the agent whose model made it (its id and name among the rest, see agent handles) and dir where that agent works; or for lug.tool.call the table given, with caller = "lua", call and id, an id for this one call, and dir made absolute (the session's when not given). A handler that runs programs passes cwd = ctx.dir, so it works where its caller does. A lug.agent.call the handler makes has that agent as its parent. ctx.progress(value) reports how the call is getting on: the value, plain data, becomes the call's item's progress while it runs, so the transcript can draw it, and a tool_progress event of the run that made the call.

handler = function(args, ctx)
  return lug.agent.call({ name = "research", prompt = args.question, tools = { "read", "grep", "ls" },
    on_event = function(e)
      if e.type == "tool_call_requested" then ctx.progress(e.call.name) end
    end })
end

A caller that may run a call again passes a key that stays the same each time, which the tool can use to do the work once.

lug.tool.register("git_diff", {
  description = "The diff from a base ref",
  parameters = { type = "object", required = { "base" }, properties = { base = { type = "string" } } },
  output = { type = "object", required = { "diff" }, properties = { diff = { type = "string" } } },
  recovery = "pure",
  ask = false,
  handler = function(input, ctx)
    return { diff = lug.proc.run({ "git", "diff", input.base }).stdout }
  end,
})

local out, err = lug.tool.call("git_diff", { base = "main" }, { key = "release/diff" })

A timer is a task that sleeps (see tasks):

require("lug.task").run(function()
  lug.sleep(5000)
  lug.ui.notify("five seconds")
end)

## Typed agent calls

lug.agent.call(spec) makes one model call with no history, and stays out of the transcript. prompt says what to do, and input, any plain value, follows it as JSON. With output, a JSON Schema as tools take, the answer is a table that fits it; without, the answer is text. system replaces the system prompt, which lug.session.system() gives to build on, and model the session's model, for this call only. thinking, max_turns and max_tokens do the same for the options of those names (max_tokens is the output-token cap per model call). mode says how the provider is held to the schema: "native" (its structured output), "tool" (a tool the model calls with its answer) or "prompted" (the schema in the prompt); left out, Lug lets Rig choose, except on providers that relay other vendors' models (opencode-go, openrouter and ones added to lug.opt.providers), where many models fail a request that carries a schema, so it is always a tool. An answer that is not JSON, or does not fit, returns nil, message, info, and the caller decides whether to ask again. Cancelling the caller (see tasks) ends the run at once: no later tool call runs, and the calls it holds leave the dialog.

local plan, info = lug.agent.call({
  prompt = "Which files need changing to fix this?",
  input = { error = stderr },
  output = { type = "object", required = { "files" },
             properties = { files = { type = "array", items = { type = "string" } } } },
})
-- plan.files; info.model, info.usage.input_tokens, info.usage.output_tokens

A plugin names a kind of model rather than a vendor: model = "@fast" is the model the user maps fast to in lug.opt.model_roles, and the session's model while they have not, so a plugin never fails for want of a provider's key. Lug's documented roles are fast and strong; any other name works the same way.

-- init.lua: what the roles mean
lug.opt.model_roles = { fast = "openrouter/qwen/qwen3-coder", strong = "anthropic/claude-opus-5" }
-- a plugin
local msg = lug.agent.call({ prompt = "Write a commit message.", input = diff, model = "@fast", max_turns = 1 })

Every call runs in a runtime of its own, as the session's runs do, from a fresh conversation that is not saved and stays out of the transcript, with the same retries. With tools = true it gets every tool the model is offered, runs them through the same approvals (the dialog names the agent), and keeps going until it answers. A list of tool names, tools = { "read", "grep", "ls" }, offers it only those, and may name a tool registered with model = false: a model is never offered a tool it may not use, so this holds in auto mode too, where approve is never asked. A call to a tool it was not offered fails the run. The list may also hold tools of the agent's own, specs as lug.tool.register takes them with a name (and no recover): only that agent is offered one, it may take a registered tool's name, which it then means for that agent alone, and it goes when the agent's run ends. A report tool the model calls once per finding hands a plugin results as they come, while output stays for the answer.

lug.agent.call({
  name = "security", prompt = "Review the diff for security problems.",
  tools = { "read", "grep", "ls",
    { name = "report", description = "Report one finding as soon as you find it.",
      parameters = finding, ask = false,
      handler = function(f) lug.feed.upsert("review", entry_for(f)) end } },
})

With output, Rig holds the model to the schema (the provider's structured output where it works, else a tool the model answers through, unless mode says which; see above), and Lug checks the answer; one that does not fit is asked for once more, in the same conversation, before the call returns nil, message. on_event(event) is given each of the call's events as it happens, as the tables lug.on hooks get (see events): enough to show "thinking", "running bash" or "waiting for approval" while it works.

local done = lug.agent.call({
  prompt = "Fix the failing test in src/cli.rs.",
  tools = true,
  output = { type = "object", required = { "summary" },
             properties = { summary = { type = "string" } } },
})

name is what the dialog, approve, rules, lug.agent.list and the agent_started and agent_finished events call the agent, agent when it is left out. A tool allowed always is allowed for every agent of that name, so a reviewer's grants are not the session's.

on_item(item) is given each item of the agent's conversation when it is new and again when it changes, as lug.transcript.items() gives this session's, for lug.feed.item to show.

approve(call) is asked about each call that would open the approval dialog, in its place: the mode, rules and tools allowed always are applied first, as for any call. It gets { id, name, args, why, preview, agent? } (preview has the dialog's path, created, notes, and text or the diff's old and new), runs as a coroutine, may wait, and returns what the dialog's keys would: "allow", "always" (and the tool is allowed always for this agent), "deny" or "stop" (deny and end the run), with a reason for a denial. It is logged in approvals.log as the dialog's answers are. An error, or anything else, opens the dialog after all, with a notice. lug.tool.call(name, input, { approve = fn }) asks the same way.

With tools, save = true saves the conversation as the session is saved, as a stream of kind agent, so it stays out of lug.session.list. on_event first gets { type = "agent_started", session = id }, and info.session and info.messages give its id and how many messages it holds when the call returns. from = { session = id } continues a saved conversation, prompt being its next message, and from = { session = id, at = n } continues a copy of its first n messages, saved as a new one, leaving the old one whole. id may also be a session's, this one's (lug.session.status().id) included: that is always copied, whole or up to at, so a side question or a reviewer starts from the conversation itself. on_item gets the conversation from its start either way. lug.session.items(id) reads one back.

local side = lug.agent.call({ prompt = "Why a BTreeMap there?", from = { session = lug.session.status().id } })
local q, info = lug.agent.call({ prompt = "Plan the fix.", tools = true, save = true, output = schema })
-- later, with the user's answer as the next message:
local plan = lug.agent.call({ prompt = answer, tools = true, output = schema,
  from = { session = info.session } })

A call that fails returns nil, a message for a person, and info as a call that answers does, with reason for the caller to act on: cancelled (the run was cancelled), stopped (an approval said stop), max_turns, misfit (the answer did not fit, twice), refused (the model declined) or failed (a provider error, or anything else). info.usage is what the run spent, and a saved conversation's info.session can be continued with from.

local plan, err, info = lug.agent.call(spec)  -- spec.save is true
if not plan and info.reason == "max_turns" then
  plan, err, info = lug.agent.call({ prompt = "Stop exploring and give the plan now.",
    tools = true, output = spec.output, from = { session = info.session } })
end

Cancelling the caller itself with lug.cancel returns nil, "cancelled", as any wait does.

### Agent handles

lug.agent.call is one answer. lug.agent.start(spec) starts an agent a plugin holds and gives its handle at once: the agent keeps its runtime, and with it its conversation, until it is closed, so each message continues it. The spec is lug.agent.call's without prompt and input, and with save, meta, a table, is kept in the header of the stream it starts, where lug.stream.list("agent") finds it after a restart. A from it cannot open gives nil and why. lug.agent.call is start, send and close in one.

Method Arguments Returns
a:send(prompt, opts?) Text; { input?, output?, mode? } for this message, output in place of the agent's The answer and info, or nil, why and info, as lug.agent.call gives them. Waits.
a:queue(prompt, opts?) As send true: it runs after what is queued, and nothing waits for it; or nil and why
a:steer(text) Text true, or nil and why; the text reaches its run as lug.session.steer's reaches the session's, and nothing waits for it
a:compact(instructions?) Text true once its conversation is compacted, or nil and why. Waits.
a:cancel() — Nothing: ends the run in progress and drops what is queued; what waits on them gets nil, why and reason = "cancelled"
a:status() — Its row of lug.agent.list, nil once it is closed
a:items() — Its conversation, as lug.transcript.items() gives the session's
a:on(event, fn) One of the run events (see events), or "item" A function that removes the hook. Only its own events reach it; lug.on hears the session's. With "item", fn(item) is given its conversation from the start, then each item again whenever it changes, as on_item is, so a window can show an agent it did not start; the session's raises, since the transcript shows it
a:close() — true once its run has ended and its conversation is let go, so from can continue a saved one. Waits. Its own tools go with it.
a.id, a.name — Its conversation's id, and its name

Cancelling a coroutine that waits on send or compact cancels the agent as a:cancel() does. A handle needs closing: an agent left open runs until :reload. lug.agent.get(id) gives a handle on any live agent, the session's included, so a panel can watch or cancel agents it did not start; the session's send takes no output and its close raises. A tool handler's ctx.agent is the handle of the agent whose model called it.

local fixer = lug.agent.start({ name = "fixer", model = "@fast", tools = { "read", "grep", "edit" } })
local answer = fixer:send("The tests fail; fix them.", { input = { log = log } })
fixer:queue("Now run the whole suite.")
local off = fixer:on("tool_finished", function(e) progress(e) end)
fixer:close()

Under --mock, a script's { "json": value } event answers with value as JSON. Every runtime shares the script's turns, each model call taking the next, so agents running at once would take them in whatever order they happen to ask. A script gives an agent turns of its own under agents, by its name, and agents it does not name share turns as before, so a test of five reviewers at once plays the same way every time.

{
  "turns": [[{ "tool_call": { "name": "review", "args": {} } }], [{ "text": "Two findings." }]],
  "agents": {
    "security": { "turns": [[{ "tool_call": { "name": "report", "args": { "line": 12 } } }], [{ "text": "done" }]] },
    "style": { "turns": [[{ "text": "nothing to report" }]] }
  }
}

### Working directories

The workspace is the directory lug started in; sessions, streams and trust belong to it. Each agent also has a working directory, where its built-in tools read, write and run commands: the session's by default, which is the workspace until lug.session.cd moves it. dir in lug.agent.call and lug.agent.start sets an agent's own, so several agents can work at once in as many git worktrees, each told where it works. Lug knows nothing of git: a plugin makes the worktree and passes its path.

local dir = "../wt/fix-login"   -- made by a plugin with `git worktree add`
local fixer = lug.agent.start({ name = "fixer", dir = dir, tools = true })
fixer:send("Fix the login redirect.")
local status = lug.proc.run({ "git", "status", "--short" }, { cwd = dir })

A Lua tool's handler gets its caller's directory as ctx.dir, lug.proc.* take cwd, and lug.tool.call runs a built-in tool in ctx.dir. The approval dialog says where a call runs when that is not where the session works. A worktree's own .lug/init.lua is never loaded.

## Streams

A stream is a durable list of events a plugin keeps: a workflow's steps, a todo list, a memory. It is a file of JSON lines under Lug's state directory, streams/KIND/ID.jsonl, and it outlives lug. What the events mean is the plugin's; Lug only appends, reads, lists and holds them. One lug holds a stream at a time; a reload or quitting gives back every stream it holds.

Sessions are streams too, of kind session, and lug.agent.call's saved conversations of kind agent. Their header's meta is { rig, model, title }, and their events are message (data.m is the message as Rig encodes it, data.n its place), compacted (data.summary, the whole summary message, and data.kept, how many messages it kept) and meta (a new title or model). A plugin can list and read them, to search or export sessions, but only Lug writes them: lug.stream.create and open refuse those kinds. lug.session.items reads one as the transcript shows it.

Name Arguments Returns
lug.stream.create(kind, meta?) A kind: letters, digits, _ or -; a table kept with the stream Its id, "kind/k3f9x2ab", held by this lug; or nil and why
lug.stream.open(id) A stream's id Its version: its last event's seq, 0 for none; or nil and why when another lug holds it
lug.stream.append(id, events, opts?) A stream this lug holds; { { type, data?, meta? }, … }; { expected? } The new version; or nil and why when the stream is not at expected, or an event is over 1 MiB. Raises when this lug does not hold it.
lug.stream.read(id, opts?) { from?, limit?, type? } events, header: { { seq, at, type, data, meta }, … } from seq from (default 1), and { id, kind, workspace, created, meta }; or nil and why
lug.stream.list(kind?, opts?) A kind, or nil for all; { all? } for every workspace's { { id, kind, workspace, created, meta, updated, version, open }, … }, most recently changed first
lug.stream.close(id) A stream's id Nothing: gives it back

The events of one append are written together. expected makes an append fail when someone appended since you last read, so two writers never interleave. meta on an event is for the plugin's own bookkeeping, such as which event caused it. A lug killed while writing loses at most the event it was writing.

local id = lug.stream.create("todo", { title = "Release" })
lug.stream.append(id, { { type = "added", data = { text = "tag v0.0.2" } } })
for _, e in ipairs(lug.stream.read(id)) do print(e.seq, e.type, e.data.text) end

## Lug itself

Plugins are installed by haul, a plugin like any other, whose README documents require("haul"). See Configuration.

Name Arguments Returns
lug.version — Lug's version, like "0.0.1"
lug.dirs — { config, data }: where init.lua and plugins/ live
lug.docs — The files lug docs lua writes, by name ("lug.lua", "lua-api.md", "lug/bsp.lua"...), as text

## Testing a plugin

lug test runs a plugin's tests inside the real Lug, headless, rather than against a stand-in lug table. Run it in the plugin's folder:

lug test                                   # every tests/test_*.lua
lug test tests/test_panel.lua              # the files named
lug test --with ../basic.lug --mock script.json

Each file runs in its own lug, with a fresh home, so no config, plugin, session or API key of yours reaches it, and an environment cleared but for PATH. Its workspace is an empty folder of its own, so what it runs and writes never touches the plugin's folder, and the files beside it can be required as helpers (require("fixtures") for tests/fixtures.lua). The plugin in the folder loads as haul loads one (its lua/ on the path, then require(NAME)), after each --with folder and after the ones it depends on. A dependency its haul.lua names is never cloned: give its folder with --with, or the file fails. basic.lug is a plugin like any other, so a test that needs its keys (the dialogs', say) names it with --with. --mock FILE is the scripted model, as for lug --mock, and a test_NAME.json beside test_NAME.lua is that file's own.

The file runs as a coroutine once Lug has started, so it may wait on anything a plugin can. It passes when it returns, and fails when it raises (printed with its traceback) or calls os.exit with a status other than 0. One left waiting on what nothing will finish, a dialog no key answers, say, fails too. lug test prints ok or FAIL for each file and exits 1 when any failed. Assertions are plain assert and error.

lug.feedkeys runs what the keys map to at once, but some answers arrive on the loop's next turn, as they would between two keys typed: a Lua tool's handler starts then, and so does a coroutine a dialog answers. lug.sleep(0) lets the loop turn, and draws a frame, so lug.ui.windows() shows what the keys opened.

-- tests/test_greet.lua
local greet = require("greet")
local answer = require("lug.task").run(greet.ask)   -- opens a dialog, waits for it
lug.feedkeys("world<CR>")                            -- through the dialog's mappings
assert(answer:wait() == "world")                     -- waits a turn for the answer
local entries = lug.feed.entries("greet")
assert(entries[#entries].text == "hello, world", entries[#entries].text)

debug.traceback(msg?, level?) is there, alone of the debug library, for error handlers such as xpcall(f, debug.traceback).