-- Expandable progress boards for in-flight children, with a bounded replay -- path that rebuilds durable child turns when a primary session is reopened. local paths = require("subagents.paths") local COLLAPSED_LINES = 4 local MAX_HISTORY_LINES = 512 local MAX_HISTORY_BYTES = 128 * 1024 local MAX_LINE_BYTES = 4096 local MAX_PRESENTATION_BYTES = 32 * 1024 local MAX_PRESENTATION_LINES = 128 local MAX_RENDER_LINES = 4096 local MAX_REPLAY_SESSIONS = 1024 local MAX_REPLAY_MESSAGES = 8192 local MAX_REPLAY_CARDS = 512 local MAX_REPLAY_BLOCKS = 4096 local MAX_REPLAY_BLOCK_BYTES = 64 * 1024 local MAX_REPLAY_MESSAGE_BYTES = 256 * 1024 local BODY_INDENT = " " local PROGRESS_TOOLS = { ["subagents.run"] = true, ["subagents.lua"] = true, ["subagents.workflow"] = true, } local GLYPHS = { running = "◷", completed = "✔", failed = "✖", cancelled = "⊘", } local M = {} local boards = {} local background_workflows = {} local bound = setmetatable({}, { __mode = "k" }) local tools_collapsed = true local sid_width = 8 local replay_board local replaying = true local function sanitize(text) return (tostring(text or ""):gsub("[%z\1-\31\127]", " ")) end local function sanitize_multiline(text) return (tostring(text or ""):gsub("[%z\1-\9\11-\31\127]", " ")) end local function display_tool_name(name) return type(name) == "string" and name:gsub("__", ".") or "tool" end -- A board replaces the whole tool block, so the Lua a `subagents.lua` call ran -- would otherwise never be visible — least of all for an inspection call that -- starts no children and therefore has no cards at all. `tool_call_complete` -- carries the raw input JSON, which a replayed entry gets too, so the source -- survives /resume without any handler-side bookkeeping. local function decode_input(text) if type(text) ~= "string" or text == "" then return nil end local ok, panto = pcall(require, "panto") local json = ok and type(panto) == "table" and type(panto.ext) == "table" and panto.ext.json or nil if type(json) ~= "table" or type(json.decode) ~= "function" then local dk_ok, dkjson = pcall(require, "dkjson") json = dk_ok and type(dkjson) == "table" and dkjson or nil end if type(json) ~= "table" or type(json.decode) ~= "function" then return nil end local decoded_ok, value = pcall(json.decode, text) if not decoded_ok or type(value) ~= "table" then return nil end return value end local function source_of(tool_name, input) if tool_name ~= "subagents.lua" then return nil end local decoded = decode_input(input) local source = decoded and decoded.source if type(source) ~= "string" or source == "" then return nil end -- Tabs are stripped to a single space by sanitization, which would flatten -- indented code; widen them first so the listing keeps its shape. return sanitize_multiline((source:gsub("\t", " "))):sub(1, MAX_PRESENTATION_BYTES) end local function wrap(text, width, emit) for _, row in ipairs(require("panto").text.wrap(sanitize(text), math.max(1, width))) do emit(row) end end local card_mt = {} card_mt.__index = card_mt local function repaint(card) local board = card.board if board and board.handle then pcall(board.handle.invalidate, board.handle) end end local function trim(card) while #card.history > MAX_HISTORY_LINES or card.history_bytes > MAX_HISTORY_BYTES do local removed = table.remove(card.history, 1) card.history_bytes = card.history_bytes - #removed end end local function adopt(card, line) line = sanitize(line):sub(1, MAX_LINE_BYTES) card.history[#card.history + 1] = line card.history_bytes = card.history_bytes + #line trim(card) end local function marker(card, text) card.partial = false if text ~= nil and text ~= "" then adopt(card, text) end end local function append_text(card, text) text = tostring(text or "") local from = 1 while from <= #text do local newline = text:find("\n", from, true) local last = newline and newline - 1 or #text if card.partial and #card.history > 0 then local index = #card.history local old = card.history[index] local room = MAX_LINE_BYTES - #old local chunk = room > 0 and text:sub(from, math.min(last, from + room - 1)) or "" local grown = old .. sanitize(chunk) card.history[index] = grown card.history_bytes = card.history_bytes + #grown - #old trim(card) else adopt(card, text:sub(from, math.min(last, from + MAX_LINE_BYTES - 1))) end card.partial = newline == nil if not newline then break end from = newline + 1 end end local function append_labeled(card, label, text) text = tostring(text or "") local continuation = string.rep(" ", #label) local from, first = 1, true while true do local newline = text:find("\n", from, true) local last = newline and newline - 1 or #text local prefix = first and label or continuation local room = math.max(0, MAX_LINE_BYTES - #prefix) local line = room > 0 and text:sub(from, math.min(last, from + room - 1)) or "" adopt(card, prefix .. line) first = false if not newline then break end from = newline + 1 end end function card_mt:event(event) if type(event) ~= "table" then return end local kind, index = event.type, event.index if kind == "block_start" then self.blocks[index] = event.block_type self.block_had_delta[index] = false elseif kind == "content_delta" then if self.blocks[index] == "text" and type(event.delta) == "string" then append_text(self, event.delta) self.block_had_delta[index] = true end elseif kind == "tool_details" then self.tools[index] = { id = event.id, name = event.name } local detail = display_tool_name(event.name) if event.id and event.id ~= "" then detail = detail .. " [" .. event.id .. "]" end marker(self, "⚒ " .. detail) elseif kind == "block_complete" then if event.block_type == "text" and not self.block_had_delta[index] and type(event.text) == "string" then append_text(self, event.text) elseif event.block_type == "tool_use" then if not self.tools[index] then local detail = display_tool_name(event.name) if event.id and event.id ~= "" then detail = detail .. " [" .. event.id .. "]" end marker(self, "⚒ " .. detail) end if type(event.text) == "string" then append_labeled(self, "input: ", event.text) end end self.blocks[index], self.block_had_delta[index] = nil, nil self.partial = false elseif kind == "tool_dispatch_result" then for _, result in ipairs(type(event.tool_results) == "table" and event.tool_results or {}) do if type(result) == "table" then local label = result.is_error and "error" or "result" if type(result.tool_use_id) == "string" and result.tool_use_id ~= "" then label = label .. " [" .. result.tool_use_id .. "]" end append_labeled(self, label .. ": ", result.output) end end self.partial = false elseif kind == "message_complete" or kind == "tool_dispatch_complete" then self.partial = false else return end repaint(self) end local function sort_cards(board) local positions = {} for index, card in ipairs(board.cards) do positions[card] = index end table.sort(board.cards, function(a, b) local a_done, b_done = a.status ~= "running", b.status ~= "running" if a_done ~= b_done then return a_done end if a.sequence ~= b.sequence then return a.sequence < b.sequence end return positions[a] < positions[b] end) end function card_mt:done(status, message) self.status = GLYPHS[status] and status or "failed" if self.status ~= "completed" then marker(self, message) else self.partial = false end if self.board then sort_cards(self.board) end repaint(self) end local NOOP = setmetatable({ history = {} }, { __index = { event = function() end, done = function() end } }) local function register_sid(sid) if sid == "" then return end for _, board in pairs(boards) do for _, card in ipairs(board.cards) do local other = card.sid if other ~= "" and other ~= sid then local limit, common = math.min(#sid, #other), 0 while common < limit and sid:byte(common + 1) == other:byte(common + 1) do common = common + 1 end sid_width = math.max(sid_width, math.min(common + 1, math.max(#sid, #other))) end end end end local function body_lines(card, width, collapsed) local out, total = {}, 0 local function add(line) total = total + 1 if collapsed and #out == COLLAPSED_LINES then table.remove(out, 1) end if #out < MAX_RENDER_LINES then out[#out + 1] = BODY_INDENT .. line end end if not collapsed then local metadata_truncated = false local function add_metadata(label, text) local continuation = string.rep(" ", #label) local from, first, lines = 1, true, 0 local function add_bounded(line) if lines < MAX_PRESENTATION_LINES then lines = lines + 1 add(line) else metadata_truncated = true end end while true do local newline = text:find("\n", from, true) local last = newline and newline - 1 or #text wrap((first and label or continuation) .. text:sub(from, last), width, add_bounded) first = false if not newline then break end from = newline + 1 end end if card.system_prompt then add_metadata("system prompt: ", card.system_prompt) end if card.prompt then add_metadata("prompt: ", card.prompt) end if metadata_truncated then add("… prompt display truncated") end end for _, line in ipairs(card.history) do wrap(line, width, add) end if not collapsed and total > #out then out[#out] = BODY_INDENT .. "… retained history exceeds render limit" end return out end -- The executed source, verbatim: real newlines, one rendered row per wrapped -- source line. Collapsed shows the head of it, expanded the whole thing. local function source_lines(board, width, collapsed) local limit = collapsed and COLLAPSED_LINES or MAX_PRESENTATION_LINES local out, truncated = {}, false local function add(row) if #out < limit then out[#out + 1] = BODY_INDENT .. row else truncated = true end end for line in (board.source .. "\n"):gmatch("(.-)\n") do if truncated then break end wrap(line, width, add) end if truncated then out[#out + 1] = BODY_INDENT .. "…" end return out end local function render(board, width) local out = {} width = math.floor(tonumber(width) or 0) if width <= #BODY_INDENT then return out end local source = board.source and source_lines(board, width - #BODY_INDENT, board.collapsed) or nil if #board.cards == 0 and not source then return out end out[#out + 1] = "" if source then out[#out + 1] = require("panto").text.truncate( sanitize("⚙ " .. display_tool_name(board.tool_name)), width) for _, line in ipairs(source) do out[#out + 1] = line end end for _, card in ipairs(board.cards) do local sid = card.sid ~= "" and (" " .. card.sid:sub(1, sid_width) .. (sid_width < #card.sid and "…" or "")) or "" out[#out + 1] = require("panto").text.truncate(sanitize(string.format("%s %s%s — %s", GLYPHS[card.status] or GLYPHS.running, card.label, sid, card.status)), width) local lines = body_lines(card, width - #BODY_INDENT, board.collapsed) for _, line in ipairs(lines) do out[#out + 1] = line end end if board.pinned then out[#out + 1] = "" end return out end local function set_board_pinned(board, pinned) if board.pinned == pinned or not board.handle then return end local ok = pcall(function() board.handle:set_pinned(pinned) end) if ok then board.pinned = pinned pcall(board.handle.invalidate, board.handle) end end local function sync_board_pin(board) local workflows = type(board.key) == "string" and background_workflows[board.key] or 0 set_board_pinned(board, board.tool_active or (workflows or 0) > 0) end local function prune_boards() for key, board in pairs(boards) do if board.handle and board.handle.alive then local ok, alive = pcall(board.handle.alive, board.handle) if ok and not alive then boards[key] = nil end end end end local function new_board(key, tool_name, source) local board = { key = key, tool_name = tool_name, source = source, cards = {}, handle = nil, collapsed = tools_collapsed, next_sequence = 0, pinned = false, tool_active = false, } board.component = { render = function(_, width) return render(board, width) end } return board end function M.claim(event) prune_boards() local name = event.tool_name if not PROGRESS_TOOLS[name] then return end if type(event.set_component) ~= "function" then return end if type(event.collapsed) == "boolean" then tools_collapsed = event.collapsed end local key = event.id or event.tool_call_id or name local board = new_board(key, name, source_of(name, event.input)) local attached, handle = pcall(event.set_component, event, board.component) if not attached then return end board.handle = handle local prior = boards[key] if prior then set_board_pinned(prior, false) end boards[key] = board -- Replayed calls are already historical. Only a call dispatched during the -- live turn belongs at the bottom of the screen. if replaying then replay_board(board, type(key) == "string" and key or nil) else board.tool_active = true end sync_board_pin(board) end -- A result settles the outer tool invocation. Keep the board attached to its -- transcript entry, but leave it pinned while a background workflow it started -- is still running. function M.settle(event) local key = type(event) == "table" and (event.id or event.tool_call_id) or nil local board = key and boards[key] if board then board.tool_active = false sync_board_pin(board) end local co = coroutine.running() if co and bound[co] == key then bound[co] = nil end end function M.workflow_started(tool_call_id) if type(tool_call_id) ~= "string" or tool_call_id == "" then return end background_workflows[tool_call_id] = (background_workflows[tool_call_id] or 0) + 1 local board = boards[tool_call_id] if board then sync_board_pin(board) end end function M.workflow_finished(tool_call_id) if type(tool_call_id) ~= "string" or tool_call_id == "" then return end local count = background_workflows[tool_call_id] if count == nil then return end if count <= 1 then background_workflows[tool_call_id] = nil else background_workflows[tool_call_id] = count - 1 end local board = boards[tool_call_id] if board then sync_board_pin(board) end end function M.collapse(event) if type(event.collapsed) ~= "boolean" then return end prune_boards() tools_collapsed = event.collapsed for _, board in pairs(boards) do board.collapsed = tools_collapsed if board.handle then pcall(board.handle.invalidate, board.handle) end end end function M.bind(context) local key = type(context) == "table" and context.tool_call_id or nil if type(key) ~= "string" or key == "" then key = nil end local co = coroutine.running() if co then bound[co] = key end end function M.bind_coroutine(co, tool_call_id) if type(co) ~= "thread" then return end if type(tool_call_id) ~= "string" or tool_call_id == "" then tool_call_id = nil end bound[co] = tool_call_id end -- The outer tool call is durable child-turn metadata, not model-visible output. -- Expose it to the spawn seam without making the board state global: workflow -- callbacks run on the same coroutine as their owning tool handler. function M.tool_call_id() local co = coroutine.running() return co and bound[co] or nil end local function new_card(board, label, id, detail, presentation, sequence) if board == nil then return NOOP end local sid = type(id) == "string" and sanitize(id):sub(1, MAX_LINE_BYTES) or "" register_sid(sid) if type(sequence) ~= "number" or sequence < 1 or sequence % 1 ~= 0 then board.next_sequence = board.next_sequence + 1 sequence = board.next_sequence else board.next_sequence = math.max(board.next_sequence, sequence) end local card = setmetatable({ board = board, label = sanitize(label or "subagent"):sub(1, MAX_LINE_BYTES), sid = sid, sequence = sequence, status = "running", history = {}, history_bytes = 0, partial = false, blocks = {}, block_had_delta = {}, tools = {}, prompt = type(presentation) == "table" and type(presentation.prompt) == "string" and sanitize_multiline(presentation.prompt):sub(1, MAX_PRESENTATION_BYTES) or nil, system_prompt = type(presentation) == "table" and type(presentation.system_prompt) == "string" and sanitize_multiline(presentation.system_prompt):sub(1, MAX_PRESENTATION_BYTES) or nil, }, card_mt) board.cards[#board.cards + 1] = card if detail then marker(card, "↳ " .. sanitize(detail):sub(1, MAX_LINE_BYTES)) end repaint(card) return card end function M.card(label, id, detail, presentation) local key = bound[coroutine.running()] local board = key and boards[key] if board == nil or (board.handle and board.handle.alive and not board.handle:alive()) then return NOOP end return new_card(board, label, id, detail, presentation) end local function nonempty(value) return type(value) == "string" and value ~= "" and value or nil end -- Keep replay work finite even when a damaged child file contains pathological -- payloads. The card itself has stricter history limits; this cap bounds the -- temporary strings made while translating stored blocks into progress events. local function bounded_text(value, limit) if type(value) ~= "string" then return "" end return #value > limit and value:sub(1, limit) or value end local function text_blocks(blocks, limit) if type(blocks) ~= "table" then return "" end local pieces, bytes, seen = {}, 0, 0 for _, block in ipairs(blocks) do seen = seen + 1 if seen > MAX_REPLAY_BLOCKS then break end if type(block) == "table" and (block.type == "text" or block.type == "system") and type(block.text) == "string" then if #pieces > 0 and bytes < limit then pieces[#pieces + 1] = "\n" bytes = bytes + 1 end local room = limit - bytes if room <= 0 then break end local text = block.text:sub(1, room) pieces[#pieces + 1] = text bytes = bytes + #text if #text < #block.text then break end end end return table.concat(pieces) end local function blocks_of(message) return type(message) == "table" and type(message.blocks) == "table" and message.blocks or {} end local function message_text(message) return type(message) == "table" and text_blocks(blocks_of(message), MAX_REPLAY_BLOCK_BYTES) or "" end local function metadata_for(conv, index) if conv == nil or type(conv.message_metadata) ~= "function" then return nil end local ok, metadata = pcall(conv.message_metadata, conv, index) if not ok or type(metadata) ~= "table" or type(metadata.subagents) ~= "table" then return nil end return metadata.subagents end local function owner_of(metadata) if type(metadata) ~= "table" then return nil end return nonempty(metadata.tool_call_id) end local function child_manifest(conv, messages) for index = 1, #messages do local message = messages[index] if type(message) == "table" and message.role == "system" then local metadata = metadata_for(conv, index) local agent = metadata and nonempty(metadata.agent) if agent then local system_prompt if metadata.inline == true then system_prompt = nonempty(message_text(message)) end return agent, system_prompt end end end return nil, nil end local function result_text(block) if type(block) ~= "table" then return "" end local parts = block.parts or block.content if type(parts) ~= "table" then return "" end local pieces, bytes, seen = {}, 0, 0 for _, part in ipairs(parts) do seen = seen + 1 if seen > MAX_REPLAY_BLOCKS then break end if type(part) == "table" and type(part.text) == "string" then local room = MAX_REPLAY_BLOCK_BYTES - bytes if room <= 0 then break end local text = part.text:sub(1, room) pieces[#pieces + 1] = text bytes = bytes + #text if #text < #part.text then break end end end return table.concat(pieces, "\n") end local function is_prompt_message(message) return type(message) == "table" and message.role == "user" and message_text(message) ~= "" end -- Translate one stored child turn through the same card event path used by a -- live stream. No outer tool result is involved: all displayed content comes -- from this child's durable conversation. local function replay_turn(board, conv, messages, start, finish, info, agent, system_prompt, sequence) local metadata = metadata_for(conv, start) local prompt = message_text(messages[start]) if prompt == "" then return end local id = nonempty(info and info.id) local model = nonempty(metadata and metadata.model) or nonempty(info and info.model) local reasoning = nonempty(metadata and metadata.reasoning) or nonempty(info and info.reasoning) if model and reasoning then model = model .. " (" .. reasoning .. ")" end local card = new_card(board, agent or "subagent", id, model, { prompt = prompt, system_prompt = system_prompt, }, sequence) local pending, terminal_assistant = {}, false local block_index = 0 for index = start + 1, finish - 1 do local message = messages[index] if type(message) == "table" and message.role == "assistant" then local has_tool = false local has_response = false local seen_blocks = 0 for _, block in ipairs(blocks_of(message)) do seen_blocks = seen_blocks + 1 if seen_blocks > MAX_REPLAY_BLOCKS then break end if type(block) == "table" and block.type == "tool_use" then has_tool = true local tool_id = bounded_text(block.id, MAX_REPLAY_BLOCK_BYTES) local tool_name = bounded_text(block.name, MAX_REPLAY_BLOCK_BYTES) local input = bounded_text(block.input, MAX_REPLAY_BLOCK_BYTES) if tool_id ~= "" then pending[tool_id] = true end card:event({ type = "block_complete", block_type = "tool_use", index = block_index, id = tool_id, name = tool_name, text = input }) block_index = block_index + 1 else -- Thinking-only and other non-text assistant messages are -- still real completed responses; presentation text is -- optional and must not decide terminal status. has_response = true if type(block) == "table" and block.type == "text" then local text = bounded_text(block.text, MAX_REPLAY_BLOCK_BYTES) if text ~= "" then card:event({ type = "block_complete", block_type = "text", index = block_index, text = text }) end block_index = block_index + 1 end end end -- A response containing a tool call is not the settled assistant -- answer; a later assistant message after its results is. terminal_assistant = has_response and not has_tool elseif type(message) == "table" and message.role == "user" then local results = {} local seen_blocks = 0 for _, block in ipairs(blocks_of(message)) do seen_blocks = seen_blocks + 1 if seen_blocks > MAX_REPLAY_BLOCKS then break end if type(block) == "table" and block.type == "tool_result" then local tool_id = bounded_text(block.tool_use_id, MAX_REPLAY_BLOCK_BYTES) if tool_id ~= "" then pending[tool_id] = nil end results[#results + 1] = { tool_use_id = tool_id, output = result_text(block), is_error = block.is_error == true, } end end if #results > 0 then card:event({ type = "tool_dispatch_result", tool_results = results }) end end end local status = metadata and nonempty(metadata.status) if status ~= "completed" and status ~= "failed" and status ~= "cancelled" then status = nil end if status == nil then local settled = terminal_assistant if settled then for _ in pairs(pending) do settled = false break end end status = settled and "completed" or "failed" end card:done(status, status == "failed" and "child turn has no settled assistant response" or nil) card.replay_sequence = sequence return card end local function replay_sequence(metadata) local sequence = type(metadata) == "table" and tonumber(metadata.sequence) or nil if sequence == nil or sequence < 1 or sequence % 1 ~= 0 then return nil end return sequence end replay_board = function(board, tool_call_id) if board == nil or type(tool_call_id) ~= "string" or tool_call_id == "" then return end local path_ok, store_dir = pcall(paths.child_store_dir) if not path_ok or type(store_dir) ~= "string" or store_dir == "" then return end local panto_ok, panto = pcall(require, "panto") if not panto_ok or type(panto) ~= "table" or type(panto.file_system_jsonl_store) ~= "function" then return end local store_ok, store = pcall(panto.file_system_jsonl_store, { dir = store_dir }) if not store_ok or store == nil or type(store.list_bounded) ~= "function" or type(store.load_messages) ~= "function" then return end -- The store owns both bounds. In particular, this never asks the binding -- for an unbounded catalog or conversation and trims only after a backend -- has already applied the requested limit. local listed, infos = pcall(store.list_bounded, store, { limit = MAX_REPLAY_SESSIONS }) if not listed or type(infos) ~= "table" then return end local sessions = {} for _, info in ipairs(infos) do if type(info) == "table" and nonempty(info.id) then sessions[#sessions + 1] = info end end table.sort(sessions, function(a, b) local ac, bc = nonempty(a.created), nonempty(b.created) if ac and bc and ac ~= bc then return ac < bc end return a.id < b.id end) -- Translate each bounded session into bounded card history immediately; -- retaining whole conversations for all sessions would merely move the -- unbounded replay footprint from the store to Lua while sorting. local scratch = new_board() local cards = {} local ordinal = 0 for _, info in ipairs(sessions) do if #cards >= MAX_REPLAY_CARDS then break end local loaded, conv = pcall(store.load_messages, store, info.id, { limit = MAX_REPLAY_MESSAGES, from_end = true, max_bytes = MAX_REPLAY_MESSAGE_BYTES, }) if loaded and conv ~= nil and type(conv.messages) == "function" then local messages_ok, messages = pcall(conv.messages, conv) if messages_ok and type(messages) == "table" and #messages > 0 then local turns = {} local index = 1 while index <= #messages do if is_prompt_message(messages[index]) then local finish = index + 1 while finish <= #messages and not is_prompt_message(messages[finish]) do finish = finish + 1 end local metadata = metadata_for(conv, index) if owner_of(metadata) == tool_call_id then turns[#turns + 1] = { start = index, finish = finish, metadata = metadata, sequence = replay_sequence(metadata), } end index = finish else index = index + 1 end end if #turns > 0 then local manifest_ok, manifest_conv = pcall(store.load_messages, store, info.id, { limit = 1, role = "system", metadata_key = "subagents", max_bytes = MAX_REPLAY_MESSAGE_BYTES, }) local agent, system_prompt if manifest_ok and manifest_conv ~= nil and type(manifest_conv.messages) == "function" then local got_messages, manifest_messages = pcall(manifest_conv.messages, manifest_conv) if got_messages and type(manifest_messages) == "table" then local got_manifest got_manifest, agent, system_prompt = pcall(child_manifest, manifest_conv, manifest_messages) if not got_manifest then agent, system_prompt = nil, nil end end end for _, turn in ipairs(turns) do if #cards >= MAX_REPLAY_CARDS then break end ordinal = ordinal + 1 local card_ok, card = pcall(replay_turn, scratch, conv, messages, turn.start, turn.finish, info, agent, system_prompt, turn.sequence) if card_ok and card ~= nil then card.replay_ordinal = ordinal cards[#cards + 1] = card end end end end end end table.sort(cards, function(a, b) if a.replay_sequence and b.replay_sequence and a.replay_sequence ~= b.replay_sequence then return a.replay_sequence < b.replay_sequence end if (a.replay_sequence ~= nil) ~= (b.replay_sequence ~= nil) then return a.replay_sequence ~= nil end return a.replay_ordinal < b.replay_ordinal end) for _, card in ipairs(cards) do if #board.cards >= MAX_REPLAY_CARDS then break end card.board = board board.cards[#board.cards + 1] = card register_sid(card.sid) repaint(card) end end -- The host fires this before the first live model turn, after startup -- conversation replay has finished. Keeping the mode explicit avoids scanning -- child files for every ordinary foreground call. function M.begin_replay() replaying = true end function M.begin_live_turn() replaying = false end function M.reset() for _, board in pairs(boards) do board.tool_active = false sync_board_pin(board) end prune_boards() -- `bound` has weak coroutine keys. Foreground handlers clear themselves in -- settle(); background workflow bindings must survive turn boundaries. end return M