summaryrefslogtreecommitdiff
path: root/subagents/progress.lua
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-08-18 14:18:14 -0600
committert <t@tjp.lol>2026-08-18 14:20:28 -0600
commitcc69a2e431779528578211a5cb3385bb23e076bf (patch)
tree8833171e25bb3dfb71fc4a2e0b8fe583782da764 /subagents/progress.lua
parent372ef8ff40991644ec2654c61328f31779f4ad21 (diff)
Persist and replay subagent progress across sessions
Record durable child-turn metadata, restore bounded progress cards during startup replay, and keep settled cards attached to transcript entries. Add workflow-local Lua profiles and expose discovered agents and workflows in the session header.
Diffstat (limited to 'subagents/progress.lua')
-rw-r--r--subagents/progress.lua786
1 files changed, 627 insertions, 159 deletions
diff --git a/subagents/progress.lua b/subagents/progress.lua
index 087a3c3..fb95115 100644
--- a/subagents/progress.lua
+++ b/subagents/progress.lua
@@ -1,32 +1,27 @@
--- Live progress cards for in-flight children.
---
--- While the primary model is blocked on one of the subagents.* tools, its
--- children are streaming. This folds each child's event stream into one small
--- card and renders the set as the component of the tool-call entry that
--- started them, so several concurrent children stay visually distinct and the
--- cards disappear with the entry they belong to. The events are presentation
--- data only: nothing here ever reaches a conversation.
---
--- Linking a card to the right entry. The host fires `tool_call_complete` with
--- the tool-use id just before dispatching that call, and hands the same id to
--- the handler as `context.tool_call_id`; `claim` records the board under the
--- id, `bind` ties the running handler coroutine to it, and `card` finds the
--- board of whichever coroutine is asking. A workflow callback runs on its
--- handler's own coroutine, so a card raised deep inside one still lands on the
--- entry the model can see.
---
--- Everything degrades to a no-op card: in print mode, in a test, or under a
--- host with no component machinery there is no board to attach to, and a child
--- still runs exactly the same.
---
--- Rendering is deliberately small — a four-line ring per card, truncated to the
--- terminal width — because several children run at once and the panel must not
--- push the transcript off the screen.
+-- Expandable progress boards for in-flight children, with a bounded replay
+-- path that rebuilds durable child turns when a primary session is reopened.
-local MAX_CARD_LINES = 4
-local MAX_LINE_BYTES = 1024
+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 TOOL_PREFIX = "subagents."
+local PROGRESS_TOOLS = {
+ ["subagents.run"] = true,
+ ["subagents.lua"] = true,
+ ["subagents.workflow"] = true,
+}
local GLYPHS = {
running = "◷",
@@ -36,99 +31,162 @@ local GLYPHS = {
}
local M = {}
-
--- Boards keyed by tool-call id, and the board bound to each handler coroutine.
--- Weak keys on the second so a finished handler's binding disappears with it.
local boards = {}
local bound = setmetatable({}, { __mode = "k" })
+local tools_collapsed = true
+local sid_width = 8
+local replay_board
+local replaying = true
--- ---------------------------------------------------------------------------
--- Text handling
--- ---------------------------------------------------------------------------
-
--- Control bytes would move the cursor or confuse the renderer's width
--- accounting, so they become spaces. UTF-8 continuation bytes are >= 0x80 and
--- pass through untouched.
local function sanitize(text)
- return (text:gsub("[%z\1-\31\127]", " "))
+ return (tostring(text or ""):gsub("[%z\1-\31\127]", " "))
end
-local function truncate(text, width)
- if width == nil or width < 1 then
- return text
- end
- local length = utf8.len(text)
- if length == nil or length <= width then
- return text
- end
- return text:sub(1, (utf8.offset(text, width + 1) or (#text + 1)) - 1)
+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
+
+local function prefix(text, width)
+ if width < 1 then return "" end
+ local count, stop = 0, 0
+ local ok = pcall(function()
+ for pos in utf8.codes(text) do
+ if count == width then break end
+ stop = pos
+ count = count + 1
+ end
+ if count > 0 then stop = (utf8.offset(text, count + 1) or (#text + 1)) - 1 end
+ end)
+ if not ok then return text:sub(1, width) end
+ return text:sub(1, stop)
end
--- ---------------------------------------------------------------------------
--- Cards
--- ---------------------------------------------------------------------------
+local function wrap(text, width, emit)
+ width = math.max(1, width)
+ text = sanitize(text)
+ if text == "" then emit(""); return end
+ local rest = text
+ while rest ~= "" do
+ local piece = prefix(rest, width)
+ if piece == "" then piece = rest:sub(1, 1) end
+ emit(piece)
+ rest = rest:sub(#piece + 1)
+ 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)
+ 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)
- card.lines[#card.lines + 1] = line
- while #card.lines > MAX_CARD_LINES do
- table.remove(card.lines, 1)
- end
+ line = sanitize(line):sub(1, MAX_LINE_BYTES)
+ card.history[#card.history + 1] = line
+ card.history_bytes = card.history_bytes + #line
+ trim(card)
end
--- A discrete, already-complete line: a tool marker, a model label, an error.
-local function marker(card, prefix, text)
+local function marker(card, text)
card.partial = false
- if text == nil or text == "" then
- return
- end
- adopt(card, prefix .. sanitize(text:sub(1, MAX_LINE_BYTES)))
+ if text ~= nil and text ~= "" then adopt(card, text) end
end
--- Fold a text delta in, breaking it on newlines. A chunk with no newline leaves
--- the tail line open so the next delta continues it.
local function append_text(card, text)
- local rest = text
- while rest ~= "" do
- local newline = rest:find("\n", 1, true)
- local chunk = newline and rest:sub(1, newline - 1) or rest
- if card.partial and #card.lines > 0 then
- local index = #card.lines
- local grown = card.lines[index] .. sanitize(chunk)
- card.lines[index] = grown:sub(1, MAX_LINE_BYTES)
- elseif chunk ~= "" then
- adopt(card, sanitize(chunk:sub(1, MAX_LINE_BYTES)))
+ 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
- rest = newline and rest:sub(newline + 1) or ""
+ if not newline then break end
+ from = newline + 1
end
end
--- One run_async event. `content_delta` carries only an index, so the block type
--- comes from the `block_start` that opened it — the same flag the v0 pump kept.
-function card_mt:event(event)
- if type(event) ~= "table" then
- return
+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
- local kind = event.type
+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.text_block = event.block_type == "text"
+ self.blocks[index] = event.block_type
+ self.block_had_delta[index] = false
elseif kind == "content_delta" then
- if self.text_block and type(event.delta) == "string" 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
- marker(self, "⚒ ", event.name)
- elseif kind == "tool_dispatch_complete" or kind == "message_complete" 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
@@ -136,123 +194,533 @@ function card_mt:event(event)
repaint(self)
end
--- The child settled. Only a failure or a cancellation gets a closing line; a
--- completed child's report is the tool result the model already sees.
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.status ~= "completed" then marker(self, message) else self.partial = false end
repaint(self)
end
-local NOOP = setmetatable({ lines = {} }, {
- __index = {
- event = function() end,
- done = function() end,
- },
-})
+local NOOP = setmetatable({ history = {} }, { __index = { event = function() end, done = function() end } })
--- ---------------------------------------------------------------------------
--- Boards (one per tool-call entry)
--- ---------------------------------------------------------------------------
+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
local function render(board, width)
local out = {}
- if #board.cards == 0 or (width or 0) <= 4 then
- return out
- end
+ width = math.floor(tonumber(width) or 0)
+ if #board.cards == 0 or width <= #BODY_INDENT then return out end
out[#out + 1] = ""
for _, card in ipairs(board.cards) do
- local sid = card.sid ~= "" and (" " .. card.sid) or ""
- out[#out + 1] = truncate(string.format("%s %s%s — %s",
- GLYPHS[card.status] or GLYPHS.running, card.label, sid, card.status), width)
- for _, line in ipairs(card.lines) do
- out[#out + 1] = BODY_INDENT .. truncate(line, width - #BODY_INDENT)
- end
+ local sid = card.sid ~= "" and (" " .. card.sid:sub(1, sid_width) ..
+ (sid_width < #card.sid and "…" or "")) or ""
+ out[#out + 1] = prefix(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 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()
- local board = { cards = {}, handle = nil }
- board.component = {
- render = function(_, width)
- return render(board, width)
- end,
- }
+ local board = { cards = {}, handle = nil, collapsed = tools_collapsed, next_sequence = 0, pinned = false }
+ board.component = { render = function(_, width) return render(board, width) end }
return board
end
--- ---------------------------------------------------------------------------
--- Host wiring
--- ---------------------------------------------------------------------------
-
--- `tool_call_complete` for one of our tools: claim that entry's component so
--- the cards the handler is about to raise have somewhere to render.
function M.claim(event)
- -- The event is host userdata; an unknown field answers nil rather than
- -- raising, and activation already refused a host too old to have these.
+ prune_boards()
local name = event.tool_name
- if type(name) ~= "string" or name:sub(1, #TOOL_PREFIX) ~= TOOL_PREFIX then
- return
- end
- if type(event.set_component) ~= "function" then
- return
- end
-
- local key = event.id or name
- local board = new_board()
+ 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, board = event.id or event.tool_call_id or name, new_board()
local attached, handle = pcall(event.set_component, event, board.component)
- if not attached then
- return
- end
+ 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
+ set_board_pinned(board, true)
+ -- During startup/restart the child catalog already contains the durable
+ -- turns for this outer call. A live call normally finds nothing here; the
+ -- ownership stamp makes that distinction without parsing outer results.
+ if replaying then
+ replay_board(board, type(key) == "string" and key or nil)
+ end
+end
+
+-- A result settles the outer tool invocation. Keep the board attached to its
+-- transcript entry, but return it to that entry's original ordering.
+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 not board then return end
+ set_board_pinned(board, false)
+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
--- Tie the running handler coroutine to the entry it was dispatched for.
function M.bind(context)
local key = type(context) == "table" and context.tool_call_id or nil
- if key == nil then
- return
- end
- bound[coroutine.running()] = key
+ if type(key) ~= "string" or key == "" then key = nil end
+ local co = coroutine.running()
+ if co then bound[co] = key end
end
--- card(label, id, detail) -> card
---
--- The card of the entry whose handler is running, or an inert one when there is
--- no such entry (print mode, a plain script, a host without components).
-function M.card(label, id, detail)
- 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
+-- 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 = tostring(label or "subagent"),
- sid = type(id) == "string" and id:sub(1, 8) or "",
+ label = sanitize(label or "subagent"):sub(1, MAX_LINE_BYTES),
+ sid = sid,
+ sequence = sequence,
status = "running",
- lines = {},
+ history = {},
+ history_bytes = 0,
partial = false,
- text_block = 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
- marker(card, "↳ ", detail)
+ if detail then marker(card, "↳ " .. sanitize(detail):sub(1, MAX_LINE_BYTES)) end
repaint(card)
return card
end
--- End of turn: the entries are gone, and the primary's own tool results are the
--- durable record from here on.
+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 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_live_turn()
+ replaying = false
+end
+
function M.reset()
- boards = {}
+ for _, board in pairs(boards) do
+ set_board_pinned(board, false)
+ end
+ prune_boards()
bound = setmetatable({}, { __mode = "k" })
end
return M
+